diff --git a/.changeset/queue-cron-while-goal-paused.md b/.changeset/queue-cron-while-goal-paused.md new file mode 100644 index 0000000000..f672fcc9fd --- /dev/null +++ b/.changeset/queue-cron-while-goal-paused.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep scheduled prompts queued without model calls while a goal is paused. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index fd795e838d..0b19357463 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -531,7 +531,13 @@ function runPromptTurn( if (settled || activeTurnId !== undefined) return; // A task whose expression has no future fire can never trigger a // turn; don't hold the run open for it. - if (tasks.some((task) => task.nextFireAt !== null)) { + // CronManager deliberately suppresses fires while a goal is paused. + // Treat those queued tasks as ineligible here too, otherwise the + // print-only keep-alive waits for an event the scheduler cannot emit. + if ( + goal?.status !== 'paused' && + tasks.some((task) => task.nextFireAt !== null) + ) { holdEventLoop(); return; } diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index d71e88b23f..7cffef7beb 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1459,4 +1459,52 @@ describe('runPrompt', () => { expect(stdout.text()).toBe('• hello world\n\n'); expect(mocks.harnessClose).toHaveBeenCalled(); }); + + it('does not wait for cron tasks while the goal is paused', async () => { + mocks.session.getGoal.mockResolvedValue({ + goal: { status: 'paused' }, + } as never); + mocks.session.getCronTasks + .mockResolvedValueOnce({ + tasks: [ + { + id: '3f9a1c2e', + cron: '*/5 * * * *', + recurring: true, + createdAt: 1, + lastFiredAt: undefined, + nextFireAt: Date.now() + 60_000, + }, + ], + } as never) + .mockResolvedValue({ tasks: [] } as never); + + let settled = false; + const run = runPrompt(opts(), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }).then(() => { + settled = true; + }); + + await waitForAssertion(() => { + expect(mocks.session.getCronTasks).toHaveBeenCalledTimes(1); + }); + const settledWhilePaused = settled; + + // Let the pre-fix implementation clean up instead of leaving its ref'ed + // keep-alive interval behind after the RED assertion. + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'goal.updated', + snapshot: { status: 'paused' }, + change: { kind: 'paused' }, + }), + ); + } + await run; + + expect(settledWhilePaused).toBe(true); + }); }); diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 8b412b5366..622abe065d 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -127,6 +127,8 @@ Scheduled task tools allow the Agent to re-inject a prompt into the current sess To prevent all users from firing at the same time on the hour, the scheduler applies deterministic jitter: recurring tasks are shifted forward by `min(10% of the period, 15 minutes)`; one-time tasks that fall exactly on `:00` or `:30` are moved forward by up to 90 seconds. If the scheduler misses several fire times (e.g., because the laptop was sleeping), it fires only once on wake-up — the prompt is wrapped in a `` envelope with a `coalescedCount`. Recurring tasks that have been alive for more than 7 days fire one final time with `stale="true"` and are then automatically deleted; call `CronCreate` again to keep them. +When the current goal is paused, due scheduled prompts stay queued without making model calls. They become eligible to fire after you run `/goal resume`. + **`CronList`** is a read-only tool that accepts no parameters. It returns one record per active task with fields: `id`, `cron`, `humanSchedule`, `nextFireAt`, `recurring`, `ageDays`, and `stale`. Records are separated by `---` and sorted by schedule time. **`CronDelete`** accepts a single `id`. For recurring tasks, all future fires stop immediately; for one-time tasks, the pending fire is cancelled. One-time tasks that have already fired are auto-deleted, so calling `CronDelete` on an already-fired one-time task returns `No cron job with id ...`. Deletion is irreversible — use `CronCreate` again to restore. `CronDelete` is also blocked in Plan mode. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 009ff3d052..c4c8fbddb3 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -127,6 +127,8 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 为避免整批用户在整点同时触发,调度器会做确定性抖动:周期任务向后偏移 `min(周期的 10%, 15 分钟)`;一次性任务若恰好落在 `:00` 或 `:30` 则向前提前最多 90 秒。如果调度器错过了若干触发时刻(如笔记本合盖),唤醒后只会触发一次,prompt 会包裹在 `` 信封里并附带 `coalescedCount`。周期任务存活超过 7 天后会以 `stale="true"` 做最后一次触发后自动删除;想继续保留时,再次调用 `CronCreate` 即可。 +当前目标暂停时,已到期的定时 prompt 会留在队列中,且不会触发模型调用。运行 `/goal resume` 后,这些 prompt 会重新具备触发条件。 + **`CronList`** 是只读工具,不接受任何参数。为每个生效中的任务返回一条记录,字段包括 `id`、`cron`、`humanSchedule`、`nextFireAt`、`recurring`、`ageDays` 和 `stale`。记录用 `---` 分隔,按调度时间排列。 **`CronDelete`** 只接受一个 `id`。对周期任务,未来所有触发立即停止;对一次性任务,挂起的那次触发会被取消。已触发的一次性任务会自动删除,因此对已触发过的一次性任务调用 `CronDelete` 会返回 `No cron job with id ...`。删除不可撤销,需要还原时只能再次 `CronCreate`。`CronDelete` 在 Plan 模式下同样会被拦截。 diff --git a/packages/agent-core/src/agent/cron/manager.ts b/packages/agent-core/src/agent/cron/manager.ts index a959ca1b07..2baf22e3dd 100644 --- a/packages/agent-core/src/agent/cron/manager.ts +++ b/packages/agent-core/src/agent/cron/manager.ts @@ -172,7 +172,9 @@ export class CronManager { this.scheduler = createCronScheduler({ clocks: this.clocks, source: () => this.store.list(), - isIdle: () => !agent.turn.hasActiveTurn, + isIdle: () => + !agent.turn.hasActiveTurn && + agent.goal.getGoal().goal?.status !== 'paused', isKilled: () => process.env['KIMI_DISABLE_CRON'] === '1', onFire: (task, ctx) => { this.handleFire(task, ctx); diff --git a/packages/agent-core/test/agent/cron/harness/stub.ts b/packages/agent-core/test/agent/cron/harness/stub.ts index 8b11cd7c78..c97d67646b 100644 --- a/packages/agent-core/test/agent/cron/harness/stub.ts +++ b/packages/agent-core/test/agent/cron/harness/stub.ts @@ -45,6 +45,8 @@ export interface AgentStubOptions { readonly steerReturns?: number | null; /** Optional homedir so CronManager persistence can derive its path. */ readonly homedir?: string; + /** Current goal status exposed through `agent.goal.getGoal()`. */ + readonly goalStatus?: 'active' | 'paused' | 'blocked'; } export interface AgentStub { @@ -53,6 +55,7 @@ export interface AgentStub { readonly telemetryCalls: TelemetryCall[]; readonly eventCalls: EventCall[]; setHasActiveTurn(v: boolean): void; + setGoalStatus(v: 'active' | 'paused' | 'blocked' | undefined): void; } export function createAgentStub(opts: AgentStubOptions = {}): AgentStub { @@ -60,6 +63,7 @@ export function createAgentStub(opts: AgentStubOptions = {}): AgentStub { const telemetryCalls: TelemetryCall[] = []; const eventCalls: EventCall[] = []; let hasActiveTurn = opts.hasActiveTurn ?? false; + let goalStatus = opts.goalStatus; // `?? 42` would collapse explicit `null` (buffered) into 42, so probe // the property's presence instead of relying on nullish coalescing. const steerReturns: number | null = @@ -79,9 +83,20 @@ export function createAgentStub(opts: AgentStubOptions = {}): AgentStub { telemetryCalls.push({ event, props }); }, }; + const goal = { + getGoal: () => ({ + goal: + goalStatus === undefined + ? null + : { + status: goalStatus, + }, + }), + }; const agent = { turn, telemetry, + goal, homedir: opts.homedir, emitEvent: (event: AgentEvent) => { eventCalls.push({ event }); @@ -95,6 +110,9 @@ export function createAgentStub(opts: AgentStubOptions = {}): AgentStub { setHasActiveTurn: (v: boolean) => { hasActiveTurn = v; }, + setGoalStatus: (v) => { + goalStatus = v; + }, }; } diff --git a/packages/agent-core/test/agent/cron/manager.test.ts b/packages/agent-core/test/agent/cron/manager.test.ts index 785b2ee4e9..3e2b4039a0 100644 --- a/packages/agent-core/test/agent/cron/manager.test.ts +++ b/packages/agent-core/test/agent/cron/manager.test.ts @@ -117,6 +117,33 @@ describe('CronManager', () => { }); describe('handleFire — recurring', () => { + it('keeps a due cron queued while the goal is paused', () => { + const stub = createAgentStub({ goalStatus: 'paused' }); + const harness = createClocks(); + const manager = new CronManager(stub.agent, { + clocks: harness.clocks, + pollIntervalMs: null, + }); + + const task = manager.store.add( + { cron: '*/5 * * * *', prompt: 'check the deploy' }, + harness.now() - 1, + ); + harness.advance(6 * 60_000); + manager.tick(); + + expect(stub.steerCalls).toHaveLength(0); + expect(stub.telemetryCalls).toHaveLength(0); + expect(manager.store.get(task.id)?.lastFiredAt).toBeUndefined(); + + stub.setGoalStatus('active'); + manager.tick(); + + expect(stub.steerCalls).toHaveLength(1); + expect(stub.telemetryCalls).toHaveLength(1); + expect(manager.store.get(task.id)?.lastFiredAt).toBeDefined(); + }); + it('steers with cron_job origin and emits cron_fired telemetry', () => { const stub = createAgentStub({ steerReturns: 7 }); const harness = createClocks();