diff --git a/.changeset/fix-print-wait-timer-overflow.md b/.changeset/fix-print-wait-timer-overflow.md new file mode 100644 index 00000000000..8f6bc2f2efe --- /dev/null +++ b/.changeset/fix-print-wait-timer-overflow.md @@ -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. diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 6112d8bf67a..2585336ce2d 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -49,6 +49,7 @@ import { resolveKimiHome, resolveLoggingConfig, resolvePrintBackgroundMode, + setClampedTimeout, type DomainEvent, type IAgentScopeHandle, type ISessionScopeHandle, @@ -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; @@ -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. } diff --git a/apps/kimi-code/test/cli/run-v2-print.test.ts b/apps/kimi-code/test/cli/run-v2-print.test.ts index 492d20ed8e8..f4927455b65 100644 --- a/apps/kimi-code/test/cli/run-v2-print.test.ts +++ b/apps/kimi-code/test/cli/run-v2-print.test.ts @@ -1,3 +1,4 @@ +import { PRINT_WAIT_CEILING_S_DEFAULT } from '@moonshot-ai/agent-core-v2'; import { describe, expect, it, vi } from 'vitest'; import { @@ -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', () => { @@ -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 }); + }); }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 94d40a8b505..023f112025b 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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`. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 4a81877cbca..02f8512fe8c 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -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` 覆盖,优先级均高于配置文件。 diff --git a/packages/agent-core-v2/src/_base/utils/promise.ts b/packages/agent-core-v2/src/_base/utils/promise.ts index 811bbda9d55..3669a548cc1 100644 --- a/packages/agent-core-v2/src/_base/utils/promise.ts +++ b/packages/agent-core-v2/src/_base/utils/promise.ts @@ -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(() => {}); export type TimeoutOutcomePromise = Promise & { @@ -17,7 +23,7 @@ export function timeoutOutcome( timeoutMs === undefined || timeoutMs <= 0 ? NEVER : new Promise((resolve) => { - timeout = setTimeout(() => { + timeout = setClampedTimeout(() => { timeout = undefined; resolve(outcome); }, timeoutMs); diff --git a/packages/agent-core-v2/src/_base/utils/timer.ts b/packages/agent-core-v2/src/_base/utils/timer.ts index 47270e384d0..08cfa4214a1 100644 --- a/packages/agent-core-v2/src/_base/utils/timer.ts +++ b/packages/agent-core-v2/src/_base/utils/timer.ts @@ -7,10 +7,25 @@ * `Disposable` owner and cleaned up for free. One instance is reused across * start/stop cycles instead of juggling raw `ReturnType` * 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 { + return setTimeout(callback, Math.min(timeoutMs, MAX_TIMER_DELAY_MS)); +} + export interface IntervalTimerOptions { readonly unref?: boolean; } diff --git a/packages/agent-core-v2/src/agent/task/printDefaults.ts b/packages/agent-core-v2/src/agent/task/printDefaults.ts index a4b133522c9..23fb69fa199 100644 --- a/packages/agent-core-v2/src/agent/task/printDefaults.ts +++ b/packages/agent-core-v2/src/agent/task/printDefaults.ts @@ -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; diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index d5078a767ac..2336e728b4e 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -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'; @@ -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); @@ -871,7 +872,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { entry.waiters.push(resolve); }), new Promise((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. + timeout = setClampedTimeout(resolve, timeoutMs); timeout.unref?.(); }), ]); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index f24b01b93f3..10529274495 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts index 1518c1f8d9e..48854287feb 100644 --- a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts +++ b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts @@ -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'; @@ -601,9 +602,9 @@ export class AgentRunBatch { 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); diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index 8ec6e8c2cbf..d15be257ca2 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -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, { diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index c40c4d6b876..9d02b6c1d60 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -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 { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 853326788d0..de2281f3810 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -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);