Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/align-v2-print-run-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Align `kimi -p` on the experimental engine with the default engine's run lifecycle: the print background policy now defaults to steer with no practical turn or time cap, background task and per-turn step limits are lifted unless configured, and the run stays alive while cron tasks still have future fires so their steered turns can run.
7 changes: 4 additions & 3 deletions apps/kimi-code/src/cli/prompt-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ interface HookResultEventLike {

/**
* Structural retry shape the renderer reads. Mirrors the v1 SDK
* `turn.step.retrying` event fields the stream-json meta line surfaces. Only
* the v1 driver forwards retries to `writeRetrying`; the v2 runner currently
* just discards the failed attempt's partial output and stays silent.
* `turn.step.retrying` event fields the stream-json meta line surfaces. Both
* drivers forward retries to `writeRetrying`: v1 from its SDK event stream,
* v2 from the native `turn.step.retrying` `DomainEvent` (same field names),
* after discarding the failed attempt's partial output.
*/
interface RetryingEventLike {
readonly failedAttempt: number;
Expand Down
137 changes: 102 additions & 35 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ import {
IConfigService,
IEventBus,
IOAuthToolkit,
ISessionCronService,
ISessionIndex,
ISessionLifecycleService,
ITelemetryService,
PRINT_MAX_TURNS_DEFAULT,
PRINT_WAIT_CEILING_S_DEFAULT,
agentCatalogRuntimeOptionsSeed,
applyPrintModeConfigDefaults,
bootstrap,
createCloudAppender,
ensureMainAgent,
Expand Down Expand Up @@ -90,10 +94,14 @@ import {
} from '../prompt-render';

const PROMPT_UI_MODE = 'print';
const DEFAULT_PRINT_WAIT_CEILING_S = 3600;
const DEFAULT_PRINT_MAX_TURNS = 50;
/** Re-check `goalActive` at least this often while waiting for goal turns. */
const GOAL_WAIT_POLL_MS = 250;
/**
* Slack on top of a scheduled cron fire time while waiting for the steered
* turn: covers the 1s tick poll interval plus fire → inject → turn-launch
* latency.
*/
const CRON_FIRE_GRACE_MS = 5_000;

export async function runV2Print(
opts: CLIOptions,
Expand Down Expand Up @@ -136,6 +144,10 @@ export async function runV2Print(

const configService = app.accessor.get(IConfigService);
await configService.ready;
// Print-mode config defaults (task timeouts / loop step cap / subagent
// timeout → unbounded) before anything resolves a session; only keys the
// user left unset are filled, in the memory layer.
await applyPrintModeConfigDefaults(configService);
const defaultModel = configService.get<string>('defaultModel') ?? undefined;
let telemetryEnabled = true;
try {
Expand Down Expand Up @@ -449,18 +461,20 @@ async function runNativeTurn(
const configService = app.accessor.get(IConfigService);
const taskConfig = resolveAgentTaskConfig(configService);
const goalService = agent.accessor.get(IAgentGoalService);
const cronService = session.accessor.get(ISessionCronService);
try {
await applyPrintBackgroundPolicy({
mode: resolvePrintBackgroundMode(configService),
ceilingS: taskConfig?.printWaitCeilingS ?? DEFAULT_PRINT_WAIT_CEILING_S,
maxTurns: taskConfig?.printMaxTurns ?? DEFAULT_PRINT_MAX_TURNS,
ceilingS: taskConfig?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT,
maxTurns: taskConfig?.printMaxTurns ?? PRINT_MAX_TURNS_DEFAULT,
countPending: () => countPendingBackgroundTasks(session),
drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS),
turnEndings,
skipTurnId: turn.id,
warn: (message) => stderr.write(`Warning: ${message}\n`),
now: () => Date.now(),
goalActive: () => goalService.getGoal().goal?.status === 'active',
cronNextFireAt: () => cronService.getNextFireTime(),
});
} catch (error) {
// A steered turn that fails fails the run (v1 parity). Anything else
Expand Down Expand Up @@ -543,6 +557,7 @@ function dispatchNativeEvent(
return;
case 'turn.step.retrying':
writer.discardAssistant();
writer.writeRetrying(event);
return;
case 'assistant.delta':
writer.writeAssistantDelta(event.delta);
Expand Down Expand Up @@ -657,54 +672,106 @@ export interface PrintBackgroundPolicyInput {
* background policy.
*/
readonly goalActive?: () => boolean;
/**
* Reports the next scheduled cron fire time (epoch ms), or `null` when no
* cron task has a future fire. While it returns non-null the policy keeps
* the process alive — the cron tick timer itself is unref'd — waiting for
* the fire to steer a new turn, then re-evaluating (a fired one-shot task
* disappears; a recurring one reports its advanced next fire). Cron
* liveness is independent of the background mode: it applies under
* `exit`/`drain` too (v1 parity). Omitted = no cron waiting.
*/
readonly cronNextFireAt?: () => number | null;
}

/**
* Apply the print-mode (`kimi -p`) background-task policy after the main turn
* completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`:
* Apply the print-mode (`kimi -p`) background-resource policy after the main
* turn completes. A single loop re-evaluates the Session's live resources in
* order on every round and stays alive while any of them is pending:
* - goal : while a goal is `active`, keep waiting for its continuation
* turns (bounded by `ceilingS` as a safety net), regardless of
* the background mode; the goal summary drives the exit code.
* - 'exit' : return immediately (default).
* - 'drain' : suppress + drain background tasks, then return.
* - 'steer' : while background tasks are still pending, stay alive so task
* completions steer new main turns; return once quiescent, or
* when the wall-clock ceiling (`ceilingS`) or the turn cap
* (`maxTurns`) is reached. A steered turn that does not complete
* fails the run.
* - cron : while `cronNextFireAt` reports a future fire, keep waiting —
* the cron tick timer is unref'd, so the process must hold the
* event loop itself (v1 parity, independent of the mode). The
* fire steers a new turn; a steered turn that does not complete
* fails the run. Each round re-reads the next fire time, so a
* fired one-shot task ends the wait while a recurring one keeps
* it. A fire time that stays unchanged and in the past across
* two consecutive rounds means the tick is wedged: warn once and
* stop cron waiting instead of spinning.
* - mode : 'exit' → return immediately;
* 'drain' → suppress + drain background tasks, then return;
* 'steer' → while background tasks are still pending, stay alive
* so task completions steer new main turns; return once
* quiescent, or when the wall-clock ceiling (`ceilingS`) or the
* turn cap (`maxTurns`) is reached. A steered turn that does not
* complete fails the run.
* The steer ceiling deadline is set once on entry, so goal/cron waiting
* consumes the same budget.
*/
export async function applyPrintBackgroundPolicy(
input: PrintBackgroundPolicyInput,
): Promise<void> {
if (input.goalActive !== undefined) {
const goalDeadline = input.now() + input.ceilingS * 1000;
while (input.goalActive()) {
// Also wake on a short poll: a goal can leave `active` without any
// further turn.ended (budget block at a turn boundary, or a pause after
// a continuation-launch failure), which would otherwise hang the run
// until the ceiling.
const deadline = input.now() + input.ceilingS * 1000;
let turns = 0;
// Cron anti-spin guard: the last fire time seen already in the past. Two
// consecutive rounds with the same past fire time mean the tick never ran.
let lastPastFireAt: number | undefined;
let cronWedged = false;
for (;;) {
// (a) goal: while a goal is `active`, keep waiting for its continuation
// turns. Also wake on a short poll: a goal can leave `active` without any
// further turn.ended (budget block at a turn boundary, or a pause after a
// continuation-launch failure), which would otherwise hang the run until
// the ceiling. A continuation turn that does not complete pauses/blocks
// the goal, so the condition exits on the next check.
while (input.goalActive?.() === true) {
const ended = await input.turnEndings.next(
Math.min(goalDeadline - input.now(), GOAL_WAIT_POLL_MS),
Math.min(deadline - input.now(), GOAL_WAIT_POLL_MS),
input.skipTurnId,
);
if (ended === null && input.now() >= goalDeadline) {
if (ended === null && input.now() >= deadline) {
input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`);
return;
}
// A continuation turn that does not complete pauses/blocks the goal, so
// the loop condition exits on the next check.
}
}
if (input.mode === 'exit') return;
if (input.mode === 'drain') {
await input.drain();
return;
}

// 'steer'
const deadline = input.now() + input.ceilingS * 1000;
let turns = 0;
for (;;) {
// (b) cron: keep the process alive until the pending fire steered a turn
// (one-shot tasks vanish after firing; recurring ones advance their next
// fire), then re-evaluate from the top.
if (!cronWedged && input.cronNextFireAt !== undefined) {
const fireAt = input.cronNextFireAt();
if (fireAt !== null) {
if (fireAt <= input.now() && lastPastFireAt === fireAt) {
cronWedged = true;
input.warn(
'print cron wait: next fire time stuck in the past; cron tick appears wedged, giving up on cron',
);
} else {
if (fireAt <= input.now()) lastPastFireAt = fireAt;
const ended = await input.turnEndings.next(
Math.max(fireAt - input.now(), 0) + CRON_FIRE_GRACE_MS,
input.skipTurnId,
Comment on lines +753 to +755

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap cron waits at the print ceiling

When the next cron fire is beyond printWaitCeilingS, this waits until fireAt + grace instead of the remaining ceiling budget. Because createPrintTurnEndings.next() uses a ref'd timer, a run configured with a small ceiling (for example 60s) and a cron scheduled hours later will stay alive until that cron fire rather than honoring the ceiling; clamp this wait to deadline - now and finish/warn when the ceiling is reached.

Useful? React with 👍 / 👎.

);
if (ended !== null && ended.reason !== 'completed') {
throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended));
}
// Fire observed (or its grace elapsed without a turn): re-read the
// next fire time from the top.
continue;
}
}
}

// (c) background-task mode.
if (input.mode === 'exit') return;
if (input.mode === 'drain') {
await input.drain();
return;
}

// 'steer'
turns += 1;
if (input.now() >= deadline) {
input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`);
Expand Down Expand Up @@ -749,7 +816,7 @@ async function drainBackgroundTasks(
const ceilingMs =
typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0
? ceilingS * 1000
: DEFAULT_PRINT_WAIT_CEILING_S * 1000;
: PRINT_WAIT_CEILING_S_DEFAULT * 1000;

const deadline = Date.now() + ceilingMs;
const seen = new Set<string>();
Expand Down
Loading
Loading