Skip to content
Open
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/queue-cron-while-goal-paused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep scheduled prompts queued without model calls while a goal is paused.
8 changes: 7 additions & 1 deletion apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
48 changes: 48 additions & 0 deletions apps/kimi-code/test/cli/run-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 2 additions & 0 deletions docs/en/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cron-fire>` 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.
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只

为避免整批用户在整点同时触发,调度器会做确定性抖动:周期任务向后偏移 `min(周期的 10%, 15 分钟)`;一次性任务若恰好落在 `:00` 或 `:30` 则向前提前最多 90 秒。如果调度器错过了若干触发时刻(如笔记本合盖),唤醒后只会触发一次,prompt 会包裹在 `<cron-fire>` 信封里并附带 `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 模式下同样会被拦截。
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core/src/agent/cron/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Comment thread
ousamabenyounes marked this conversation as resolved.
isKilled: () => process.env['KIMI_DISABLE_CRON'] === '1',
onFire: (task, ctx) => {
this.handleFire(task, ctx);
Expand Down
18 changes: 18 additions & 0 deletions packages/agent-core/test/agent/cron/harness/stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -53,13 +55,15 @@ 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 {
const steerCalls: SteerCall[] = [];
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 =
Expand All @@ -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 });
Expand All @@ -95,6 +110,9 @@ export function createAgentStub(opts: AgentStubOptions = {}): AgentStub {
setHasActiveTurn: (v: boolean) => {
hasActiveTurn = v;
},
setGoalStatus: (v) => {
goalStatus = v;
},
};
}

Expand Down
27 changes: 27 additions & 0 deletions packages/agent-core/test/agent/cron/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down