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
2 changes: 1 addition & 1 deletion .changeset/align-print-background-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@moonshot-ai/kimi-code": patch
---

Align the print-mode background-task policy across engines: `print_background_mode` and `print_max_turns` now take effect for `kimi -p` on the experimental engine, with the same exit / drain / steer semantics and defaults as the default engine.
Align the print-mode run lifecycle across engines: `print_background_mode` and `print_max_turns` now take effect for `kimi -p` on the experimental engine, with the same exit / drain / steer semantics and defaults as the default engine, and `kimi -p "/goal ..."` now stays alive until the goal reaches a terminal state instead of exiting after the first turn.
41 changes: 38 additions & 3 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ import {
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;

export async function runV2Print(
opts: CLIOptions,
Expand Down Expand Up @@ -373,6 +375,7 @@ async function runNativeTurn(
if (result.type === 'completed') {
const configService = app.accessor.get(IConfigService);
const taskConfig = resolveAgentTaskConfig(configService);
const goalService = agent.accessor.get(IAgentGoalService);
try {
await applyPrintBackgroundPolicy({
mode: resolvePrintBackgroundMode(configService),
Expand All @@ -384,6 +387,7 @@ async function runNativeTurn(
skipTurnId: turn.id,
warn: (message) => stderr.write(`Warning: ${message}\n`),
now: () => Date.now(),
goalActive: () => goalService.getGoal().goal?.status === 'active',
});
} catch (error) {
// A steered turn that fails fails the run (v1 parity). Anything else
Expand Down Expand Up @@ -537,9 +541,11 @@ export function createPrintTurnEndings(): PrintTurnEndings & {
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it
resolve(value);
};
const timer = setTimeout(() => {
settle(null);
}, ms);
const timer = Number.isFinite(ms)
? setTimeout(() => {
settle(null);
}, ms)
: undefined;
waiter = settle;
});
for (;;) {
Expand Down Expand Up @@ -571,11 +577,21 @@ export interface PrintBackgroundPolicyInput {
readonly skipTurnId: number;
readonly warn: (message: string) => void;
readonly now: () => number;
/**
* Reports whether an agent goal is still `active`. v2 drives goal
* continuation as new turns (v1 keeps a single turn alive), so a `-p` goal
* run must stay alive until the goal leaves `active`, independent of the
* background policy.
*/
readonly goalActive?: () => boolean;
}

/**
* Apply the print-mode (`kimi -p`) background-task policy after the main turn
* completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`:
* - 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
Expand All @@ -587,6 +603,25 @@ export interface PrintBackgroundPolicyInput {
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 ended = await input.turnEndings.next(
Math.min(goalDeadline - input.now(), GOAL_WAIT_POLL_MS),
input.skipTurnId,
);
Comment on lines +613 to +616

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 Wake the goal wait on terminal goal updates

When a goal stops without producing another turn.ended after this wait starts (for example, a continuation launch fails and the goal service pauses the goal via goal.updated after handling the same turn end), this loop waits only on turnEndings.next. If the current turn end was already consumed before goalActive() observes the pause, no further turn ending arrives, so kimi -p "/goal ..." remains alive until print_wait_ceiling_s (default 3600s) instead of immediately printing the paused/blocked summary; the wait should also be woken by terminal goal updates or otherwise re-check after the goal service settles.

Useful? React with 👍 / 👎.

if (ended === null && input.now() >= goalDeadline) {
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();
Expand Down
80 changes: 80 additions & 0 deletions apps/kimi-code/test/cli/run-v2-print.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,86 @@ describe('applyPrintBackgroundPolicy', () => {
}),
).rejects.toThrow(PrintSteeredTurnFailedError);
});

it('waits for goal continuation turns before applying the mode', async () => {
let active = true;
let consumed = 0;
const drain = vi.fn(async () => {});
await applyPrintBackgroundPolicy({
mode: 'drain',
ceilingS: 60,
maxTurns: 50,
countPending: () => 0,
drain,
turnEndings: scriptedTurnEndings([
{ event: ending(2), apply: () => { consumed += 1; } },
{
event: ending(3),
apply: () => {
consumed += 1;
active = false;
},
},
]),
skipTurnId: 1,
warn: () => {},
now: () => Date.now(),
goalActive: () => active,
});
// Both continuation turns ended before the mode ('drain') ran.
expect(consumed).toBe(2);
expect(drain).toHaveBeenCalledTimes(1);
});

it('warns and returns when the goal wait hits the ceiling', async () => {
let now = 0;
const warn = vi.fn();
await applyPrintBackgroundPolicy({
mode: 'exit',
ceilingS: 10,
maxTurns: 50,
countPending: () => 0,
drain: async () => {},
// No continuation turn ever ends; the poll interval elapses each time.
turnEndings: {
next: async () => {
now = 10_001;
return null;
},
},
skipTurnId: 1,
warn,
now: () => now,
goalActive: () => true,
});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toContain('goal wait ceiling');
});

it('exits the goal wait promptly when the goal settles without a turn ending', async () => {
let active = true;
const warn = vi.fn();
await applyPrintBackgroundPolicy({
mode: 'exit',
ceilingS: 3600,
maxTurns: 50,
countPending: () => 0,
drain: async () => {},
// Poll interval elapses; the goal settles (paused/blocked) mid-wait
// without producing a turn.ended.
turnEndings: {
next: async () => {
active = false;
return null;
},
},
skipTurnId: 1,
warn,
now: () => Date.now(),
goalActive: () => active,
});
expect(warn).not.toHaveBeenCalled();
});
});

describe('createPrintTurnEndings', () => {
Expand Down
Loading