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/fix-print-wait-timer-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix kimi -p exiting right after the main turn instead of waiting for background tasks and subagents to finish.
11 changes: 9 additions & 2 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
resolveKimiHome,
resolveLoggingConfig,
resolvePrintBackgroundMode,
setClampedTimeout,
type DomainEvent,
type IAgentScopeHandle,
type ISessionScopeHandle,
Expand Down Expand Up @@ -621,8 +622,13 @@ export function createPrintTurnEndings(): PrintTurnEndings & {
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it
resolve(value);
};
// A delay beyond the host timer ceiling (an explicit
// `print_wait_ceiling_s` or a far-future cron fire can still reach
// it) is clamped by `setClampedTimeout`, so the timer can expire
// early: the loop below treats that as a chunk boundary and
// re-arms against the real deadline.
const timer = Number.isFinite(ms)
? setTimeout(() => {
? setClampedTimeout(() => {
settle(null);
}, ms)
: undefined;
Expand All @@ -636,7 +642,8 @@ export function createPrintTurnEndings(): PrintTurnEndings & {
const ms = deadlineAt - Date.now();
if (ms <= 0) return null;
const ending = await waitOnce(ms);
if (ending === null) return null;
// Timer-chunk boundary, not the real deadline: keep waiting.
if (ending === null) continue;
if (ending.turnId !== skipTurnId) return ending;
// The skipped turn's own ending: keep waiting within the same budget.
}
Expand Down
49 changes: 49 additions & 0 deletions apps/kimi-code/test/cli/run-v2-print.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PRINT_WAIT_CEILING_S_DEFAULT } from '@moonshot-ai/agent-core-v2';
import { describe, expect, it, vi } from 'vitest';

import {
Expand Down Expand Up @@ -423,6 +424,36 @@ describe('applyPrintBackgroundPolicy', () => {
expect(cronFirstCallAtConsumed).toBe(2);
expect(cronNextFireAt).toHaveBeenCalled();
});

it('steer keeps waiting under the default ceiling with tasks pending', async () => {
let pending = 1;
const turnEndings = createPrintTurnEndings();
const policy = applyPrintBackgroundPolicy({
mode: 'steer',
ceilingS: PRINT_WAIT_CEILING_S_DEFAULT,
maxTurns: 50,
countPending: () => pending,
drain: async () => {},
turnEndings,
skipTurnId: 1,
warn: () => {},
now: () => Date.now(),
});
// The default ceiling is ~24.8 days: with tasks still pending the wait
// must not return null early and end the run here.
const early = await Promise.race([
policy.then(() => 'returned' as const),
new Promise<'waiting'>((resolve) => setTimeout(() => {
resolve('waiting');
}, 50)),
]);
expect(early).toBe('waiting');
// A background task completion steered a new turn; once it ends and no
// tasks remain, the policy returns.
pending = 0;
turnEndings.push(ending(2));
await policy;
});
});

describe('createPrintTurnEndings', () => {
Expand Down Expand Up @@ -452,4 +483,22 @@ describe('createPrintTurnEndings', () => {
endings.push(ending(4));
await expect(pending).resolves.toMatchObject({ turnId: 4 });
});

it('does not resolve null early when the budget exceeds the timer ceiling', async () => {
const endings = createPrintTurnEndings();
// 10 years in ms — beyond Node's 2^31-1 ms setTimeout ceiling, which an
// explicit `print_wait_ceiling_s` can still reach.
const pending = endings.next(10 * 365 * 24 * 3600 * 1000, 1);
// Node clamps a >2^31-1 ms setTimeout to 1ms; the wait must ride out the
// overflow in chunks instead of resolving null at once.
const early = await Promise.race([
pending,
new Promise<'waiting'>((resolve) => setTimeout(() => {
resolve('waiting');
}, 50)),
]);
expect(early).toBe('waiting');
endings.push(ending(7));
await expect(pending).resolves.toMatchObject({ turnId: 7 });
});
});
2 changes: 1 addition & 1 deletion docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ Retries only apply to transient failures — connection errors, timeouts, HTTP 4
| `bash_auto_background_on_timeout` | `boolean` | `true` | When a foreground `Bash` command hits its timeout, move it to a background task instead of killing it — the agent is notified when it completes, and the backgrounded command is bounded by the `bash_task_timeout_s` default background timeout. Set to `false` to kill timed-out foreground commands instead |
| `bash_task_timeout_s` | `integer` | `600` | Default timeout (seconds) for background `Bash` tasks when the call omits `timeout`; also used to re-arm foreground commands moved to the background on timeout. `0` means no timeout — the task runs until it exits or the model stops it. Explicit per-call `timeout` values are unaffected. In print mode (`kimi -p`) the default is `0` unless explicitly set |
| `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"steer"` | Print mode (`kimi -p`) only. Governs how pending background tasks are handled once the main agent's turn ends: `"exit"` exits immediately; `"drain"` waits for every background task to reach a terminal state before exiting (results are not fed back to the main agent); `"steer"` stays alive so a completing background task — like a background subagent — injects a synthetic user message that steers the main agent into a new turn, looping until a turn ends with no pending background tasks or a limit is hit. Takes precedence over the `keep_alive_on_exit` print fallback |
| `print_wait_ceiling_s` | `integer` | `315360000` | In print mode (`kimi -p`), the wall-clock ceiling (seconds) for the wait/steer loop when `print_background_mode` is `"drain"` or `"steer"` (the default is 10 years — effectively unbounded). Has no effect outside print mode or when it is `"exit"` |
| `print_wait_ceiling_s` | `integer` | `2147483` | In print mode (`kimi -p`), the wall-clock ceiling (seconds) for the wait/steer loop when `print_background_mode` is `"drain"` or `"steer"` (the default is ~24.8 days — effectively unbounded). Has no effect outside print mode or when it is `"exit"` |
| `print_max_turns` | `integer` | `100000` | In print mode (`kimi -p`) with `print_background_mode = "steer"`, the maximum number of new turns that may be triggered by background-task completions, to keep the steering loop bounded (the default is effectively unbounded) |

`keep_alive_on_exit` can be overridden by the `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable, and `max_running_tasks` by `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS`; both take higher priority than `config.toml`.
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ max_output_size = 8192
| `bash_auto_background_on_timeout` | `boolean` | `true` | 前台 `Bash` 命令触及超时时间时,将其转为后台任务而不是直接终止:命令完成时 agent 会收到通知,转入后台的命令受 `bash_task_timeout_s` 默认后台超时约束。设为 `false` 则恢复超时即终止的行为 |
| `bash_task_timeout_s` | `integer` | `600` | 后台 `Bash` 任务在调用未传 `timeout` 时的默认超时(秒);前台命令超时转后台后也按此值重新计时。`0` 表示无超时——任务一直运行到自行结束或被模型手动停止。显式传入的 `timeout` 不受影响。在 print 模式(`kimi -p`)下未显式设置时默认为 `0` |
| `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"steer"` | 仅 print 模式(`kimi -p`)生效,决定主 agent 的 turn 结束后如何处理未返回的后台任务:`"exit"` 立即退出;`"drain"` 退出前等待所有后台任务进入终态(结果不回馈给主 agent);`"steer"` 不退出,让后台任务完成时像后台子代理一样以合成 user 消息 steer 主 agent 进入新 turn,直到某 turn 结束时无未决后台任务或触及上限。设置后优先级高于 `keep_alive_on_exit` 的 print 回退 |
| `print_wait_ceiling_s` | `integer` | `315360000` | print 模式(`kimi -p`)下,`print_background_mode` 为 `"drain"` 或 `"steer"` 时,等待/steer 循环的墙钟上限(秒;默认 10 年,近似不设限)。在非 print 模式或 `"exit"` 时无效 |
| `print_wait_ceiling_s` | `integer` | `2147483` | print 模式(`kimi -p`)下,`print_background_mode` 为 `"drain"` 或 `"steer"` 时,等待/steer 循环的墙钟上限(秒;默认约 24.8 天,近似不设限)。在非 print 模式或 `"exit"` 时无效 |
| `print_max_turns` | `integer` | `100000` | print 模式(`kimi -p`)且 `print_background_mode = "steer"` 时,允许由后台任务完成触发的新 turn 的最大数量,防止 steer 循环失控(默认值近似不设限) |

`keep_alive_on_exit` 可被环境变量 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖,`max_running_tasks` 可被 `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` 覆盖,优先级均高于配置文件。
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-core-v2/src/_base/utils/promise.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/**
* Timeout outcome promise — resolves with a fixed value after a delay.
*
* The timer goes through `setClampedTimeout`, so huge ("effectively
* unbounded") timeouts still mean a long wait instead of overflowing into an
* immediate fire.
*/

import { setClampedTimeout } from './timer';

const NEVER = new Promise<never>(() => {});

export type TimeoutOutcomePromise<Outcome> = Promise<Outcome> & {
Expand All @@ -17,7 +23,7 @@ export function timeoutOutcome<Outcome>(
timeoutMs === undefined || timeoutMs <= 0
? NEVER
: new Promise((resolve) => {
timeout = setTimeout(() => {
timeout = setClampedTimeout(() => {
timeout = undefined;
resolve(outcome);
}, timeoutMs);
Expand Down
15 changes: 15 additions & 0 deletions packages/agent-core-v2/src/_base/utils/timer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@
* `Disposable` owner and cleaned up for free. One instance is reused across
* start/stop cycles instead of juggling raw `ReturnType<typeof setInterval>`
* values. Mirrors VS Code's `IntervalTimer`.
*
* `setClampedTimeout` is a `setTimeout` whose delay is clamped to
* `MAX_TIMER_DELAY_MS`, the largest delay the host timer accepts: beyond it
* the delay overflows into an immediate (~1ms) fire, so huge ("effectively
* unbounded") timeouts would fire at once instead of waiting. Callers that
* outlive the clamp (~24.8 days) re-arm.
*/

import type { IDisposable } from '#/_base/di/lifecycle';

export const MAX_TIMER_DELAY_MS = 0x7fffffff;

export function setClampedTimeout(
callback: () => void,
timeoutMs: number,
): ReturnType<typeof setTimeout> {
return setTimeout(callback, Math.min(timeoutMs, MAX_TIMER_DELAY_MS));
}

export interface IntervalTimerOptions {
readonly unref?: boolean;
}
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-core-v2/src/agent/task/printDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,20 @@
* default). Because the memory layer shadows a whole section on read, each
* patch spreads the section's current effective value so sibling user keys
* stay visible. Explicit user config always wins over these defaults.
*
* The wait ceiling defaults to the host timer's maximum delay
* (`MAX_TIMER_DELAY_MS`, ~24.8 days) expressed in seconds: effectively
* unbounded, while `ceilingS * 1000` can never overflow a timer.
*/

import { MAX_TIMER_DELAY_MS } from '#/_base/utils/timer';
import { ConfigTarget, type ConfigInspectValue, type IConfigService } from '#/app/config/config';
import { LOOP_CONTROL_SECTION } from '#/agent/loop/configSection';
import { SUBAGENT_SECTION } from '#/session/subagent/configSection';

import { LEGACY_BACKGROUND_SECTION, TASK_SECTION } from './configSection';

export const PRINT_WAIT_CEILING_S_DEFAULT = 315_360_000;
export const PRINT_WAIT_CEILING_S_DEFAULT = Math.floor(MAX_TIMER_DELAY_MS / 1000);

export const PRINT_MAX_TURNS_DEFAULT = 100_000;

Expand Down
8 changes: 6 additions & 2 deletions packages/agent-core-v2/src/agent/task/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
abortable,
userCancellationReason,
} from '#/_base/utils/abort';
import { setClampedTimeout } from '#/_base/utils/timer';
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
import { IEventBus } from '#/app/event/eventBus';
import { Error2, ErrorCodes } from '#/errors';
Expand Down Expand Up @@ -685,7 +686,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
}

private armManagerTimeout(entry: ManagedTask, timeoutMs: number): void {
entry.timeoutHandle = setTimeout(() => {
entry.timeoutHandle = setClampedTimeout(() => {
entry.timeoutHandle = undefined;
if (this.canAutoBackgroundOnTimeout(entry)) {
this.detachEntry(entry, true);
Expand Down Expand Up @@ -871,7 +872,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
entry.waiters.push(resolve);
}),
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs);
// A clamped early return just makes callers (e.g. the print drain
// loop) re-poll — the task may still be running, which the caller
// observes from the returned info.
Comment on lines +875 to +877

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Move the wait note out of the method body

In packages/agent-core-v2 implementation files, the local guide requires comments to live only in the top-of-file block; this new explanatory note is inside wait() beside a statement, so it violates the package convention and should be removed or folded into the file header if still needed.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

timeout = setClampedTimeout(resolve, timeoutMs);
timeout.unref?.();
}),
]);
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,9 @@ export * from '#/agent/fullCompaction/types';
export * from '#/agent/llmRequester/llmRequester';
export * from '#/agent/llmRequester/llmRequesterService';
export * from '#/agent/llmRequester/llmRequestOps';
export * from '#/_base/utils/promise';
export * from '#/_base/utils/retry';
export * from '#/_base/utils/timer';
import '#/agent/loop/configSection';
export * from '#/agent/loop/loop';
export * from '#/agent/loop/loopService';
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-core-v2/src/session/swarm/agentRunBatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { type TokenUsage } from '#/kosong/contract/usage';
import * as retry from 'retry';

import { isUserCancellation } from '#/_base/utils/abort';
import { setClampedTimeout } from '#/_base/utils/timer';
import { BugIndicatingError, Error2, ErrorCodes } from '#/errors';
import type { SessionSwarmRunResult, SessionSwarmTask } from './sessionSwarm';

Expand Down Expand Up @@ -601,9 +602,9 @@ export class AgentRunBatch<T> {
attempt.controller.abort(task.signal?.reason);
};
const timeout =
task.timeout === undefined
task.timeout === undefined || task.timeout <= 0
? undefined
: setTimeout(() => {
: setClampedTimeout(() => {
attempt.timedOut = true;
attempt.controller.abort(new Error('Aborted'));
}, task.timeout);
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-core-v2/test/agent/task/taskService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,23 @@ describe('AgentTaskService', () => {
await svc.stop(id);
});

it('wait with a timeout beyond the timer ceiling does not resolve immediately', async () => {
const svc = ix.get(IAgentTaskService);
const taskId = svc.registerTask(fakeProcessTask());
// 10 years in ms overflows Node's setTimeout ceiling (2^31-1 ms) into a
// 1ms fire; wait must clamp instead of returning at once.
const waited = svc.wait(taskId, 10 * 365 * 24 * 3600 * 1000);
const early = await Promise.race([
waited.then(() => 'returned' as const),
new Promise<'waiting'>((resolve) => setTimeout(() => {
resolve('waiting');
}, 50)),
]);
expect(early).toBe('waiting');
await svc.stop(taskId);
await expect(waited).resolves.toMatchObject({ taskId });
});

function capturingWire(): { dispatched: { type: string; payload: unknown }[] } {
const dispatched: { type: string; payload: unknown }[] = [];
ix.stub(IWireService, {
Expand Down
29 changes: 29 additions & 0 deletions packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,35 @@ describe('AgentRunBatch scheduling contract', () => {
}
});

it('a non-positive task timeout means unbounded (v1 parity)', async () => {
vi.useFakeTimers();
try {
const { runBatch, attempts } = createMockAgentRunBatchRunner();
const running = runBatch([{ ...queuedAgentRunTask(1), timeout: 0 }], {
signal: new AbortController().signal,
});

await vi.advanceTimersByTimeAsync(0);
attempts[0]!.markReady();
// Print mode fills the subagent timeout with 0 = unbounded; it must not
// arm an immediate abort.
await vi.advanceTimersByTimeAsync(60_000);

attempts[0]!.outcome.resolve({
task: attempts[0]!.task,
agentId: 'agent-1',
status: 'completed',
result: 'done',
});
await vi.advanceTimersByTimeAsync(0);
await expect(running).resolves.toMatchObject([
{ task: { data: 1 }, agentId: 'agent-1', status: 'completed' },
]);
} finally {
vi.useRealTimers();
}
});

it('does not spend task timeout while the task is queued', async () => {
vi.useFakeTimers();
try {
Expand Down
10 changes: 5 additions & 5 deletions packages/node-sdk/src/sdk-rpc-client-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1994,11 +1994,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
if (seen.has(task.taskId)) continue;
seen.add(task.taskId);
suppressions.push(tasks.suppressTerminalNotification(task.taskId));
// The engine's `wait` arms a raw `setTimeout(timeoutMs)`, which
// overflows above the ~24.8-day timer ceiling into an immediate
// resolve (v1's `timeoutOutcome` clamps to the same bound) — the
// default print ceiling is 10 years, so clamp here. The outer loop
// re-enumerates after an early return, so semantics are unchanged.
// A configured ceiling above the ~24.8-day timer ceiling overflows
// a raw `setTimeout` into an immediate resolve — clamp here (the
// engine's `wait` clamps too; this keeps the caller side correct
// regardless). The outer loop re-enumerates after an early return,
// so semantics are unchanged.
const remaining = Math.min(Math.max(1, deadline - Date.now()), MAX_TIMER_DELAY_MS);
const waiter = tasks.wait(task.taskId, remaining);
batch.push(waiter);
Expand Down
Loading