feat(agent-core): add session-scoped cron tools with persistence (#157)

* feat(agent-core): add cron ClockSources abstraction

ClockSources splits wall-clock and monotonic time so the cron scheduler
can be driven by an injected/simulated clock without breaking the lock
heartbeat. resolveClockSources reads KIMI_CRON_CLOCK to switch between
system, env-var-backed, and file-backed wall clocks; monotonic time is
always process.hrtime.bigint() and never overridable.

* feat(agent-core): add 5-field cron expression parser

parseCronExpression handles the standard 5-field syntax with the
cron-style dom/dow OR rule. computeNextCronRun uses field-by-field
jumping (not minute scanning) so sparse expressions like '0 12 1 1 *'
stay fast. hasFireWithinYears caps the search at 5 years so syntactically
legal but never-firing expressions ('0 0 31 2 *') return null instead of
spinning forever — required by CronCreate validation.

* feat(agent-core): add deterministic cron jitter

Recurring jobs shift forward by min(10% of period, 15min); one-shot
jobs landing on :00/:30 shift earlier by up to 90s. Offset is hashed
from task.id so reschedules and restarts stay stable. KIMI_CRON_NO_JITTER
disables both branches for reproducible benches.

* feat(agent-core): add CronTask type and cron prompt origins

CronTask matches what gets persisted to tasks.json (with durable stripped).
CronJobOrigin carries coalescedCount and stale so the agent can react to
collapsed fires without separate channels; CronMissedOrigin tags the
boot-time AskUserQuestion path.

* test(agent-core): guard against Date.now() in cron scheduler files

oxlint 1.59 does not support no-restricted-syntax, so the ESLint-style
guard from the plan is implemented as a vitest scan. The four guarded
files (scheduler/persist/lock/jitter) must route every wall-clock read
through ClockSources.wallNow(); clock.ts is excluded because that is
where the abstraction is defined. Non-existent files are skipped so the
guard activates automatically when later commits introduce them.

* feat(agent-core): add cron telemetry event-name constants

Four event names emitted by later commits (cron_scheduled, cron_fired,
cron_missed, cron_deleted) live with the cron module rather than in the
generic telemetry interface so a typo can't drift the metric and the
abstraction stays domain-free.

* feat(agent-core): add in-memory SessionCronStore

Holds cron tasks scoped to a single CLI session — vanish on exit. Phase 2
will add a file-backed sibling that shares the shape. Ids are 8 hex
characters with a collision-retry cap; createdAt is supplied by the
caller's wall clock so the store stays clock-pure (and exempt from the
Date.now() guard for the same reason).

* feat(agent-core): add session-only CronScheduler engine

The scheduler is a pure callback-driven loop: it gets tasks from a
source(), gates on isIdle()/isKilled?(), computes next fire via
cron-expr + jitter, and invokes onFire with the coalesced count when a
task is due. lastSeenAt is in-memory only — coalesce semantics make a
restart skip acceptable, but persisting last-fire would silently swallow
legitimately-due fires. pollIntervalMs=null lets P1.8 disable the
auto-tick timer for bench scenarios.

* feat(agent-core): add CronManager Agent integration layer

CronManager owns a SessionCronStore + CronScheduler and wires them to
the Agent: scheduler.isIdle reads agent.turn.hasActiveTurn, isKilled
reads KIMI_DISABLE_CRON, onFire builds a CronJobOrigin and routes
through agent.turn.steer. Stale flag is computed on read (7-day age,
recurring only, KIMI_CRON_NO_STALE shorts it) so manager doesn't have
to mutate persisted tasks. handleMissed takes a renderer callback so
P2.7 can plug in the AskUserQuestion text without bringing render
imports into Phase 1.

* feat(agent-core): add CronCreate tool (session-only path)

CronCreate validates the expression, enforces the 5-year fire window
(blocking '0 0 31 2 *' typos), caps prompt bytes at 8KB, caps active
jobs at 50 per session, and rejects durable=true until Phase 2 adds the
file-backed store. Manager exposes emitScheduled/emitDeleted so tools
never reach into agent.telemetry directly.

* feat(agent-core): add CronList tool

Read-only tool surfacing every scheduled cron job for the session. Each
record carries id / cron / humanSchedule / nextFireAt / recurring /
durable / ageDays / stale, formatted in the same key: value\n---\n
shape as TaskList. nextFireAt is the post-jitter timestamp so the model
sees what the scheduler will actually fire on. Malformed cron strings
render with null nextFireAt instead of throwing — defends against any
future direct store inserts.

* feat(agent-core): add CronDelete tool

Validates the 8-hex id shape up front and routes deletion through the
manager so cron_deleted telemetry stays consistent with cron_scheduled.
Not-found is reported as an error so the model corrects itself rather
than silently believing the delete succeeded — the next CronList would
still show whatever id was missed.

* feat(agent-core): wire CronManager + cron tools into Agent

Agent gains a public cron field constructed and started in the
constructor. The scheduler's setInterval is .unref()'d so the cron
timer never keeps the process alive, and isKilled (KIMI_DISABLE_CRON)
short-circuits every tick, so eager start is safe.

ToolManager registers CronCreate/CronList/CronDelete next to the
background tools. initializeBuiltinTools runs lazily after the Agent
constructor finishes, so this.agent.cron is already defined when the
tools are constructed.

* feat(agent-core): add manual-tick env + SIGUSR1 bench hook

KIMI_CRON_MANUAL_TICK=1 forces the scheduler into manual-drive mode
(pollIntervalMs: null), and in the same gate SIGUSR1 binds to a
no-throw manager.tick() so bench scripts can advance the scheduler
with kill -USR1 <pid> without a custom RPC.

SIGUSR1 binding is opt-in (rather than always-on) for two reasons:
the auto-tick interval already advances the scheduler, and a CLI with
many subagents would otherwise pile up listeners and trip Node's
10-listener default. Tests cover the env gate, signal swallowing,
listener cleanup, and the no-bind path when the env is unset. The
AgentTestContext harness gains an onTestFinished cleanup so the
auto-started cron manager never leaks across test files.

* test(agent-core): add end-to-end session cron smoke

Exercises the full Agent → ToolManager → CronScheduler stack through
the production CronCreateTool surface. Local-time anchor + injected
ClockSources make coalescedCount=3 deterministic across host
timezones. A second case walks the Create → List → Delete tool cycle
to confirm the three-tool surface composes round-trip.

* test(agent-core): extract shared cron test harness

Pulls the duplicated createAgentStub + createClocks helpers out of the
five cron test files into test/agent/cron/harness/stub.ts. The shared
stub keeps the lightweight-Agent shape (only turn + telemetry surfaces
need to look real) while letting individual tests opt into the
options that mattered locally (hasActiveTurn / steerReturns).

* test(agent-core): trim cron test file headers

Compresses each cron test header to a couple of lines describing what
the file covers. The long rationale blocks were process documentation
(why-this-file-exists, why-stub-vs-real-agent, plan-doc references)
that didn't help anyone reading the test later. The few details that
mattered (local-time anchor, coalescedCount math) stayed inline next
to the code that needs them. E2E test also picks up the shared
createClocks helper instead of defining its own.

* test(agent-core): convert cron tool output assertions to inline snapshots

The 21 multi-field toMatch / toContain assertions across CronCreate /
CronList / CronDelete tests covered the same ground a snapshot would
have but cost more diff churn when a format detail changes. Errors
become single-line snapshots; success outputs go through a small
scrubCronOutput helper that replaces the random 8-hex id and ISO
timestamp with stable placeholders so the snapshot is deterministic
across TZ and run.

* refactor(cron): remove durable flag, env clock source, and enable cron tools in default profile

- Remove the unimplemented durable persistence field from the entire cron stack (types, tools, manager, tests, docs) to avoid misleading the model into promising cross-session persistence that does not exist yet.
- Drop the env:VAR clock source in favour of the file:path source for test/bench control.
- Register CronCreate, CronList, and CronDelete in the default agent profile.

* fix(agent-core): address PR #136 typecheck and Codex review for cron tools

Typecheck:
- Bracket-access KIMI_CRON_* env reads under TS4111 in cron source/tests.
- Add `approvalRule` to CronCreate/CronList/CronDelete tool executions.
- Guard `cron-expr.detectStep` against undefined array elements; cast the
  readonly snapshot in session-store.test through `unknown`; import the
  missing `ClockSources`/`ContentPart` symbols in manager.test.
- Extend `isReplayUserTurnRecord` switch to cover the new `cron_job` and
  `cron_missed` origins so kimi-code stays exhaustive.

Semantics (from Codex review):
- CronCreate re-reads `wallNow()` and re-checks the session cap inside
  `execute()`, so manual-approval delays and concurrent prepared calls
  can't backdate the schedule or breach the cap.
- One-shot jitter floors the pull-forward at `task.createdAt`, so a
  brand-new `:00`/`:30` reminder can't end up before its scheduling time.
- Scheduler coalesce loop reapplies the same jitter as the delivery path
  and advances `lastSeenAt` to the last actually-delivered ideal fire;
  a not-yet-due jittered slot is no longer lost. One-shot fires always
  report `coalescedCount: 1`.
- Manager removes recurring tasks after the first stale fire and emits a
  `cron_deleted` event, matching the 7-day auto-expire contract.
- CronList anchors one-shot `nextFireAt` at `createdAt`, so a pending
  today's slot isn't rendered as tomorrow.

Tests cover each of the above and a changeset is added.

* fix(agent-core,tui): address deep review + Codex review on PR #136

Closes the deep-review pass and the four Codex review rounds that
followed on the cron tools feature. Consolidated rather than landed
as a series so the PR history reads as one fix wave on top of the
original Phase-1 cron implementation.

## Lifecycle + structural

- Session.close() now stops every agent's CronManager via
  `Promise.allSettled` (mirroring `stopBackgroundTasksOnExit`).
  Without this the 1s setInterval and its closure-captured
  Agent/Session graph leaked on every closed session.
- Agent constructor gates `cron.start()` and the three Cron tool
  instantiations on `type !== 'sub'`. Subagents no longer pile up
  empty 1Hz timers or duplicate SIGUSR1 listeners under
  `KIMI_CRON_MANUAL_TICK=1`.

## Permission + plan-mode parity

- `PlanModeGuardDenyPermissionPolicy` denies CronCreate and
  CronDelete during plan mode (CronList stays allowed); matches the
  TaskStop precedent.
- `DEFAULT_APPROVE_TOOLS` includes CronList for parity with TaskList
  / TaskOutput so manual-approval mode doesn't prompt on read-only
  listings.

## Cron-fire envelope + projector

- Cron fires wrap `task.prompt` in a `<cron-fire jobId=… cron=…
  recurring=… coalescedCount=… stale=…><prompt>…</prompt></cron-fire>`
  XML envelope (mirrors `notification-xml.ts`). Without this the
  `coalescedCount` and `stale` cues documented in cron-create.md
  were invisible to the LLM.
- Projector `isInjectionUserMessage` recognises `<cron-fire ` so the
  envelope isn't merged into adjacent real user messages.
- TUI `SessionReplayController.renderUserMessage` skips cron_job /
  cron_missed origins (matches `isReplayUserTurnRecord`'s exclusion);
  resumed sessions no longer render the raw envelope as user text or
  miscount cron records toward the replay turn limit.

## Scheduler invariants

- `tick()` only advances `lastSeenAt` / removes one-shots after a
  successful `onFire`. A throw in `agent.turn.steer` previously
  silently lost the fire; now the next tick re-detects and retries.
- New `getNextFireForTask(id)` on the scheduler (delegated through
  the manager) lets CronList render the same instant the scheduler
  will fire. Previously CronList computed from `nowMs` and could
  report tomorrow's slot while a current-period jittered delivery
  was still pending.

## Cron parser + validation

- `parseCronExpression` now rejects non-cron numeric forms via a
  `parseCronInt` helper guarded by `^\d+$` — `''` / `'1e1'` /
  `'0x10'` / `'+5'` / `'-5'` no longer silently become 0, 10, 16,
  5, or `0-5`.
- `nextRunWithinMinutes` bounds search by a wall-time deadline
  instead of iteration count. Each iteration can skip a month, so
  the old `capMinutes + 10_000` cap let `0 0 30 2 *` walk ~200 000
  years before bailing; the new path returns null in microseconds.
- `oneShotJitteredNextCronRunMs` returns `idealMs` (not `createdAt`)
  when the pulled-forward time would precede `createdAt`. The old
  clamp made an 08:59:30-scheduled `0 9 * * *` fire on the very next
  tick — ~29 s before ideal — instead of at 09:00.

## CronList + CronCreate output

- CronList output adds a `prompt:` row, JSON-encoded so newlines
  stay on one line, truncated to ~200 UTF-8 bytes on a char
  boundary. The model can recall a task's intent after compaction
  and use it as the source for the documented refresh ritual.
- CronCreate normalizes `args.cron` whitespace BEFORE
  `parseCronExpression` so `parsed.raw` is single-line. Otherwise
  inputs like `"1\n2\n3\n4\n5"` (legal — parser accepts any \s+)
  produced a multi-line `humanSchedule:` row via the cronToHuman
  raw-fallback branch.

## Misc hardening

- `KIMI_CRON_CLOCK=file:<path>` reads at most 64 bytes via
  `openSync` + `readSync` so a stray-large file can't OOM.
- SIGUSR1 handler logs swallowed `tick()` exceptions to stderr when
  `KIMI_CRON_DEBUG=1` (matches scheduler's debug pattern); silent in
  production.
- Documentation rewrite across cron-list.md / cron-create.md /
  cron-delete.md so the documented stale + nextFireAt behaviour
  matches the implementation (recurring stale tasks auto-delete after
  the final fire; `nextFireAt` is an ISO timestamp; refresh ritual
  is "just CronCreate again — the old id is already gone").

Tests cover each of the above. Suite at 2090+ passing across
agent-core, kosong, and the kimi-code app; typecheck clean across
all workspace packages.

* feat(cron): persist scheduled tasks across kimi resume

Add per-id JSON persistence so cron tasks survive a kimi resume of the same session.

Core changes:
- CronManager: addTask / removeTasks mirror mutations to <sessionDir>/cron/<id>.json
- CronManager.loadFromDisk() rehydrates the in-memory store on resume
- CronManager.flushPersist() drains pending writes for graceful shutdown
- SessionCronStore.adopt() inserts persisted tasks with original id and createdAt
- Extract shared createPerIdJsonStore utility from background/persist.ts
- Refactor background/persist.ts to use createPerIdJsonStore (no behavior change)
- New tests: resume.test.ts, persist.test.ts, per-id-json-store.test.ts
- Update cron-create / cron-list / cron-delete docs to reflect session lifetime

* fix(agent-core): fix cron-stop-on-close test import for tsgo compat

* feat(cron): persist lastFiredAt cursor and scope approval rules

- persist lastFiredAt across resume so recurring tasks don't replay
- add one-shot pinned-date guard to reject >1-year-out first fires
- scope CronCreate approvalRule to exact payload (cron, prompt, recurring)
- add resume replay tests and corrupt-cursor fallback test
- stop cron before awaiting background shutdown so due ticks cannot
  start a fresh turn while session.close() is mid-flight
- filter cron_job / cron_missed origins from markdown export so the
  internal <cron-fire ...> envelope no longer leaks into user-facing
  exports
This commit is contained in:
Haozhe 2026-05-28 21:05:46 +08:00 committed by GitHub
parent 28d2b5c018
commit 971fce6e52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 8230 additions and 73 deletions

6
.changeset/cron-tools.md Normal file
View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
---
Add session-scoped scheduled prompts so the assistant can register, list, and cancel recurring or one-shot reminders that fire later in the same session.

View file

@ -203,6 +203,12 @@ export class SessionReplayRenderer {
if (message.origin?.kind === 'injection') {
return;
}
// WHY: cron fires are not user turns (see isReplayUserTurnRecord); skip
// visual render and turn advance so the raw <cron-fire ...> envelope never
// surfaces in the resumed transcript.
if (message.origin?.kind === 'cron_job' || message.origin?.kind === 'cron_missed') {
return;
}
this.flushAssistant(context);
const skill = skillActivationFromOrigin(message.origin);

View file

@ -97,6 +97,12 @@ const INTERNAL_ORIGINS = new Set<PromptOrigin['kind']>([
'system_trigger',
'compaction_summary',
'hook_result',
// Cron fires are stored as user-role records carrying a `<cron-fire ...>`
// XML envelope meant only for the model. Replay and the TUI projector
// already hide them; the markdown exporter must do the same or the raw
// protocol XML leaks into the user-facing export.
'cron_job',
'cron_missed',
]);
export function isInternalMessage(msg: ContextMessage): boolean {

View file

@ -246,6 +246,8 @@ function isReplayUserTurnRecord(record: AgentReplayRecord): boolean {
return message.origin.trigger === 'user-slash';
case 'background_task':
case 'compaction_summary':
case 'cron_job':
case 'cron_missed':
case 'hook_result':
case 'injection':
case 'system_trigger':

View file

@ -181,6 +181,27 @@ describe('isInternalMessage', () => {
).toBe(true);
});
it('marks cron_job origin as internal', () => {
expect(
isInternalMessage(
userMsg('x', {
kind: 'cron_job',
jobId: 'a1b2c3d4',
cron: '0 9 * * *',
recurring: true,
coalescedCount: 1,
stale: false,
}),
),
).toBe(true);
});
it('marks cron_missed origin as internal', () => {
expect(
isInternalMessage(userMsg('x', { kind: 'cron_missed', count: 2 })),
).toBe(true);
});
it('keeps real user messages', () => {
expect(isInternalMessage(userMsg('hello', { kind: 'user' }))).toBe(false);
});

View file

@ -372,6 +372,54 @@ describe('KimiTUI resume message replay', () => {
]);
});
it('skips cron_job origin records during replay', async () => {
const cronFire =
'<cron-fire jobId="job-1" cron="*/5 * * * *" recurring="true" coalescedCount="1">\nrun nightly\n</cron-fire>';
const driver = await replayIntoDriver([
message('user', [{ type: 'text', text: 'real prompt' }]),
message('assistant', [{ type: 'text', text: 'real answer' }]),
message('user', [{ type: 'text', text: cronFire }], {
origin: {
kind: 'cron_job',
jobId: 'job-1',
cron: '*/5 * * * *',
recurring: true,
coalescedCount: 1,
stale: false,
},
}),
]);
const transcript = driver.state.transcriptContainer.render(120).join('\n');
expect(transcript).not.toContain('<cron-fire');
expect(
driver.state.transcriptEntries
.filter((entry) => entry.kind === 'user')
.map((entry) => entry.content),
).toEqual(['real prompt']);
});
it('skips cron_missed origin records during replay', async () => {
const cronMissed =
'<cron-fire jobId="job-2" missed="true" count="3">\n3 one-shot tasks missed while offline\n</cron-fire>';
const driver = await replayIntoDriver([
message('user', [{ type: 'text', text: 'real prompt' }]),
message('assistant', [{ type: 'text', text: 'real answer' }]),
message('user', [{ type: 'text', text: cronMissed }], {
origin: { kind: 'cron_missed', count: 3 },
}),
]);
const transcript = driver.state.transcriptContainer.render(120).join('\n');
expect(transcript).not.toContain('<cron-fire');
expect(transcript).not.toContain('missed while offline');
expect(
driver.state.transcriptEntries
.filter((entry) => entry.kind === 'user')
.map((entry) => entry.content),
).toEqual(['real prompt']);
});
it('renders user-slash skill activation once without exposing injected prompt text', async () => {
const activation = message(
'user',

View file

@ -115,6 +115,7 @@ function isInjectionUserMessage(message: Message): boolean {
if (trimmed.startsWith('<notification ')) return true;
if (trimmed.startsWith('<system-reminder>')) return true;
if (trimmed.startsWith('<hook_result ')) return true;
if (trimmed.startsWith('<cron-fire ')) return true;
return false;
}

View file

@ -42,6 +42,23 @@ export interface BackgroundTaskOrigin {
readonly notificationId: string;
}
export interface CronJobOrigin {
readonly kind: 'cron_job';
readonly jobId: string;
readonly cron: string;
readonly recurring: boolean;
/** Number of theoretical fires that were collapsed into this single delivery (>= 1). */
readonly coalescedCount: number;
/** True for recurring tasks past the 7-day age threshold. */
readonly stale: boolean;
}
export interface CronMissedOrigin {
readonly kind: 'cron_missed';
/** Number of one-shot tasks bundled into this missed-fire notification. */
readonly count: number;
}
export interface HookResultOrigin {
readonly kind: 'hook_result';
readonly event: string;
@ -55,6 +72,8 @@ export type PromptOrigin =
| CompactionSummaryOrigin
| SystemTriggerOrigin
| BackgroundTaskOrigin
| CronJobOrigin
| CronMissedOrigin
| HookResultOrigin;
export type ContextMessage = Message & {

View file

@ -0,0 +1 @@
export { CronManager, type CronManagerOptions } from './manager';

View file

@ -0,0 +1,566 @@
/**
* CronManager Agent-facing facade for the cron scheduler.
*
* This layer sits between the raw `CronScheduler` (which knows nothing
* about agents) and the rest of the agent runtime (Agent / turn /
* telemetry / tool surface). Its job is small but important:
*
* - own the `SessionCronStore` for this session;
* - hand `() => store.list()` to the scheduler so add / delete are
* picked up automatically every tick;
* - gate fires on `agent.turn.hasActiveTurn` rather than maintaining a
* duplicate idle flag the turn machinery already knows;
* - translate a fired `CronTask` into a `steer(...)` call carrying a
* `CronJobOrigin`, plus the `cron_fired` telemetry event;
* - mirror every store mutation to `<sessionDir>/cron/<id>.json`
* (via {@link addTask} / {@link removeTasks}) so that `kimi resume`
* can call {@link loadFromDisk} to rehydrate previously-scheduled
* tasks. When no `sessionDir` is supplied (subagents, tests,
* ephemeral sessions) the manager stays purely in-memory.
* - provide a `handleMissed(...)` entry point that future boot-time
* missed-task notification will call. Today the scheduler's
* `coalescedCount` semantics handle missed fires inline, so this
* entry point is not wired by the framework it stays exposed so
* adding a banner later does not require API churn here.
*
* The manager does NOT read `Date.now()` directly anywhere; every
* wall-clock read goes through `this.clocks.wallNow()`. The
* `no-date-now.test.ts` guard does not list this file (it covers the
* scheduler / jitter layer), but the same discipline is intentional so
* bench / test clock injection holds end-to-end.
*
* Note on `recurring` semantics: the canonical task representation uses
* `recurring: boolean | undefined` where `undefined` means recurring
* (cron tasks default to repeating). One-shot is the explicit
* `recurring === false` opt-out. Every check in this file uses
* `task.recurring !== false` to keep that default behaviour even when
* the field is omitted by the caller.
*/
import type { ContentPart } from '@moonshot-ai/kosong';
import type { Agent } from '../index';
import type { CronJobOrigin, CronMissedOrigin } from '../context/types';
import {
resolveClockSources,
SYSTEM_CLOCKS,
type ClockSources,
} from '../../tools/cron/clock';
import { renderCronFireXml } from '../../tools/cron/cron-fire-xml';
import { createCronPersistStore } from '../../tools/cron/persist';
import { SessionCronStore } from '../../tools/cron/session-store';
import {
createCronScheduler,
type CronScheduler,
} from '../../tools/cron/scheduler';
import {
CRON_DELETED,
CRON_FIRED,
CRON_MISSED,
CRON_SCHEDULED,
} from '../../tools/cron/telemetry-events';
import type { CronTask } from '../../tools/cron/types';
import type { PerIdJsonStore } from '../../utils/per-id-json-store';
import type { SessionCronTaskInit } from '../../tools/cron/session-store';
/**
* Threshold past which a recurring task is flagged `stale: true` on its
* fire `origin`. One-shot tasks never carry the stale flag they are
* one-time, "we always fire at most once" by construction. Disabled by
* `KIMI_CRON_NO_STALE=1` (bench / acceptance tests).
*
* Seven days mirrors the wall-clock "this got forgotten about" window
* we want the LLM to notice; the figure also matches the auto-expire
* cadence documented in the user-facing schedule story.
*/
const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
export interface CronManagerOptions {
/**
* Override for tests / bench. Defaults to
* `resolveClockSources(process.env.KIMI_CRON_CLOCK)` so production
* picks up `KIMI_CRON_CLOCK=file:...` automatically.
* When unset, falls through to {@link SYSTEM_CLOCKS}.
*/
readonly clocks?: ClockSources;
/**
* Override scheduler poll interval. Defaults handled by the scheduler
* (1000ms unless `KIMI_CRON_MANUAL_TICK=1`, which forces `null` here
* so the auto-tick `setInterval` is never installed). `null` or `0`
* means "no automatic timer — caller drives `tick()` manually".
*/
readonly pollIntervalMs?: number | null;
/**
* Per-session directory used to persist cron tasks across
* `kimi resume`. When set, `addTask` / `removeTasks` mirror the
* in-memory store to `<sessionDir>/cron/<id>.json` and
* `loadFromDisk()` re-populates the store on resume. When omitted
* (subagents, tests, ephemeral sessions), the manager stays purely
* in-memory and `loadFromDisk()` is a no-op.
*/
readonly sessionDir?: string;
}
export class CronManager {
/** In-memory task store. Empty at construction; populated by
* {@link addTask} (and {@link loadFromDisk} on resume). */
readonly store: SessionCronStore;
/**
* Clock source used for the stale judgment. Also passed to the
* scheduler so the entire stack shares one notion of "now".
*/
readonly clocks: ClockSources;
private readonly scheduler: CronScheduler;
private readonly agent: Agent;
/**
* Tracks whether `start()` has been called without a matching `stop()`.
* Used to keep `start()` / `stop()` idempotent and more importantly
* for P1.8 to gate SIGUSR1 binding so we don't accumulate handlers
* across repeated start() calls.
*/
private started = false;
/**
* Reference to the bound SIGUSR1 listener while the manager is
* running. Held so `stop()` can call `process.off('SIGUSR1', handler)`
* with the same function reference and not leak handlers across vitest
* files. `null` whenever the manager is not started, or when running
* on a platform that does not support SIGUSR1 (Windows).
*/
private sigusr1Handler: NodeJS.SignalsListener | null = null;
/**
* File-backed mirror of {@link store}. `undefined` when no
* `sessionDir` was supplied the manager then behaves as pure
* in-memory, matching pre-persistence semantics. When defined,
* `addTask` / `removeTasks` schedule fire-and-forget writes so a
* later `kimi resume` can reload via {@link loadFromDisk}.
*/
private readonly persistStore: PerIdJsonStore<CronTask> | undefined;
/**
* Per-id serializer for persistence writes. Prevents a fast
* `add` `remove` sequence on the same id from racing each other on
* the rename the rm must observe the prior write's renamed file.
* Empty between bursts; entries are deleted once their tail promise
* settles so the map cannot grow unboundedly with churn.
*/
private readonly persistQueues: Map<string, Promise<void>> = new Map();
constructor(agent: Agent, opts: CronManagerOptions = {}) {
this.agent = agent;
this.store = new SessionCronStore();
this.clocks =
opts.clocks ??
resolveClockSources(process.env['KIMI_CRON_CLOCK']) ??
SYSTEM_CLOCKS;
this.persistStore =
opts.sessionDir === undefined
? undefined
: createCronPersistStore(opts.sessionDir);
this.scheduler = createCronScheduler({
clocks: this.clocks,
source: () => this.store.list(),
isIdle: () => !agent.turn.hasActiveTurn,
isKilled: () => process.env['KIMI_DISABLE_CRON'] === '1',
onFire: (task, ctx) => this.handleFire(task, ctx),
removeOneShot: (id) => {
this.removeTasks([id]);
},
onAdvanceCursor: (id, lastFiredAt) => {
this.advanceCursor(id, lastFiredAt);
},
// P1.8: `KIMI_CRON_MANUAL_TICK=1` forces the scheduler into
// manual-drive mode (no setInterval), so bench / time-injected
// tests can step time forward and call `tick()` explicitly without
// racing a 1-second auto-tick. Explicit caller overrides
// (`opts.pollIntervalMs`) lose to the env so a bench can flip the
// switch from the outside without rebuilding the manager wiring.
pollIntervalMs:
process.env['KIMI_CRON_MANUAL_TICK'] === '1'
? null
: opts.pollIntervalMs,
});
}
/**
* Add a fresh task to the in-memory store and, when persistence is
* attached, mirror the new record to `<sessionDir>/cron/<id>.json`.
*
* The store call is synchronous (CronCreate needs the id for its
* response); the on-disk write is fire-and-forget so a slow disk
* never blocks the tool's reply. Per-id queueing serializes
* concurrent writes on the same id (e.g. add stale auto-expire) so
* the rm cannot race the rename.
*
* Persistence failures are logged via `agent.log.warn` and swallowed
* a flaky disk drops cross-resume durability but must not crash
* the agent loop.
*/
addTask(init: SessionCronTaskInit): CronTask {
const task = this.store.add(init, this.clocks.wallNow());
this.persistEnqueue(task.id, () =>
this.persistStore!.write(task.id, task),
);
return task;
}
/**
* Remove a batch of tasks from the in-memory store and mirror each
* deletion to disk (when persistence is attached). Returns the
* subset of ids that were actually present, matching
* `SessionCronStore.remove`'s contract callers (CronDelete /
* scheduler one-shot cleanup / stale auto-expire) read this to
* decide whether to emit telemetry.
*
* Persistence failures are logged and swallowed; cross-resume the
* worst case is a ghost entry that gets dropped on the next
* `list()` shape-guard pass.
*/
removeTasks(ids: readonly string[]): readonly string[] {
const removed = this.store.remove(ids);
for (const id of removed) {
this.persistEnqueue(id, () => this.persistStore!.remove(id));
}
return removed;
}
/**
* Persist the scheduler's `lastFiredAt` cursor for a recurring task
* so a `kimi resume` does not coalesce-replay an already-delivered
* fire. Called by the scheduler's `onAdvanceCursor` callback after a
* successful recurring fire.
*
* No-op when the task has already been removed between fire and
* callback (concurrent CronDelete is the canonical case). When
* persistence is detached (subagent / ephemeral session) we still
* update the in-memory record same-session stale checks read off
* the in-memory store. The on-disk write is fire-and-forget via
* `persistEnqueue`; a flaky disk drops cross-resume durability but
* never blocks the scheduler.
*/
private advanceCursor(id: string, lastFiredAt: number): void {
const updated = this.store.markFired(id, lastFiredAt);
if (updated === undefined) return;
if (this.persistStore === undefined) return;
this.persistEnqueue(id, () => this.persistStore!.write(id, updated));
}
/**
* Rehydrate the in-memory store from `<sessionDir>/cron/` after
* `kimi resume`. No-op when persistence is not attached. Idempotent:
* clears the in-memory map and re-inserts every record on disk.
*
* Tasks are inserted via {@link SessionCronStore.adopt} so the
* original `id` and `createdAt` survive `createdAt` is the
* scheduler's recurring baseline and the 7-day stale judgment's
* input, so a regenerated value would corrupt both.
*/
async loadFromDisk(): Promise<void> {
if (this.persistStore === undefined) return;
const tasks = await this.persistStore.list();
this.store.clear();
for (const task of tasks) {
this.store.adopt(task);
}
}
/**
* Serialize per-id persistence writes. Concurrent mutations on the
* same id (uncommon but reachable via `add` immediately followed by
* stale auto-expire) would otherwise race on the rename atomicWrite
* is per-call atomic, not per-id ordered. Each id's chain is dropped
* from the map once it settles so the map size tracks live in-flight
* writes, not lifetime churn.
*/
private persistEnqueue(id: string, work: () => Promise<void>): void {
if (this.persistStore === undefined) return;
const prev = this.persistQueues.get(id) ?? Promise.resolve();
const next = prev
.catch(() => {})
.then(() => work())
.catch((err: unknown) => {
this.agent.log?.warn?.('cron persist failed', err);
})
.finally(() => {
if (this.persistQueues.get(id) === next) {
this.persistQueues.delete(id);
}
});
this.persistQueues.set(id, next);
}
/**
* Wait for every pending persistence write / remove scheduled via
* {@link addTask} / {@link removeTasks} to settle. Called from
* {@link stop} for graceful session shutdown and exposed publicly so
* tests can synchronise on disk-visible state without polling.
*
* Errors are already swallowed by `persistEnqueue`, so this never
* rejects.
*/
async flushPersist(): Promise<void> {
// Snapshot the chain promises rather than the map itself — the
// `.finally` cleanup deletes entries while we await, and a live
// map iteration would observe the deletions and miss tails.
const inFlight = Array.from(this.persistQueues.values());
await Promise.allSettled(inFlight);
}
/**
* Begin the scheduler's auto-tick loop and bind the SIGUSR1 manual-tick
* hook (P1.8). Idempotent: a second call is a no-op so the boot
* sequence and tests can opt into "ensure started" without bookkeeping.
*/
start(): void {
if (this.started) return;
this.started = true;
this.scheduler.start();
this.bindSigusr1();
}
/**
* Stop the scheduler, drain pending persistence writes, clear
* in-flight bookkeeping, and unbind the SIGUSR1 handler. Idempotent
* and signal-handler-safe multiple vitest files exercising the
* manager must not leave a SIGUSR1 listener dangling on the shared
* process.
*
* Draining persistence on shutdown matters for production: a session
* `close()` immediately after a CronCreate would otherwise tear the
* process down before the JSON file lands on disk, and the task
* would be missing from the resume's `loadFromDisk()`.
*/
async stop(): Promise<void> {
this.unbindSigusr1();
await this.scheduler.stop();
await this.flushPersist();
this.started = false;
}
/** Drive one scheduler tick synchronously. Used by tests + P1.8 SIGUSR1. */
tick(): void {
this.scheduler.tick();
}
/**
* Earliest theoretical (post-jitter) next-fire across all tasks, or
* null if there are no tasks / none have a future fire. Used by the
* `/cron` slash command and external monitoring.
*/
getNextFireTime(): number | null {
return this.scheduler.getNextFireTime();
}
/**
* Per-task post-jitter next-fire. Forwards to the scheduler so
* CronList renders the same instant the scheduler will fire even
* when an already-past ideal still has a pending jittered delivery
* in the current period.
*/
getNextFireForTask(taskId: string): number | null {
return this.scheduler.getNextFireForTask(taskId);
}
/**
* Stale judgment.
*
* - `KIMI_CRON_NO_STALE=1` short-circuits to false (bench).
* - One-shot tasks (`recurring === false`) are never stale they
* fire at most once by construction; flagging them stale would be
* a noisy false positive on every backlog wakeup.
* - Otherwise: `wallNow() - createdAt >= 7 days`.
*
* `Number.isFinite` guards against the wall clock being broken (e.g.
* a mis-set bench env that returns `NaN`); a non-finite age is
* treated as "we don't know, don't claim stale".
*/
isStale(task: CronTask): boolean {
if (process.env['KIMI_CRON_NO_STALE'] === '1') return false;
if (task.recurring === false) return false;
const age = this.clocks.wallNow() - task.createdAt;
return Number.isFinite(age) && age >= STALE_THRESHOLD_MS;
}
/**
* Translate a scheduler fire into a steer + telemetry event.
*
* `agent.turn.steer` returns the new turnId, or `null` when the input
* was buffered because a turn is in flight (see turn/index.ts:84).
* We propagate that as `buffered` on the telemetry props so dashboards
* can distinguish "fired into a fresh turn" from "fired into a steer
* buffer that may not run until the user's turn ends".
*
* Honours the documented 7-day auto-expire contract for recurring
* tasks: a stale recurring task gets exactly one final delivery
* (already issued above) and is then removed from the store. The
* scheduler picks up the deletion on its next tick via `source()`
* and stops re-firing the task. One-shots are not affected they
* are deleted by the scheduler immediately after delivery via the
* `removeOneShot` callback.
*/
private handleFire(
task: CronTask,
ctx: { readonly coalescedCount: number },
): void {
const stale = this.isStale(task);
const origin: CronJobOrigin = {
kind: 'cron_job',
jobId: task.id,
cron: task.cron,
recurring: task.recurring !== false,
coalescedCount: ctx.coalescedCount,
stale,
};
const content: ContentPart[] = [
{
type: 'text',
text: renderCronFireXml(origin, task.prompt),
},
];
const turnId = this.agent.turn.steer(content, origin);
this.agent.telemetry.track(CRON_FIRED, {
recurring: task.recurring !== false,
coalesced_count: ctx.coalescedCount,
stale,
buffered: turnId === null,
});
// 7-day auto-expire — the recurring branch of CronCreate's tool
// description promises this contract to the model. Without the
// removal a long-lived session keeps re-injecting a multi-day-old
// cron prompt forever; with it, the task fires one last time
// (above) and is then dropped. Emit `cron_deleted` symmetrically
// with manual deletion so dashboards see the lifecycle close.
if (stale && task.recurring !== false) {
this.removeTasks([task.id]);
this.emitDeleted(task.id);
}
}
/**
* Reserved hook for an explicit "you missed N fires while offline"
* banner. Today the scheduler's `coalescedCount` semantics already
* communicate missed fires inside the `cron_job` envelope (and
* recurring tasks past 7 days arrive with `stale: true`), so the
* resume path does NOT invoke this from the framework. The method
* stays exposed because adding a separate user-facing banner later
* e.g. for one-shots whose fire times all landed during a long
* outage should not require an API change here.
*
* The `renderMissedNotification` callback is supplied by the caller
* (rather than imported here) so this module stays free of UI / copy
* coupling; the same manager works for tests that want to inject a
* trivial renderer.
*
* `count: 0` is a no-op the scheduler-side missed-task detector
* filters empties before calling us, but defending here keeps the
* contract simple ("safe to call with anything, no-op when empty").
*/
handleMissed(
tasks: readonly CronTask[],
renderMissedNotification: (
tasks: readonly CronTask[],
) => readonly ContentPart[],
): void {
if (tasks.length === 0) return;
const content = renderMissedNotification(tasks);
const origin: CronMissedOrigin = {
kind: 'cron_missed',
count: tasks.length,
};
this.agent.turn.steer(content, origin);
this.agent.telemetry.track(CRON_MISSED, { count: tasks.length });
}
/**
* Emit `cron_scheduled` for a freshly-added task. Called by
* `CronCreate` after a successful `store.add(...)`. Kept as an
* explicit method so the tool layer never reaches into
* `manager.agent.telemetry` preserves the "tools see the manager,
* the manager sees the agent" layering and matches the symmetric
* `emitDeleted` used by `CronDelete` (P1.6).
*/
emitScheduled(task: CronTask): void {
this.agent.telemetry.track(CRON_SCHEDULED, {
recurring: task.recurring !== false,
});
}
/**
* Emit `cron_deleted` for a removed task. Wired up here so P1.6 can
* land without touching this file again. `task_id` matches the field
* naming used elsewhere in the telemetry surface (snake_case).
*/
emitDeleted(taskId: string): void {
this.agent.telemetry.track(CRON_DELETED, { task_id: taskId });
}
/**
* Wire `SIGUSR1` to a manual `tick()` so bench scripts can advance the
* scheduler with `kill -USR1 <pid>` without a custom RPC.
*
* Gated on `KIMI_CRON_MANUAL_TICK=1` for two reasons:
*
* 1. SIGUSR1 only makes sense when auto-tick is off. When the 1s
* interval is running, it already advances the scheduler a
* manual signal is redundant.
* 2. In production a single CLI process can host one main agent plus
* many subagents. Each Agent unconditionally binding a SIGUSR1
* listener would put us over Node's 10-listener default cap and
* print a `MaxListenersExceededWarning`. Coupling the binding to
* the same env that disables auto-tick keeps the production path
* at zero listeners while still giving benches the affordance.
*
* Skipped on Windows because Node's signal layer does not deliver
* POSIX signals there; attempting to `process.on('SIGUSR1', ...)` is a
* silent no-op but we avoid the call entirely so the bookkeeping
* (`sigusr1Handler !== null` means "we did bind") stays accurate.
*
* Idempotent repeated calls keep the same listener registered once,
* so `start() → start()` does not stack handlers.
*
* The handler swallows any throw from `tick()` because a signal-driven
* bench tool must never crash the host process; the tick failure mode
* is already surfaced via telemetry / logs inside the scheduler.
* Set `KIMI_CRON_DEBUG=1` to surface the swallowed error to stderr
* mirrors `scheduler.ts`'s debugLog pattern so bench debugging can
* see a bad tick.
*/
private bindSigusr1(): void {
if (process.platform === 'win32') return;
if (process.env['KIMI_CRON_MANUAL_TICK'] !== '1') return;
if (this.sigusr1Handler !== null) return;
const handler: NodeJS.SignalsListener = () => {
try {
this.tick();
} catch (error) {
if (process.env['KIMI_CRON_DEBUG'] === '1') {
const msg = error instanceof Error ? error.message : String(error);
process.stderr.write(
`[cron/manager] SIGUSR1 tick threw: ${msg}\n`,
);
}
}
};
this.sigusr1Handler = handler;
process.on('SIGUSR1', handler);
}
/**
* Detach the SIGUSR1 listener registered by `bindSigusr1`. Safe to
* call when nothing is bound (no-op). Pair this with `stop()` so
* vitest files don't leak signal handlers across the shared process
* `process.listenerCount('SIGUSR1')` should return to its pre-`start()`
* value once `stop()` resolves.
*/
private unbindSigusr1(): void {
if (this.sigusr1Handler === null) return;
process.off('SIGUSR1', this.sigusr1Handler);
this.sigusr1Handler = null;
}
}

View file

@ -29,6 +29,7 @@ import {
import type { PromisableMethods } from '../utils/types';
import { BackgroundManager } from './background';
import { FullCompaction, type CompactionStrategy } from './compaction';
import { CronManager } from './cron';
import { ConfigState } from './config';
import { ContextMemory } from './context';
import { HookEngine } from './hooks';
@ -74,6 +75,13 @@ export interface AgentConfig {
readonly hookEngine?: HookEngine;
readonly backgroundMaxRunningTasks?: number;
readonly backgroundSessionDir?: string;
/**
* Per-session directory used by `CronManager` to persist scheduled
* tasks across `kimi resume`. When omitted the cron stack stays
* purely in-memory (subagents, ephemeral sessions). Set in parallel
* with {@link backgroundSessionDir} from the session homedir.
*/
readonly cronSessionDir?: string;
readonly permission?: PermissionManagerOptions | undefined;
readonly log?: Logger;
readonly telemetry?: TelemetryClient | undefined;
@ -106,6 +114,7 @@ export class Agent {
readonly usage: UsageRecorder;
readonly tools: ToolManager;
readonly background: BackgroundManager;
readonly cron: CronManager;
readonly replayBuilder: ReplayBuilder;
readonly log: Logger;
@ -157,6 +166,25 @@ export class Agent {
maxRunningTasks: config.backgroundMaxRunningTasks,
sessionDir: config.backgroundSessionDir,
});
this.cron = new CronManager(this, {
// Subagents stay in-memory: their default profiles don't expose
// cron tools, so attaching a sessionDir would only litter the
// subagent homedir with an empty `cron/` directory on first
// mkdir. Main / non-sub agents persist when the session supplied
// a directory.
sessionDir: this.type !== 'sub' ? config.cronSessionDir : undefined,
});
if (this.type !== 'sub') {
// Skip auto-tick for subagents: each session can spawn many
// subagents, and stacking 1s setInterval timers + SIGUSR1
// listeners per subagent serves no purpose — the default subagent
// profiles don't expose Cron tools, so the store stays empty.
// The scheduler unref()'s its setInterval so the cron timer never
// keeps the process alive on its own, and isKilled (reading
// KIMI_DISABLE_CRON) short-circuits every tick — no need to
// delay start when the killswitch is set.
this.cron.start();
}
this.replayBuilder = new ReplayBuilder(this);
}
@ -254,6 +282,12 @@ export class Agent {
const result = await this.records.replay();
await this.background.loadFromDisk();
await this.background.reconcile();
// Rehydrate cron tasks scheduled in the previous CLI invocation.
// No-op when this agent was constructed without a `cronSessionDir`
// (subagents, ephemeral sessions). The scheduler's `createdAt`-based
// baseline then handles any fire times missed during downtime via
// `coalescedCount` (and the 7-day stale flag) without further wiring.
await this.cron.loadFromDisk();
this.turn.finishResume();
return result;
}

View file

@ -9,6 +9,7 @@ const DEFAULT_APPROVE_TOOLS = new Set([
'TodoList',
'TaskList',
'TaskOutput',
'CronList',
'WebSearch',
'FetchURL',
'Agent',

View file

@ -28,12 +28,23 @@ export class PlanModeGuardDenyPermissionPolicy implements PermissionPolicy {
};
}
if (toolName !== 'TaskStop') return;
return {
kind: 'deny',
message:
'TaskStop is not available in plan mode. Call ExitPlanMode to exit plan mode before stopping a background task.',
};
if (toolName === 'TaskStop') {
return {
kind: 'deny',
message:
'TaskStop is not available in plan mode. Call ExitPlanMode to exit plan mode before stopping a background task.',
};
}
if (toolName === 'CronCreate' || toolName === 'CronDelete') {
return {
kind: 'deny',
message:
`${toolName} is not available in plan mode because it would mutate scheduled work that runs after plan exit. Call ExitPlanMode first.`,
};
}
return;
}
}

View file

@ -374,6 +374,9 @@ export class ToolManager {
new b.TaskListTool(background),
new b.TaskOutputTool(background),
new b.TaskStopTool(background),
this.agent.type !== 'sub' && new b.CronCreateTool(this.agent.cron),
this.agent.type !== 'sub' && new b.CronListTool(this.agent.cron),
this.agent.type !== 'sub' && new b.CronDeleteTool(this.agent.cron),
this.agent.skills !== undefined &&
this.agent.skills.registry.listInvocableSkills().length > 0 &&
new b.SkillTool(this.agent),

View file

@ -15,6 +15,9 @@ tools:
- TaskList
- TaskOutput
- TaskStop
- CronCreate
- CronList
- CronDelete
- ReadMediaFile
- TodoList
- Skill

View file

@ -181,6 +181,13 @@ export class Session {
async close(): Promise<void> {
try {
// Stop cron FIRST. `stopBackgroundTasksOnExit()` can await
// long-running background workers (especially with
// `background.keepAliveOnExit=false`); while we are waiting, a due
// cron tick would otherwise still call `turn.steer()` and start a
// fresh turn after shutdown has already begun, racing the
// metadata flush below.
await this.stopCronOnExit();
await this.stopBackgroundTasksOnExit();
await this.flushMetadata();
await this.triggerSessionEnd('exit');
@ -193,6 +200,15 @@ export class Session {
}
}
// Stop every agent's cron scheduler on close. No keepAlive notion — cron
// intervals reference the agent/session graph and must die with the session.
// `allSettled` keeps one agent's failure from blocking the rest.
private async stopCronOnExit(): Promise<void> {
await Promise.allSettled(
Array.from(this.agents.values(), (agent) => agent.cron.stop()),
);
}
private async stopBackgroundTasksOnExit(): Promise<void> {
const keepAliveOnExit = resolveConfigValue({
env: process.env,
@ -415,6 +431,7 @@ export class Session {
mcp: this.mcp,
backgroundMaxRunningTasks: this.config.background?.maxRunningTasks,
backgroundSessionDir: homedir,
cronSessionDir: homedir,
permission: this.permissionOptions(parentAgentId, config.permission),
telemetry: this.telemetry,
log: this.log.createChild({ agentId: id }),

View file

@ -5,15 +5,19 @@
* restart can list previously-running tasks (now lost) and emit terminal
* notifications.
*
* Writes use `atomicWrite` (write-tmp-fsync-rename) so a crash mid-write
* never leaves a half-truncated file.
* The per-id JSON layer (write / read / list / remove) is delegated to
* `createPerIdJsonStore`, which centralises atomic-write +
* path-traversal-guarded readdir for cron / background / anything else
* that needs session-scoped per-id JSON. This module keeps the
* background-specific shape, the output.log helpers, and the named
* exports (`writeTask`, ) the rest of `background/` already imports.
*/
import { statSync } from 'node:fs';
import { appendFile, mkdir, open, readFile, readdir, rm, stat, unlink } from 'node:fs/promises';
import { appendFile, mkdir, open, readFile, rm, stat } from 'node:fs/promises';
import { dirname, join } from 'pathe';
import { atomicWrite } from '../../utils/fs';
import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-json-store';
import type { BackgroundTaskStatus } from './manager';
/**
@ -67,13 +71,6 @@ function tasksDirOf(sessionDir: string): string {
return join(sessionDir, 'tasks');
}
function taskFile(sessionDir: string, taskId: string): string {
if (!VALID_TASK_ID.test(taskId)) {
throw new Error(`Invalid task id: "${taskId}"`);
}
return join(tasksDirOf(sessionDir), `${taskId}.json`);
}
function taskOutputDir(sessionDir: string, taskId: string): string {
if (!VALID_TASK_ID.test(taskId)) {
throw new Error(`Invalid task id: "${taskId}"`);
@ -85,11 +82,33 @@ export function taskOutputFile(sessionDir: string, taskId: string): string {
return join(taskOutputDir(sessionDir, taskId), 'output.log');
}
/**
* Cache of `createPerIdJsonStore` instances keyed by sessionDir.
*
* Per-id stores hold no state beyond their options object, so reusing
* the same instance across calls into this module is purely an allocation
* micro-optimisation. The cache is unbounded; the number of distinct
* session directories per process is small (typically 1) and the
* lifetime matches the process.
*/
const storeCache = new Map<string, PerIdJsonStore<PersistedTask>>();
function storeFor(sessionDir: string): PerIdJsonStore<PersistedTask> {
const cached = storeCache.get(sessionDir);
if (cached !== undefined) return cached;
const store = createPerIdJsonStore<PersistedTask>({
rootDir: sessionDir,
subdir: 'tasks',
idRegex: VALID_TASK_ID,
isValid: isValidPersistedTask,
entityName: 'task id',
});
storeCache.set(sessionDir, store);
return store;
}
/** Atomically write a task's persisted state. Creates dirs as needed. */
export async function writeTask(sessionDir: string, task: PersistedTask): Promise<void> {
await mkdir(tasksDirOf(sessionDir), { recursive: true, mode: 0o700 });
const target = taskFile(sessionDir, task.task_id);
await atomicWrite(target, JSON.stringify(task, null, 2));
await storeFor(sessionDir).write(task.task_id, task);
}
/** Read a single task file. Returns undefined when missing/corrupt. */
@ -97,22 +116,7 @@ export async function readTask(
sessionDir: string,
taskId: string,
): Promise<PersistedTask | undefined> {
// Path-traversal validation runs before the try/catch so callers see
// an explicit error instead of a misleading "missing" return.
const path = taskFile(sessionDir, taskId);
let raw: string;
try {
raw = await readFile(path, 'utf-8');
} catch {
return undefined;
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsed !== 'object' || parsed === null) return undefined;
return parsed as unknown as PersistedTask;
} catch {
return undefined;
}
return storeFor(sessionDir).read(taskId);
}
export async function appendTaskOutput(
@ -206,34 +210,22 @@ export async function readTaskOutputBytes(
}
}
/** Enumerate all persisted tasks for a session. Skips corrupt entries. */
export async function listTasks(sessionDir: string): Promise<PersistedTask[]> {
let entries: string[];
try {
entries = await readdir(tasksDirOf(sessionDir));
} catch {
return [];
}
const out: PersistedTask[] = [];
for (const entry of entries) {
if (!entry.endsWith('.json')) continue;
const taskId = entry.slice(0, -'.json'.length);
// Silently drop: filename basename is not a valid task id (stray files,
// legacy bg_* leftovers, etc.).
if (!VALID_TASK_ID.test(taskId)) continue;
const task = await readTask(sessionDir, taskId);
// Silently drop: JSON parse failed or file disappeared between readdir
// and readTask. writeTask uses an atomic temp+rename pattern so a
// genuinely truncated file in production is rare; if it happens we
// accept the loss rather than emit a ghost with no recoverable
// metadata beyond the filename.
if (task === undefined) continue;
// Silently drop: parsed JSON is missing one or more required fields
// for a PersistedTask. Treated the same as a missing file.
if (!isValidPersistedTask(task)) continue;
out.push(task);
}
return out;
/**
* Enumerate all persisted tasks for a session.
*
* Skips, silently:
* - basenames that don't match `VALID_TASK_ID` (stray files, legacy
* `bg_*` leftovers, partially-written temp files);
* - files that fail to read / parse;
* - records that fail `isValidPersistedTask` (canonical "spec with
* missing fields" failure mode).
*
* `writeTask` uses atomic temp+rename so a genuinely truncated file in
* production is rare; if it happens we accept the loss rather than
* emit a ghost with no recoverable metadata beyond the filename.
*/
export async function listTasks(sessionDir: string): Promise<readonly PersistedTask[]> {
return storeFor(sessionDir).list();
}
/**
@ -256,14 +248,15 @@ function isValidPersistedTask(obj: unknown): obj is PersistedTask {
);
}
/** Remove a task file (idempotent). */
/**
* Remove a task both the per-id JSON spec and the task's `output.log`
* directory. Idempotent: missing spec or missing output dir is not an
* error. Throws for an invalid `taskId` (path-traversal guard fires
* before any FS call).
*/
export async function removeTask(sessionDir: string, taskId: string): Promise<void> {
// Path-traversal validation outside try/catch.
const path = taskFile(sessionDir, taskId);
try {
await unlink(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
await storeFor(sessionDir).remove(taskId);
// `taskOutputDir` re-validates the id, so a malformed id throws here
// even if the spec rm above silently returned; matches prior behavior.
await rm(taskOutputDir(sessionDir, taskId), { recursive: true, force: true });
}

View file

@ -2,6 +2,9 @@ export * from '../background/manager';
export * from '../background/task-list';
export * from '../background/task-output';
export * from '../background/task-stop';
export * from '../cron/cron-create';
export * from '../cron/cron-delete';
export * from '../cron/cron-list';
export * from './collaboration/agent';
export * from './collaboration/ask-user';
export * from './collaboration/skill-tool';

View file

@ -0,0 +1,148 @@
/**
* Clock sources for the cron scheduler.
*
* Two distinct notions of time are kept apart on purpose:
*
* 1. wall-clock what the user perceives as "the current time". Used
* for cron expression matching, `createdAt`, and the 7-day stale
* judgment. May be overridden in tests / multi-process benches so
* that scenarios can run in simulated time without `setTimeout`.
*
* 2. monotonic ms a strictly non-decreasing counter that never
* jumps backwards across NTP adjustments, suspend/resume, or
* simulated-clock injection. Used for the poll cadence and the
* lock heartbeat anything where "did 5 seconds elapse since we
* last looked" must hold even when the wall clock is frozen.
*
* Mixing the two pollutes test reproducibility: a heartbeat tied to
* `wallNow()` will appear stuck when the test clock is frozen; a cron
* fire tied to `monoNowMs()` will not advance when the bench rewinds
* the simulated day. Every component in `tools/cron/` MUST take a
* `ClockSources` and route every time read through it.
*
* `monoNowMs` is ALWAYS `process.hrtime.bigint()` (converted to ms).
* It is not overridable accepting an external monotonic clock would
* defeat the safety net the lock heartbeat depends on.
*
* `wallNow` resolution is driven by the `KIMI_CRON_CLOCK` env var; see
* `resolveClockSources` below. Defaults to `Date.now()`.
*/
import { closeSync, openSync, readSync } from 'node:fs';
export interface ClockSources {
/**
* Wall-clock epoch milliseconds. May be overridden in tests / bench
* via `KIMI_CRON_CLOCK`. Used for cron matching, `createdAt`, stale
* judgment.
*/
wallNow(): number;
/**
* Strictly monotonic millisecond counter. Never overridden. Used for
* the 1-second poll cadence and the lock-heartbeat liveness window.
*/
monoNowMs(): number;
}
const systemMonoNowMs = (): number => Number(process.hrtime.bigint() / 1_000_000n);
/**
* Production default `Date.now()` + `process.hrtime.bigint()`. Used
* whenever `KIMI_CRON_CLOCK` is unset, set to `"system"`, or set to a
* spec that fails to parse.
*/
export const SYSTEM_CLOCKS: ClockSources = {
wallNow: () => Date.now(),
monoNowMs: systemMonoNowMs,
};
/**
* Resolve a `ClockSources` implementation from a spec string (typically
* `process.env.KIMI_CRON_CLOCK`).
*
* unset / `"system"` {@link SYSTEM_CLOCKS}
* `"file:<path>"` `wallNow` reads the first line of `<path>`
* on every call (sync the tick path is not
* async) and parses it as `Number(...)`. A
* missing file or bad parse falls back to
* `Date.now()` for that call. Used so a
* multi-process bench can share a single
* file-backed simulated clock.
*
* `monoNowMs` ALWAYS uses `process.hrtime.bigint()`. No spec overrides
* it see file header.
*
* Each `wallNow()` call re-reads its source. We deliberately do NOT
* cache, because a multi-process bench tick mutating the file must be
* picked up by every reader immediately; a cache would silently lock
* each process to its first observation.
*
* Unrecognised specs fall back to {@link SYSTEM_CLOCKS} (with a
* debug-log on stderr). This is deliberate bricking the agent on a
* typoed bench env var would be worse than running with system time.
*/
export function resolveClockSources(spec?: string): ClockSources {
if (spec === undefined || spec === '' || spec === 'system') {
return SYSTEM_CLOCKS;
}
if (spec.startsWith('file:')) {
const filePath = spec.slice('file:'.length);
if (filePath === '') {
debugInvalidSpec(spec, 'empty file path');
return SYSTEM_CLOCKS;
}
return {
wallNow: () => readFileWall(filePath),
monoNowMs: systemMonoNowMs,
};
}
debugInvalidSpec(spec, 'unrecognised scheme');
return SYSTEM_CLOCKS;
}
// Epoch-ms is always under 20 characters in practice; 64 bytes leaves
// slack for a leading newline / `\r` and prevents OOM on a hostile or
// accidentally-huge clock file (e.g. a `/dev/zero` redirect).
const MAX_CLOCK_FILE_BYTES = 64;
function readFileWall(filePath: string): number {
let bytesRead = 0;
const buf = Buffer.alloc(MAX_CLOCK_FILE_BYTES);
let fd: number;
try {
fd = openSync(filePath, 'r');
} catch {
return Date.now();
}
try {
bytesRead = readSync(fd, buf, 0, MAX_CLOCK_FILE_BYTES, 0);
} catch {
return Date.now();
} finally {
try {
closeSync(fd);
} catch {
/* swallow close errors */
}
}
const raw = buf.subarray(0, bytesRead).toString('utf8');
const firstLine = raw.split('\n', 1)[0]?.trim() ?? '';
if (firstLine === '') return Date.now();
const parsed = Number(firstLine);
if (!Number.isFinite(parsed)) return Date.now();
return parsed;
}
function debugInvalidSpec(spec: string, reason: string): void {
// We do not pull in a logger here — `clock.ts` is the lowest layer of
// the cron module and must stay dependency-free so it can be imported
// from anywhere (including lint rules, type files). A stderr write
// gated on KIMI_CRON_DEBUG is enough — production is silent.
if (process.env['KIMI_CRON_DEBUG'] === '1') {
process.stderr.write(
`[cron/clock] invalid KIMI_CRON_CLOCK spec ${JSON.stringify(spec)}: ${reason} — falling back to system clock\n`,
);
}
}

View file

@ -0,0 +1,84 @@
Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.
Uses standard 5-field cron in the user's local timezone: minute hour day-of-month month day-of-week. `0 9 * * *` means 9am local — no timezone conversion needed.
## One-shot tasks (recurring: false)
For "remind me at X" or "at <time>, do Y" requests — fire once then auto-delete.
Pin minute/hour/day-of-month/month to specific values:
"remind me at 2:30pm today to check the deploy" → cron: "30 14 <today_dom> <today_month> *", recurring: false
"tomorrow morning, run the smoke test" → cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false
## Recurring jobs (recurring: true, the default)
For "every N minutes" / "every hour" / "weekdays at 9am" requests:
"*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local)
## Avoid the :00 and :30 minute marks when the task allows it
Every user who asks for "9am" gets `0 9`, and every user who asks for "hourly" gets `0 *` — which means requests from across the planet land on the API at the same instant. When the user's request is approximate, pick a minute that is NOT 0 or 30:
"every morning around 9" → "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")
"hourly" → "7 * * * *" (not "0 * * * *")
"in an hour or so, remind me to..." → pick whatever minute you land on, don't round
Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past", coordinating with a meeting). When in doubt, nudge a few minutes early or late — the user will not notice, and the fleet will.
## Coalesce semantics
If the scheduler slept past multiple ideal fire times (laptop closed, long-running turn, etc.), only **one** fire is delivered when it wakes up. The origin carries `coalescedCount` showing how many ideal fires were collapsed into this single delivery. You should treat `coalescedCount > 1` as "I missed some checks; only the latest state matters" rather than running the prompt that many times.
## Cron-fire envelope
When a cron task fires, the prompt you scheduled is re-injected wrapped in an XML envelope that exposes the fire context:
```
<cron-fire jobId="..." cron="..." recurring="true|false" coalescedCount="N" stale="true|false">
<prompt>
your original prompt text, verbatim
</prompt>
</cron-fire>
```
The envelope is parseable. Use `coalescedCount > 1` to know multiple ideal fires were collapsed into a single delivery (treat as "only the latest state matters"), and `stale="true"` as a cue that the task is past its 7-day threshold.
## 7-day stale behavior
Recurring tasks that have been alive for more than 7 days fire one
final time with `stale: true` on the envelope, and the system then
auto-deletes the task. The flag is the model's notice that this is
the last delivery. If the schedule is still wanted, call `CronCreate`
again with the same `cron` and `prompt` — that resets `createdAt` and
starts a fresh 7-day window. One-shot tasks are never marked stale.
Bench / acceptance runs can set `KIMI_CRON_NO_STALE=1` to disable the
judgment entirely.
## Jitter behavior
Anti-herd jitter is applied deterministically per task id:
- Recurring: ideal fire time is shifted **forward** by an offset ≤ min(10% of the cron period, 15 minutes). A `*/5 * * * *` task can drift up to 30s; a `0 9 * * *` task can drift up to 15 minutes.
- One-shot: only when the ideal fire lands on `:00` or `:30` of the hour, the fire is pulled **earlier** by ≤ 90 seconds. Other minutes pass through unchanged.
Bench / acceptance tests can set `KIMI_CRON_NO_JITTER=1` to disable jitter entirely.
## One-shot vs recurring — when to pick which
Use `recurring: false` for "remind me at X" style requests, single deadlines, "in N minutes do Y", and any task that should not repeat. Use `recurring: true` for periodic polling (CI status, build watchers, scheduled reports), workday rituals, and anything the user explicitly described as recurring.
## Session lifetime
Cron tasks live in the current kimi CLI session. When you exit, they
are persisted under the session homedir; the next `kimi resume` of the
same session reloads them and the scheduler resumes from each task's
`createdAt`. Fire times that fell during the offline window are
collapsed into a single delivery via `coalescedCount` (and recurring
tasks past their 7-day window arrive with `stale: true` as their final
delivery).
Tasks do **not** carry over into a brand-new session — they are scoped
to the resumed session id, not to the working directory.
## Returned fields
`id` (8-hex), `humanSchedule` (English summary), `recurring`,
`nextFireAt` (ISO timestamp or null). `id` is needed by `CronDelete`.

View file

@ -0,0 +1,327 @@
/**
* CronCreateTool schedule a prompt to be re-injected into this session
* at a future wall-clock time, either once (`recurring: false`) or on a
* cron cadence (`recurring: true`, the default).
*
* Tasks live in `SessionCronStore` and are mirrored to
* `<sessionDir>/cron/<id>.json` via `CronManager.addTask`, so a
* `kimi resume` of the same session reloads them and the scheduler
* picks up where it left off (fires that fell during downtime are
* collapsed into a single delivery with `coalescedCount`). Tasks do
* NOT carry over into a brand-new session.
*
* The tool itself is pure validation + bookkeeping; the firing /
* coalesce / jitter logic lives in `CronScheduler` (one layer below)
* and `CronManager` (one layer up). This file only knows how to:
*
* 1. validate the request (killswitch, cron parse, 5-year window,
* session cap, byte-length cap);
* 2. add it to the manager (which writes through to disk on success);
* 3. report back the post-jitter `nextFireAt` and a human-readable
* schedule for the model's benefit;
* 4. emit `cron_scheduled` telemetry through the manager (the tool
* does **not** reach into `manager.agent.telemetry` directly).
*/
import { z } from 'zod';
import type { BuiltinTool } from '../../agent/tool';
import type { CronManager } from '../../agent/cron';
import type { ToolExecution } from '../../loop/types';
import { toInputJsonSchema } from '../support/input-schema';
import { literalRulePattern } from '../support/rule-match';
import {
computeNextCronRun,
cronToHuman,
hasFireWithinYears,
parseCronExpression,
type ParsedCronExpression,
} from './cron-expr';
import {
jitteredNextCronRunMs,
oneShotJitteredNextCronRunMs,
} from './jitter';
import CRON_CREATE_DESCRIPTION from './cron-create.md';
// ── Constants ────────────────────────────────────────────────────────
/**
* Session-level cap on the number of live cron tasks. Exported so tests
* can pre-fill the store without re-deriving the magic number.
*/
export const MAX_CRON_JOBS_PER_SESSION = 50;
/**
* Hard ceiling on `prompt` byte length (UTF-8). The zod `.max(...)`
* upstream is in code units, which underflows multi-byte input
* (`'汉'.length === 1` even though it is 3 bytes); we re-check using
* `Buffer.byteLength` so the budget reflects the actual on-the-wire
* size the model will eventually see.
*/
const MAX_PROMPT_BYTES = 8 * 1024;
/**
* Maximum forward distance allowed for a one-shot (`recurring: false`)
* cron's first fire. The canonical footgun is following the tool docs
* and pinning today's day/month for a "remind me at X today"
* reminder if submission lands seconds past the target minute,
* `computeNextCronRun` rolls the match to next year (~365 days),
* which is still inside the 5-year `hasFireWithinYears` window, and
* the user gets a year-late notification instead of an error. 350
* days is tight enough to catch the rollover (365 ± epsilon) while
* still leaving room for legitimate "schedule for late this year"
* pinning from early-year submissions. A user who genuinely wants a
* one-shot 11+ months out is better served by a natural-language
* date in the prompt body than by stretching the cron field semantics.
*/
const ONE_SHOT_MAX_FUTURE_MS = 350 * 24 * 60 * 60 * 1000;
// ── Input schema ─────────────────────────────────────────────────────
export const CronCreateInputSchema = z.object({
cron: z
.string()
.describe(
'5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes, "30 14 28 2 *" = Feb 28 at 2:30pm local once).',
),
prompt: z
.string()
.min(1)
.max(MAX_PROMPT_BYTES)
.describe('The prompt to enqueue at each fire time.'),
recurring: z
.boolean()
.optional()
.default(true)
.describe(
'true (default) = fire on every cron match until deleted or auto-expired after 7 days. false = fire once at the next match, then auto-delete. Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.',
),
});
export type CronCreateInput = z.Infer<typeof CronCreateInputSchema>;
// ── Output shape (internal) ─────────────────────────────────────────
interface CronCreateOutput {
readonly id: string;
readonly cron: string;
readonly humanSchedule: string;
readonly recurring: boolean;
readonly nextFireAt: number | null;
}
// ── Implementation ───────────────────────────────────────────────────
export class CronCreateTool implements BuiltinTool<CronCreateInput> {
readonly name = 'CronCreate' as const;
readonly description = CRON_CREATE_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(
CronCreateInputSchema,
);
constructor(private readonly manager: CronManager) {}
resolveExecution(args: CronCreateInput): ToolExecution {
// 1. Global killswitch — checked first so a flipped env stops all
// further work, including the cron parse which can throw on
// legitimately-malformed input.
if (process.env['KIMI_DISABLE_CRON'] === '1') {
return {
isError: true,
output: 'Cron scheduling is disabled (KIMI_DISABLE_CRON=1).',
};
}
// 2. Normalize whitespace BEFORE parsing so `parsed.raw` (which
// `cronToHuman` falls back to for non-template shapes) is the
// single-line form. Otherwise tabs/newlines from the raw input
// leak into the rendered `humanSchedule:` row and break the
// one-key-per-line tool output format. Parse errors still report
// against canonical field positions; only whitespace is
// degraded, not semantics.
const normalizedCron = args.cron.trim().split(/\s+/).join(' ');
// 3. Parse the cron expression. Any parse failure is a user error
// rather than an internal one, so we surface the message
// verbatim — the parser is already careful to name the
// offending field.
let parsed: ParsedCronExpression;
try {
parsed = parseCronExpression(normalizedCron);
} catch (err) {
return {
isError: true,
output: `Invalid cron expression: ${
err instanceof Error ? err.message : String(err)
}`,
};
}
// 4. Reject "legal but never fires within 5 years" — the same
// bound the scheduler uses internally to refuse to spin.
// `0 0 31 2 *` is the canonical example. The exact `nowMs` does
// not matter for this judgment (it only changes the search
// window by < 5 years), so we read it here at prepare time and
// re-read inside `execute()` for the actual schedule anchor.
const nowAtPrepare = this.manager.clocks.wallNow();
if (!hasFireWithinYears(parsed, 5, nowAtPrepare)) {
return {
isError: true,
output: `Cron expression ${JSON.stringify(
normalizedCron,
)} has no fire within 5 years; refusing to schedule.`,
};
}
// 5. Session-level cap — preliminary check. We re-check inside
// `execute()` because manual-approval mode can delay execution
// long enough for parallel CronCreate calls to all pass this
// gate and then collectively breach the cap on insert.
if (this.manager.store.list().length >= MAX_CRON_JOBS_PER_SESSION) {
return {
isError: true,
output: `Cron job cap reached (max ${String(
MAX_CRON_JOBS_PER_SESSION,
)} per session).`,
};
}
// 6. Byte-length cap. zod's `.max()` counts code units, which is
// not the budget we actually want for a multi-byte prompt; the
// Buffer.byteLength check makes the 8 KiB intent literal.
const byteLen = Buffer.byteLength(args.prompt, 'utf8');
if (byteLen > MAX_PROMPT_BYTES) {
return {
isError: true,
output: `Prompt exceeds ${String(
MAX_PROMPT_BYTES,
)} bytes (got ${String(byteLen)}).`,
};
}
// `recurring` is defaulted to true upstream; we re-derive the
// boolean (rather than trusting the post-default arg) to match the
// canonical "recurring iff not explicitly false" convention used
// everywhere else in the cron stack.
const recurring = args.recurring !== false;
// 7. One-shot "rolled to next year" guard. The tool docs recommend
// pinning today's dom/month for "remind me at X today"; if
// submission lands seconds past the target minute,
// `computeNextCronRun` returns next year's match, the 5-year
// window above accepts it, and the user's reminder fires a
// year late. Reject when the first ideal fire is more than
// ~one year out — for a 5-field cron this can only mean the
// pinned date already passed this year. Recurring tasks are
// unaffected; they re-fire as expected.
if (!recurring) {
const firstFire = computeNextCronRun(parsed, nowAtPrepare);
if (
firstFire !== null &&
firstFire - nowAtPrepare > ONE_SHOT_MAX_FUTURE_MS
) {
return {
isError: true,
output: `One-shot cron ${JSON.stringify(
normalizedCron,
)} would not fire until ${new Date(
firstFire,
).toISOString()} (more than a year out). If you meant "today" or a near date, the pinned day/month has already passed this year pick a future date or use wildcards.`,
};
}
}
return {
description: recurring
? `Scheduling cron ${normalizedCron}`
: `Scheduling one-shot ${normalizedCron}`,
// Scope `session` approval to this exact payload. Without the
// payload in the rule, a single approved CronCreate would
// authorize any future scheduled prompt for the rest of the
// session — including ones the user never saw before approving.
// Matches the Bash / Write / Edit convention of including the
// command / path in the literal rule pattern.
approvalRule: literalRulePattern(
this.name,
JSON.stringify({
cron: normalizedCron,
prompt: args.prompt,
recurring,
}),
),
execute: async () => {
// Anchor the schedule to the moment of execution, not the
// moment of preparation. Manual-approval mode can leave
// resolveExecution() and execute() minutes apart; inserting
// with a stale `nowMs` would let the scheduler treat a fresh
// one-shot as already overdue and fire it on the next tick
// with a phantom `coalescedCount > 1`.
const nowMs = this.manager.clocks.wallNow();
// Re-check the session cap against the live store size so two
// concurrently-prepared CronCreate calls cannot collectively
// breach it after both passed the prepare-time check.
if (this.manager.store.list().length >= MAX_CRON_JOBS_PER_SESSION) {
return {
isError: true,
output: `Cron job cap reached (max ${String(
MAX_CRON_JOBS_PER_SESSION,
)} per session).`,
};
}
const task = this.manager.addTask({
cron: normalizedCron,
prompt: args.prompt,
recurring,
});
// Post-jitter next-fire for the response. `computeNextCronRun`
// returns `null` if there's no fire in the 5-year window (we
// already rejected that above, but be defensive — the jitter
// helper would then have nothing to shift).
const ideal = computeNextCronRun(parsed, nowMs);
const nextFireAt =
ideal === null
? null
: recurring
? jitteredNextCronRunMs(task, parsed, ideal)
: oneShotJitteredNextCronRunMs(task, ideal);
const humanSchedule = cronToHuman(parsed);
// Telemetry goes through the manager so the tool stays out of
// `manager.agent.telemetry`. CronDelete (P1.6) will use the
// symmetric `emitDeleted`.
this.manager.emitScheduled(task);
const output: CronCreateOutput = {
id: task.id,
cron: normalizedCron,
humanSchedule,
recurring,
nextFireAt,
};
return {
output: formatOutput(output),
isError: false,
message: `Scheduled cron ${task.id}`,
};
},
};
}
}
function formatOutput(o: CronCreateOutput): string {
const lines = [
`id: ${o.id}`,
`cron: ${o.cron}`,
`humanSchedule: ${o.humanSchedule}`,
`recurring: ${String(o.recurring)}`,
`nextFireAt: ${
o.nextFireAt === null ? 'null' : new Date(o.nextFireAt).toISOString()
}`,
];
return lines.join('\n');
}

View file

@ -0,0 +1,39 @@
Cancel a scheduled cron job by id.
Use this tool to remove a cron task previously scheduled with
`CronCreate`. The `id` is the 8-hex value returned by `CronCreate`, or
shown in the `id:` column of `CronList` — quote it verbatim, no
prefix.
Behaviour by task kind:
- **Recurring task** (`recurring: true`): stops all future fires
immediately. The scheduler picks up the deletion on its next tick.
- **One-shot task** (`recurring: false`): cancels the pending fire if
it has not happened yet. One-shots that have already fired
auto-delete themselves, so calling `CronDelete` on a fired one-shot
returns "no cron job with id ...".
Not-found is reported as an error (not a silent no-op) so you can
correct yourself — typically by calling `CronList` to see which ids
are actually live, rather than re-trying with the same stale id.
Refresh pattern (use when you want a stale recurring schedule to
continue):
Stale recurring tasks are auto-deleted by the system after their final
fire — there is nothing for `CronDelete` to remove at that point. To
keep the schedule running, just call `CronCreate` with the same `cron`
and `prompt`. Use `CronList`'s `prompt` field to recall the original
text after a context compaction.
`CronDelete` remains the right call when you want to cancel a task
that is still live (recurring not yet stale, or a one-shot still
pending).
Guidelines:
- Cron deletion is irreversible — there is no undo. If you delete the
wrong task, you must re-create it with `CronCreate`.
- If the model is unsure which id is current (e.g. after a context
compaction), call `CronList` first rather than guessing.

View file

@ -0,0 +1,119 @@
/**
* CronDeleteTool cancel a scheduled cron job by id.
*
* The tool's job is intentionally narrow: validate the id shape, ask the
* session store to drop the entry, and report whether anything was
* actually removed. The scheduler picks up the deletion on its next
* `tick()` automatically because `source: () => store.list()` is
* re-read every pass there is no separate "unsubscribe" handshake to
* keep in sync.
*
* Why "not found" is reported as an error:
*
* - The model uses the result string to decide whether to follow up
* (e.g. confirm to the user, retry, or move on). Returning a
* success-shaped message for a no-op would silently teach the model
* that CronDelete is idempotent against missing ids, which it is
* not the next `CronList` would still show whatever id the model
* thought it deleted. Surfacing `isError: true` lets the model
* correct itself (typically by calling `CronList` again).
*
* Why the manager is not consulted for telemetry on the not-found
* branch:
*
* - `cron_deleted` records an actual state change. Emitting it on a
* miss would inflate the metric and break parity with `cron_create`
* (which never fires on a rejected schedule). The branch is fully
* observable through tool-call telemetry already.
*
* Refresh-cron pattern this tool participates in:
*
* When `CronList` (or a fired job's origin) reports `stale: true`, the
* documented "refresh" flow is `CronDelete(id)` followed by a fresh
* `CronCreate` with the same cron + prompt. That resets `createdAt`,
* clears the stale flag, and rejoins the herd-avoidance jitter draw
* with a new task id. The doc string spells this out so the model can
* reach for it without prompting from a system message.
*/
import { z } from 'zod';
import type { BuiltinTool } from '../../agent/tool';
import type { CronManager } from '../../agent/cron';
import type { ToolExecution } from '../../loop/types';
import { toInputJsonSchema } from '../support/input-schema';
import CRON_DELETE_DESCRIPTION from './cron-delete.md';
// ── Constants ────────────────────────────────────────────────────────
/**
* Same id shape used by `SessionCronStore` and the on-disk persistence
* layer. We re-check here so a malformed id never reaches the store
* the regex is the single source of truth for the on-the-wire id
* format and an early reject keeps the error message close to the
* user's input.
*/
const ID_PATTERN = /^[0-9a-f]{8}$/;
// ── Input schema ─────────────────────────────────────────────────────
export const CronDeleteInputSchema = z.object({
id: z
.string()
.describe('The 8-hex cron job id returned by CronCreate / CronList.'),
});
export type CronDeleteInput = z.infer<typeof CronDeleteInputSchema>;
// ── Implementation ───────────────────────────────────────────────────
export class CronDeleteTool implements BuiltinTool<CronDeleteInput> {
readonly name = 'CronDelete' as const;
readonly description = CRON_DELETE_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(
CronDeleteInputSchema,
);
constructor(private readonly manager: CronManager) {}
resolveExecution(args: CronDeleteInput): ToolExecution {
// Format check up front. The store would reject the lookup anyway,
// but the message is more actionable when it names the constraint
// ("8 lowercase hex characters") rather than a generic "not found".
if (!ID_PATTERN.test(args.id)) {
return {
isError: true,
output: `Invalid cron job id ${JSON.stringify(
args.id,
)} must be 8 lowercase hex characters.`,
};
}
return {
description: `Deleting cron ${args.id}`,
approvalRule: this.name,
execute: async () => {
const removed = this.manager.removeTasks([args.id]);
if (removed.length === 0) {
// Not found is reported as an error so the model can correct
// itself — see the module header for the rationale. We
// deliberately do NOT emit `cron_deleted` here; the metric
// tracks real state changes.
return {
isError: true,
output: `No cron job with id ${args.id}.`,
};
}
// Telemetry goes through the manager so the tool stays out of
// `manager.agent.telemetry` — symmetric with `CronCreate`'s use
// of `emitScheduled`.
this.manager.emitDeleted(args.id);
return {
output: `Deleted cron job ${args.id}.`,
isError: false,
};
},
};
}
}

View file

@ -0,0 +1,451 @@
/**
* 5-field cron expression parsing and "next fire time" computation, in
* local time. Self-contained no external cron library is used because
* upstream `claude-code` mirrors the same semantics and we need exact
* lock-step behaviour with their implementation.
*
* Two flavours of correctness we care about:
*
* 1. **Semantics.** Standard 5 fields (minute hour day-of-month month
* day-of-week). Day-of-month and day-of-week combine with cron's
* OR rule when both are restricted (POSIX/Vixie tradition). dow
* accepts 0..7 with 7 folded to 0 (Sunday).
*
* 2. **Termination.** Computing `next` for a legal-but-never-fires
* expression like `0 0 31 2 *` must not spin. We bound the search
* at a fixed window (5 years by default) and return `null` past
* that the validator at `CronCreate` reuses this signal.
*/
/** A parsed cron expression. Opaque to callers — pass it back into {@link computeNextCronRun}. */
export interface ParsedCronExpression {
readonly raw: string;
readonly minutes: ReadonlySet<number>;
readonly hours: ReadonlySet<number>;
readonly daysOfMonth: ReadonlySet<number>;
readonly months: ReadonlySet<number>;
readonly daysOfWeek: ReadonlySet<number>;
/** True if the source field was `*` — needed so cron's dom/dow OR rule fires only when both are restricted. */
readonly daysOfMonthWildcard: boolean;
readonly daysOfWeekWildcard: boolean;
}
const MINUTE_RANGE = { min: 0, max: 59 } as const;
const HOUR_RANGE = { min: 0, max: 23 } as const;
const DOM_RANGE = { min: 1, max: 31 } as const;
const MONTH_RANGE = { min: 1, max: 12 } as const;
const DOW_RANGE = { min: 0, max: 7 } as const; // 7 → 0 fold after parse
const MS_PER_MINUTE = 60_000;
/**
* Parse a 5-field cron expression. Throws with a message naming the
* offending field on any syntax error. Whitespace-separated; exactly 5
* fields. Tokens supported per field: `*`, integers, ranges (`a-b`),
* lists (`a,b,c`), and step (e.g. star-slash-n or `a-b/n`).
*/
export function parseCronExpression(expr: string): ParsedCronExpression {
if (typeof expr !== 'string') {
throw new TypeError('cron expression must be a string');
}
const trimmed = expr.trim();
if (trimmed === '') {
throw new Error('cron expression is empty');
}
const fields = trimmed.split(/\s+/);
if (fields.length !== 5) {
throw new Error(
`cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week); got ${fields.length}`,
);
}
const [minField, hourField, domField, monthField, dowField] = fields as [
string,
string,
string,
string,
string,
];
const minutes = parseField(minField, MINUTE_RANGE.min, MINUTE_RANGE.max, 'minute');
const hours = parseField(hourField, HOUR_RANGE.min, HOUR_RANGE.max, 'hour');
const daysOfMonth = parseField(domField, DOM_RANGE.min, DOM_RANGE.max, 'day-of-month');
const months = parseField(monthField, MONTH_RANGE.min, MONTH_RANGE.max, 'month');
const dowRaw = parseField(dowField, DOW_RANGE.min, DOW_RANGE.max, 'day-of-week');
const daysOfWeek = new Set<number>();
for (const v of dowRaw) daysOfWeek.add(v === 7 ? 0 : v);
return {
raw: trimmed,
minutes,
hours,
daysOfMonth,
months,
daysOfWeek,
daysOfMonthWildcard: isWildcard(domField),
daysOfWeekWildcard: isWildcard(dowField),
};
}
function isWildcard(field: string): boolean {
// `*` and `*/n` both leave the field unconstrained in the
// "every value" sense — but only bare `*` should suppress the dom/dow
// OR rule. cron's tradition treats `*/n` as a restriction.
return field === '*';
}
function parseField(field: string, min: number, max: number, name: string): Set<number> {
if (field === '') {
throw new Error(`cron ${name} field is empty`);
}
const out = new Set<number>();
const terms = field.split(',');
for (const term of terms) {
if (term === '') {
throw new Error(`cron ${name} field has empty term in list`);
}
addTerm(out, term, min, max, name);
}
if (out.size === 0) {
throw new Error(`cron ${name} field matches no values`);
}
return out;
}
// Cron numeric fields are digit-only. `Number(...)` would otherwise
// accept `''` (→ 0), `'1e1'`, `'0x10'`, `'+5'`, `' 3 '`, etc. — none
// of which are valid cron syntax. This regex gate runs before the
// conversion to surface a typo as a parse error instead of silently
// rescheduling the task.
const DIGIT_ONLY = /^\d+$/;
function parseCronInt(raw: string, name: string, role: string): number {
if (!DIGIT_ONLY.test(raw)) {
throw new Error(
`cron ${name} ${role} must be a non-negative integer with digits only (got ${JSON.stringify(raw)})`,
);
}
return Number.parseInt(raw, 10);
}
function addTerm(out: Set<number>, term: string, min: number, max: number, name: string): void {
let rangePart = term;
let step = 1;
const slash = term.indexOf('/');
if (slash !== -1) {
rangePart = term.slice(0, slash);
const stepStr = term.slice(slash + 1);
if (stepStr === '') {
throw new Error(`cron ${name} step is empty in "${term}"`);
}
const parsedStep = parseCronInt(stepStr, name, 'step');
if (parsedStep <= 0) {
throw new Error(`cron ${name} step must be a positive integer (got "${stepStr}")`);
}
step = parsedStep;
if (rangePart === '') {
throw new Error(`cron ${name} step needs a range or "*" before "/" in "${term}"`);
}
}
let lo: number;
let hi: number;
if (rangePart === '*') {
lo = min;
hi = max;
} else {
const dash = rangePart.indexOf('-');
if (dash === -1) {
const single = parseCronInt(rangePart, name, 'value');
if (single < min || single > max) {
throw new Error(`cron ${name} value ${single} out of range ${min}..${max}`);
}
// A bare single value with a step (`5/10`) is unusual; treat as
// "from value through max stepping by N", which is what most cron
// dialects do.
if (slash !== -1) {
lo = single;
hi = max;
} else {
out.add(single);
return;
}
} else {
const loStr = rangePart.slice(0, dash);
const hiStr = rangePart.slice(dash + 1);
lo = parseCronInt(loStr, name, 'range lower bound');
hi = parseCronInt(hiStr, name, 'range upper bound');
if (lo < min || hi > max || lo > hi) {
throw new Error(
`cron ${name} range ${lo}-${hi} out of bounds (must be ${min}..${max}, ascending)`,
);
}
}
}
for (let v = lo; v <= hi; v += step) {
out.add(v);
}
}
/**
* Find the next wall-clock epoch ms strictly greater than `fromMs` that
* satisfies `expr`, using local-time semantics. Returns `null` if no
* match exists inside the default 5-year search window defensive
* against legal-but-never-fires expressions like `0 0 31 2 *`.
*
* Uses an O(transitions) field-by-field skip algorithm rather than a
* minute-by-minute scan month mismatch advances by months, day
* mismatch by days, etc., so the worst case for `0 12 1 1 *` is a
* handful of iterations, not 43 200.
*
* Termination is bounded by a wall-time deadline on the candidate
* date not an iteration count so a pathological expression that
* spends every iteration on `advanceMonth` still bails inside the
* documented window. A secondary `HARD_ITERATION_CAP` guards against
* a future refactor that fails to advance the date.
*/
export function computeNextCronRun(expr: ParsedCronExpression, fromMs: number): number | null {
return nextRunWithinMinutes(expr, fromMs, 5 * 366 * 24 * 60);
}
/**
* True iff at least one fire exists within `years` years of `fromMs`.
* Used by CronCreate validation to reject `0 0 31 2 *` and friends up
* front, with the same wall-time deadline {@link computeNextCronRun}
* uses (so the validator never says yes to something the scheduler
* will later refuse to compute).
*/
export function hasFireWithinYears(
expr: ParsedCronExpression,
years: number,
fromMs: number,
): boolean {
const cap = Math.max(1, Math.floor(years * 366 * 24 * 60));
return nextRunWithinMinutes(expr, fromMs, cap) !== null;
}
function nextRunWithinMinutes(
expr: ParsedCronExpression,
fromMs: number,
capMinutes: number,
): number | null {
// Seek strictly into the next minute: drop seconds/ms and add one
// minute. This guarantees we never return `fromMs` itself.
const start = new Date(fromMs);
start.setSeconds(0, 0);
const date = new Date(start.getTime() + MS_PER_MINUTE);
// Wall-clock deadline. Each loop body only advances `date` forward
// (month / day / hour / minute), so a single deadline check on
// `date.getTime()` bounds total work regardless of which granularity
// dominates — including the pathological case where `advanceMonth`
// is the dominant op (e.g. `0 0 30 2 *` never matches February).
const deadlineMs = fromMs + capMinutes * MS_PER_MINUTE;
// Secondary safety net: if a future refactor accidentally fails to
// advance `date`, this prevents an infinite loop. Generous enough to
// cover any minute-by-minute walk within a sane window, and many
// orders of magnitude below the previous iteration bound.
let iterations = 0;
const HARD_ITERATION_CAP = 10_000_000;
while (date.getTime() <= deadlineMs && iterations++ < HARD_ITERATION_CAP) {
// Month — coarsest. If wrong, jump to day 1 of the next allowed
// month and restart the day check.
if (!expr.months.has(date.getMonth() + 1)) {
advanceMonth(date);
continue;
}
// Day. Cron-style OR: when both dom and dow are restricted, match
// either; when one is `*`, only the other constrains.
if (!dayMatches(expr, date)) {
advanceDay(date);
continue;
}
if (!expr.hours.has(date.getHours())) {
advanceHour(date);
continue;
}
if (!expr.minutes.has(date.getMinutes())) {
advanceMinute(date);
continue;
}
return date.getTime();
}
return null;
}
function dayMatches(expr: ParsedCronExpression, date: Date): boolean {
const dom = date.getDate();
const dow = date.getDay();
const domOk = expr.daysOfMonth.has(dom);
const dowOk = expr.daysOfWeek.has(dow);
if (expr.daysOfMonthWildcard && expr.daysOfWeekWildcard) return true;
if (expr.daysOfMonthWildcard) return dowOk;
if (expr.daysOfWeekWildcard) return domOk;
// Both restricted: cron-style OR.
return domOk || dowOk;
}
function advanceMonth(date: Date): void {
// Jump to the 1st of the next month at 00:00. Date's wrap-around
// handles year rollover for us.
date.setDate(1);
date.setHours(0, 0, 0, 0);
date.setMonth(date.getMonth() + 1);
}
function advanceDay(date: Date): void {
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 1);
}
function advanceHour(date: Date): void {
date.setMinutes(0, 0, 0);
date.setHours(date.getHours() + 1);
}
function advanceMinute(date: Date): void {
date.setSeconds(0, 0);
date.setMinutes(date.getMinutes() + 1);
}
const MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const;
const DAY_NAMES = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
] as const;
/**
* Cheap human-readable summary of an expression. Falls back to the raw
* string when the shape isn't one of the patterns we recognise the
* caller (CronList) uses this purely for display, so a wordy fallback
* is fine and we don't try to be exhaustive.
*/
export function cronToHuman(expr: ParsedCronExpression): string {
const allMin = isFullRange(expr.minutes, 0, 59);
const allHour = isFullRange(expr.hours, 0, 23);
const allDom = expr.daysOfMonthWildcard;
const allMonth = isFullRange(expr.months, 1, 12);
const allDow = expr.daysOfWeekWildcard;
// every N minutes — common LLM pattern (`*/5 * * * *`).
if (allHour && allDom && allMonth && allDow) {
const step = detectStep(expr.minutes, 0, 59);
if (step !== null && step > 1) return `every ${step} minutes`;
if (allMin) return 'every minute';
if (expr.minutes.size === 1) {
const m = [...expr.minutes][0]!;
return `at minute ${m} of every hour`;
}
}
// every N hours.
if (expr.minutes.size === 1 && allDom && allMonth && allDow) {
const m = [...expr.minutes][0]!;
const step = detectStep(expr.hours, 0, 23);
if (step !== null && step > 1) {
return `every ${step} hours at minute ${pad(m)}`;
}
}
// at HH:MM every day, optional dow restriction.
if (
expr.minutes.size === 1 &&
expr.hours.size === 1 &&
allDom &&
allMonth
) {
const h = [...expr.hours][0]!;
const m = [...expr.minutes][0]!;
if (allDow) return `at ${pad(h)}:${pad(m)} every day`;
const dowStr = formatDows(expr.daysOfWeek);
if (dowStr !== null) return `at ${pad(h)}:${pad(m)} on ${dowStr}`;
}
// at HH:MM on day N of <month>.
if (
expr.minutes.size === 1 &&
expr.hours.size === 1 &&
expr.daysOfMonth.size === 1 &&
!expr.daysOfMonthWildcard &&
expr.months.size === 1 &&
allDow
) {
const h = [...expr.hours][0]!;
const m = [...expr.minutes][0]!;
const d = [...expr.daysOfMonth][0]!;
const mo = [...expr.months][0]!;
return `at ${pad(h)}:${pad(m)} on day ${d} of ${MONTH_NAMES[mo - 1]}`;
}
return expr.raw;
}
function isFullRange(set: ReadonlySet<number>, min: number, max: number): boolean {
if (set.size !== max - min + 1) return false;
for (let v = min; v <= max; v++) if (!set.has(v)) return false;
return true;
}
/**
* If the set looks like `{min, min+step, ..., <=max}` with a constant
* step, return `step`. Otherwise null. Used to pretty-print star-slash-N.
*/
function detectStep(set: ReadonlySet<number>, min: number, max: number): number | null {
const values = [...set].toSorted((a, b) => a - b);
if (values.length < 2) return null;
if (values[0] !== min) return null;
const step = values[1]! - values[0]!;
if (step <= 0) return null;
let expected = min;
for (const v of values) {
if (v !== expected) return null;
expected += step;
}
// The last expected value should exceed `max` by less than `step`.
if (expected - step > max) return null;
return step;
}
function formatDows(set: ReadonlySet<number>): string | null {
const values = [...set].toSorted((a, b) => a - b);
if (values.length === 0) return null;
// Mon-Fri shortcut.
if (values.length === 5 && values.every((v, i) => v === i + 1)) {
return 'weekdays';
}
if (values.length === 2 && values[0] === 0 && values[1] === 6) {
return 'weekends';
}
return values.map((v) => DAY_NAMES[v]!).join(', ');
}
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}

View file

@ -0,0 +1,44 @@
/**
* Cron-fire XML rendering produces the chat-history injection text
* the scheduler hands to the model when a CronTask fires.
*
* Output shape:
* <cron-fire jobId="..." cron="..." recurring="true|false" coalescedCount="N" stale="true|false">
* <prompt>
* verbatim user prompt
* </prompt>
* </cron-fire>
*
* Mirrors `agent/context/notification-xml.ts`: attribute values are
* escape-safe via `stringAttr`, but the body inside `<prompt>` is
* verbatim. The injection target is an LLM-visible transcript where
* double-escaping would be noisier than literal punctuation.
*/
import type { CronJobOrigin } from '../../agent/context/types';
export function renderCronFireXml(
origin: CronJobOrigin,
prompt: string,
): string {
const jobId = stringAttr(origin.jobId, 'unknown');
const cron = stringAttr(origin.cron, 'unknown');
const recurring = origin.recurring ? 'true' : 'false';
const coalescedCount = String(origin.coalescedCount);
const stale = origin.stale ? 'true' : 'false';
return [
`<cron-fire jobId="${jobId}" cron="${cron}" recurring="${recurring}" coalescedCount="${coalescedCount}" stale="${stale}">`,
'<prompt>',
prompt,
'</prompt>',
'</cron-fire>',
].join('\n');
}
function stringAttr(value: unknown, fallback: string): string {
if (typeof value !== 'string' || value.length === 0) return fallback;
// Attribute boundary safety: escape `&` and `"`. Body-text `<` / `>`
// stay untouched — the injection target is an LLM-visible transcript
// where double-escaping would be noisier than literal punctuation.
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
}

View file

@ -0,0 +1,53 @@
List all cron jobs currently scheduled in this session.
Use this tool to see every pending cron task — both recurring jobs and
one-shot reminders — that you (or the user) have scheduled with
`CronCreate`. The output is the entry point for inspecting scheduled
work: it returns a stable id, the original cron expression, a human
rendering, the next post-jitter fire time, the recurring flag, the
task's age in days, and a stale indicator.
Each record carries:
- `id` — the 8-hex task id. Pass this to `CronDelete` to remove the
task, or quote it in user-facing messages when asking for
confirmation.
- `cron` — the verbatim 5-field cron expression as scheduled.
- `humanSchedule` — plain-English rendering (e.g. `every 5 minutes`).
- `prompt` — the scheduled prompt text, JSON-encoded so embedded
newlines stay on one line. Truncated to 200 UTF-8 bytes with
`…(truncated)` if longer. Use this to recall what a task is for
after a context compaction, and as the source for the
`CronCreate` refresh ritual.
- `nextFireAt` — ISO timestamp of the next fire **after jitter has
been applied**. The actual fire may land slightly before or after a
round `:00` / `:30` minute mark due to herd-avoidance jitter; this
is the value the scheduler will compare against, so it reflects
what will really happen. `null` if the expression has no fire in
the next 5 years (should not happen for tasks created through
`CronCreate`, which validates).
- `recurring``true` for cadenced jobs, `false` for one-shots.
- `ageDays``(now - createdAt) / day`, two decimal places. Useful
when deciding whether a long-running cron is still relevant.
- `stale``true` when a recurring task is older than 7 days. The
system **auto-deletes the task after this fire** to bound session
lifetime; the `stale: true` flag is the model's notice that this is
the final delivery. To resume the same schedule, call `CronCreate`
again with the original `cron` and `prompt` (the `prompt` row above
carries it for exactly this purpose). One-shots are never marked
stale — they fire at most once by construction.
`KIMI_CRON_NO_STALE=1` disables the judgment entirely (bench /
acceptance runs).
Guidelines:
- This tool is read-only and never mutates state, so it is always
safe to call (including in plan mode).
- The empty case returns `cron_jobs: 0\nNo cron jobs scheduled.`. Cron
tasks survive a `kimi resume` of the same session but do not bleed
into new sessions.
- After a context compaction, or whenever you are unsure which cron
jobs are live, call this tool to re-enumerate them rather than
guessing ids from earlier in the conversation.
- Records are separated by a line containing just `---`, in the
insertion order they were scheduled.

View file

@ -0,0 +1,170 @@
/**
* CronListTool enumerate the cron tasks currently scheduled in this
* session.
*
* Read-only and side-effect-free. The output mirrors the
* `key: value\n---\n` shape used by `tools/background/task-list.ts` so
* the LLM sees a consistent record layout across the "list scheduled
* work" tools.
*
* What each record carries:
*
* - `id` the 8-hex task id (also accepted by CronDelete).
* - `cron` verbatim 5-field expression as scheduled.
* - `humanSchedule` best-effort plain-English rendering via
* `cronToHuman`; falls back to the raw `cron`
* string if the expression can't be parsed.
* - `nextFireAt` post-jitter ISO timestamp, or the literal
* string `null` when there is no fire in the
* 5-year window (or the expression is malformed).
* This is the same jittered value `CronCreate`
* reports, so the LLM can reason about herd-
* avoidance offsets without surprise.
* - `recurring` `true` unless the task was explicitly created
* with `recurring: false`.
* - `ageDays` `(wallNow - createdAt) / day`, formatted to two
* decimal places. Useful context for the `stale`
* flag and for the LLM's "should I still be
* running?" judgement.
* - `stale` mirrors `CronManager.isStale(task)`; see that
* method for the precise rules
* (`recurring && age >= 7 days`, gated by
* `KIMI_CRON_NO_STALE`).
*
* The tool never throws on malformed cron strings. A defensive
* try/catch around the parse path lets the record render with the raw
* `cron`, a `humanSchedule` fallback equal to `cron`, and
* `nextFireAt: null` that should never happen for tasks that went
* through `CronCreate` (which validates), but guards against future
* direct `store.add(...)` inserts.
*/
import { z } from 'zod';
import type { BuiltinTool } from '../../agent/tool';
import type { CronManager } from '../../agent/cron';
import type { ToolExecution } from '../../loop/types';
import { toInputJsonSchema } from '../support/input-schema';
import {
cronToHuman,
parseCronExpression,
} from './cron-expr';
import type { CronTask } from './types';
import CRON_LIST_DESCRIPTION from './cron-list.md';
// ── Input schema ─────────────────────────────────────────────────────
/**
* No arguments. Strict so the loop's AJV validator rejects accidental
* extras (e.g. an `active_only` borrowed from `TaskList`) instead of
* silently ignoring them.
*/
export const CronListInputSchema = z.object({}).strict();
export type CronListInput = z.infer<typeof CronListInputSchema>;
// ── Constants ────────────────────────────────────────────────────────
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// Cap each rendered prompt at 200 UTF-8 bytes so a 50-task list with
// kilobyte-scale prompts can't blow up the context window.
const PROMPT_PREVIEW_BYTES = 200;
function previewPrompt(prompt: string): string {
const buf = Buffer.from(prompt, 'utf8');
if (buf.byteLength <= PROMPT_PREVIEW_BYTES) return prompt;
// Slice to PROMPT_PREVIEW_BYTES. If that lands inside a multi-byte
// sequence, walk back to the nearest UTF-8 char boundary (continuation
// bytes start with 10xxxxxx).
let end = PROMPT_PREVIEW_BYTES;
while (end > 0 && (buf[end]! & 0b1100_0000) === 0b1000_0000) end--;
return `${buf.subarray(0, end).toString('utf8')}…(truncated)`;
}
// ── Implementation ───────────────────────────────────────────────────
export class CronListTool implements BuiltinTool<CronListInput> {
readonly name = 'CronList' as const;
readonly description = CRON_LIST_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(
CronListInputSchema,
);
constructor(private readonly manager: CronManager) {}
resolveExecution(_args: CronListInput): ToolExecution {
return {
description: 'Listing scheduled cron jobs',
approvalRule: this.name,
execute: async () => {
// Snapshot the store once and pin "now" from the manager's
// clock — keeping both reads inside the same execute() call
// guarantees the `ageDays` and `nextFireAt` columns are
// computed against the same instant even if the bench-injected
// clock advances between the two.
const tasks = this.manager.store.list();
const nowMs = this.manager.clocks.wallNow();
const records = tasks.map((t) => this.renderRecord(t, nowMs));
const header = `cron_jobs: ${String(tasks.length)}`;
if (records.length === 0) {
return {
output: `${header}\nNo cron jobs scheduled.`,
isError: false,
};
}
return {
output: `${header}\n${records.join('\n---\n')}`,
isError: false,
};
},
};
}
private renderRecord(task: CronTask, nowMs: number): string {
// `recurring: undefined` is the canonical "repeat by default"
// shape across the cron stack; only an explicit `false` opts out.
const recurring = task.recurring !== false;
// `ageDays` is purely informational — a non-finite age (e.g.
// wallNow returned NaN from a misconfigured bench clock) is
// reported as 0.00 so the column stays parseable rather than
// emitting the string "NaN".
const ageMs = nowMs - task.createdAt;
const ageDays = Number.isFinite(ageMs) ? ageMs / MS_PER_DAY : 0;
const stale = this.manager.isStale(task);
let humanSchedule = task.cron;
let nextFireAtIso = 'null';
try {
const parsed = parseCronExpression(task.cron);
humanSchedule = cronToHuman(parsed);
// Delegate to the scheduler so the rendered ISO matches what the
// scheduler will actually deliver — including a pending jittered
// slot in the current period.
const nextFireMs = this.manager.getNextFireForTask(task.id);
if (nextFireMs !== null) {
nextFireAtIso = new Date(nextFireMs).toISOString();
}
} catch {
// Malformed cron string — leave humanSchedule as the raw
// expression and nextFireAt as `null`. Should never happen for
// tasks that went through CronCreate (which validates), but
// defends against direct store inserts (tests).
}
return [
`id: ${task.id}`,
`cron: ${task.cron}`,
`humanSchedule: ${humanSchedule}`,
// JSON-stringify so embedded newlines become `\n` escapes and
// the record stays one `key: value` per line — otherwise a
// multi-line prompt would corrupt the per-record parser.
`prompt: ${JSON.stringify(previewPrompt(task.prompt))}`,
`nextFireAt: ${nextFireAtIso}`,
`recurring: ${String(recurring)}`,
`ageDays: ${ageDays.toFixed(2)}`,
`stale: ${String(stale)}`,
].join('\n');
}
}

View file

@ -0,0 +1,170 @@
/**
* Per-task deterministic jitter for cron fire times.
*
* Why this exists: if every user writes `0 9 * * *` ("every day at 9
* am") then every CLI fires at the same instant and the upstream API
* sees a thundering herd at :00. We soften that by shifting each
* task's ideal fire time by a small, **deterministic** per-task
* offset so a given task always lands at the same jittered point
* reschedules and restarts don't drift, and bench reproducibility
* stays intact when {@link KIMI_CRON_NO_JITTER} is set.
*
* Two flavours:
*
* - **Recurring**: shift *forward* by a fraction of the period
* (cap 10% of period, hard cap 15 min). Long-period jobs (`0 9 *
* * *`, period 1 day) hit the 15-minute cap; short-period jobs
* (`*` /5 * * * *`, period 5 min) are bounded by the 10% rule.
*
* - **One-shot**: shift *earlier* (negative), but only when the
* ideal lands on `:00` or `:30` that's the signal the model
* picked a round number with no specific intent. Cap 90 s
* earlier. Any other minute (`:07`, `:23`, ) passes through
* unchanged because the model presumably meant that exact time.
*
* The function is pure given its inputs no module-level cache; the
* hash is recomputed from `task.id` each call. That trades a handful
* of cheap arithmetic ops for a guarantee that there is no hidden
* state to invalidate when a task is rescheduled.
*/
import type { ParsedCronExpression } from './cron-expr';
import { computeNextCronRun } from './cron-expr';
/** Tunables for {@link jitteredNextCronRunMs} / {@link oneShotJitteredNextCronRunMs}. */
export interface JitterConfig {
/** Recurring offset cap as a fraction of the cron period (0..1). */
readonly recurringMaxFractionOfPeriod: number;
/** Absolute cap on the recurring offset, in ms. */
readonly recurringMaxMs: number;
/** Absolute cap on the one-shot pull-forward, in ms. */
readonly oneShotMaxMs: number;
}
export const DEFAULT_CRON_JITTER_CONFIG: JitterConfig = {
recurringMaxFractionOfPeriod: 0.1,
recurringMaxMs: 15 * 60_000,
oneShotMaxMs: 90_000,
};
const MS_PER_DAY = 24 * 60 * 60_000;
const MS_PER_MINUTE = 60_000;
/**
* Map a task id to a deterministic fraction in `[0, 1)`. Cron task
* ids are 8 hex chars (`/^[0-9a-f]{8}$/`), so `parseInt(id, 16)` /
* `2^32` lands neatly in range. For non-hex inputs we fall back to a
* djb2-style reduction so callers passing test fixtures with
* arbitrary string ids still get a stable spread.
*/
function fractionFromId(id: string): number {
if (/^[0-9a-f]{8}$/i.test(id)) {
const n = Number.parseInt(id, 16);
if (Number.isFinite(n)) {
// 2^32 keeps the result strictly < 1.
return n / 0x1_0000_0000;
}
}
// djb2 reduction — overflow-safe in JS (operates on int32) and
// good enough spread for non-hex test ids.
let hash = 5381;
for (let i = 0; i < id.length; i++) {
hash = ((hash << 5) + hash + id.charCodeAt(i)) | 0;
}
// Map signed int32 to [0, 1).
const unsigned = hash >>> 0;
return unsigned / 0x1_0000_0000;
}
function jitterDisabledByEnv(): boolean {
return process.env['KIMI_CRON_NO_JITTER'] === '1';
}
/**
* Apply recurring-job jitter to an already-computed ideal fire time.
*
* The shift is **forward only** ( 0), bounded by both the relative
* fraction-of-period cap and the absolute ms cap. We discover the
* period by asking {@link computeNextCronRun} for the run *after*
* `idealMs`; if that returns `null` (legal-but-never-fires
* expression should have been rejected upstream) we fall back to a
* 24-hour assumption so we still produce some sensible offset rather
* than spiking on the original `idealMs`.
*/
export function jitteredNextCronRunMs(
task: { id: string; cron: string; recurring?: boolean },
parsed: ParsedCronExpression,
idealMs: number,
config: JitterConfig = DEFAULT_CRON_JITTER_CONFIG,
): number {
if (jitterDisabledByEnv()) {
return idealMs;
}
const nextNext = computeNextCronRun(parsed, idealMs);
const period =
nextNext !== null && nextNext > idealMs ? nextNext - idealMs : MS_PER_DAY;
const periodCap = period * config.recurringMaxFractionOfPeriod;
const cap = Math.min(periodCap, config.recurringMaxMs);
if (!(cap > 0)) {
return idealMs;
}
const offset = cap * fractionFromId(task.id);
return idealMs + offset;
}
/**
* Apply one-shot pull-forward jitter to an ideal fire time.
*
* Only fires on `:00` and `:30` of the hour the minute marks the
* model is most likely to pick out of habit. Other minutes pass
* through verbatim so a user who said "remind me at 2:07" gets
* 2:07 exactly. The shift is in `[-oneShotMaxMs, 0)`; never exactly
* 0 unless the deterministic hash happens to land on 0 (which is
* fine it just means this task is the unlucky one that pays the
* full delay).
*
* When the deterministic offset would land before `task.createdAt`,
* the jitter budget is too small to safely pull forward: a previous
* version clamped to `createdAt` itself, but the scheduler condition
* `now >= nextFireAt` then fires on the very next tick for the
* canonical 08:59:30-created `0 9 * * *` case, that means firing
* ~29 s before the ideal 09:00 mark. We skip jitter instead and
* return `idealMs` unchanged; the task fires at the ideal time, no
* earlier. Callers without `createdAt` (legacy test fixtures) get
* the unclamped pulled-forward value, preserving the previous
* behaviour for them.
*/
export function oneShotJitteredNextCronRunMs(
task: { id: string; createdAt?: number | undefined },
idealMs: number,
config: JitterConfig = DEFAULT_CRON_JITTER_CONFIG,
): number {
if (jitterDisabledByEnv()) {
return idealMs;
}
// `idealMs % MS_PER_MINUTE === 0` is a UTC minute-boundary check.
// It coincides with a local minute boundary in every modern timezone
// because all offsets are minute-aligned — there are no sub-minute
// offsets in current use. Cron firings are always on the minute, so
// this gate is almost always true; it remains as a guard against
// synthetic idealMs values from tests that aren't on the minute.
if (idealMs % MS_PER_MINUTE !== 0) {
return idealMs;
}
const minuteOfHour = new Date(idealMs).getMinutes();
if (minuteOfHour !== 0 && minuteOfHour !== 30) {
return idealMs;
}
if (!(config.oneShotMaxMs > 0)) {
return idealMs;
}
const offset = -config.oneShotMaxMs * fractionFromId(task.id);
const shifted = idealMs + offset;
// Skip jitter when the budget is insufficient: the previous version
// clamped to `createdAt`, but `now >= nextFireAt` then fired on the
// very next tick — ~29 s before ideal for the 08:59:30 → 09:00 case.
// Returning `idealMs` keeps the fire on schedule, never earlier.
if (task.createdAt !== undefined && shifted < task.createdAt) {
return idealMs;
}
return shifted;
}

View file

@ -0,0 +1,64 @@
/**
* Cron task persistence.
*
* Thin wrapper over `createPerIdJsonStore` that pins the on-disk layout
* (`<sessionDir>/cron/<task_id>.json`), the cron-id shape (8 lowercase
* hex chars same shape `SessionCronStore` generates), and a shape
* guard for `CronTask`.
*
* No `PersistedCronTask` type: a `CronTask` is already pure plain data,
* so the on-disk record is the in-memory record verbatim. Optional
* `recurring` is honoured: an absent field round-trips as `undefined`,
* which the rest of the cron stack treats as "recurring" by convention.
*
* The store is crash-safe (atomic write under the hood) and silently
* ignores stray files, corrupt JSON, and records that fail the shape
* guard the cron stack would rather lose a malformed task than refuse
* to boot.
*/
import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-json-store';
import type { CronTask } from './types';
/**
* On-disk id shape. Mirrors the regex `SessionCronStore` uses when
* generating ids and doubles as the path-traversal guard inside the
* generic per-id store.
*/
export const CRON_ID_REGEX: RegExp = /^[0-9a-f]{8}$/;
/**
* Cheap shape guard. Run on every parsed JSON value before it is
* surfaced from `list()` / `read()`; failing values are silently
* dropped.
*/
export function isValidCronTask(obj: unknown): obj is CronTask {
if (typeof obj !== 'object' || obj === null) return false;
const o = obj as Record<string, unknown>;
if (typeof o['id'] !== 'string' || !CRON_ID_REGEX.test(o['id'])) return false;
if (typeof o['cron'] !== 'string') return false;
if (typeof o['prompt'] !== 'string') return false;
if (typeof o['createdAt'] !== 'number') return false;
if (o['recurring'] !== undefined && typeof o['recurring'] !== 'boolean') return false;
if (
o['lastFiredAt'] !== undefined &&
(typeof o['lastFiredAt'] !== 'number' || !Number.isFinite(o['lastFiredAt']))
) {
return false;
}
return true;
}
/**
* Construct a per-id JSON store for cron tasks under `sessionDir`. The
* store is stateless callers can create it on demand.
*/
export function createCronPersistStore(sessionDir: string): PerIdJsonStore<CronTask> {
return createPerIdJsonStore<CronTask>({
rootDir: sessionDir,
subdir: 'cron',
idRegex: CRON_ID_REGEX,
isValid: isValidCronTask,
entityName: 'cron job id',
});
}

View file

@ -0,0 +1,516 @@
/**
* CronScheduler the scheduling engine.
*
* This is the bottom of the cron stack: it knows about tasks, clocks,
* jitter, and "is the REPL idle?", but nothing about agents, tools,
* persistence, locks, or the file system. Persistence is layered on
* top by `CronManager` writing through to per-id JSON files; the
* scheduler stays oblivious so its tick-loop tests can run with a
* pure in-memory `source()`.
*
* Design notes worth keeping near the code:
*
* - **No direct wall-clock reads.** Every wall-clock read goes
* through `clocks.wallNow()`. The companion `no-date-now.test.ts`
* enforces this at the file level; bypassing the abstraction
* here would break bench / test clock injection.
*
* - **`source()` is called every tick.** It returns the *current*
* task list. Callers (the manager) typically wire it to
* `() => store.list()`, so creating / deleting tasks between ticks
* is picked up automatically. Keep `source()` cheap.
*
* - **`isIdle()` gates fires, not state updates.** When the REPL is
* mid-turn we skip firing but we do NOT advance `lastSeenAt`. The
* next idle tick will see the tasks as still due and fire them,
* with `coalescedCount` reflecting the gap (so the LLM knows it
* missed N ideal fires while the user was talking).
*
* - **`coalescedCount` semantics.** When a sleep / busy turn / system
* pause causes the scheduler to miss multiple ideal fires, we
* deliver exactly ONE `onFire` call and tell the caller how many
* ideal fires we collapsed into it. Floor at 1 every fire that
* happens counts as at least one occurrence.
*
* - **`inFlight` is cleared at end of tick.** `onFire` is synchronous
* (the manager's steer is fire-and-forget). The set exists only to
* defend against re-entrant ticks within the same call stack a
* theoretical concern, but cheap insurance.
*
* - **Bad tasks do not poison the loop.** Each task's processing is
* wrapped in try/catch; failures are swallowed (with an optional
* stderr trace gated on `KIMI_CRON_DEBUG=1`) so one busted cron
* expression cannot starve the other tasks.
*/
import type { ParsedCronExpression } from './cron-expr';
import { computeNextCronRun, parseCronExpression } from './cron-expr';
import type { ClockSources } from './clock';
import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from './jitter';
import type { CronTask } from './types';
export interface CronSchedulerOptions {
/** Required. Wall + monotonic clock source. */
readonly clocks: ClockSources;
/**
* Required. Returns the live task list (e.g. `() => store.list()`).
* Called every tick keep cheap.
*/
readonly source: () => readonly CronTask[];
/**
* Required. Called when a task fires. `coalescedCount >= 1`; > 1
* when the scheduler slept past multiple ideal fires.
*/
readonly onFire: (task: CronTask, ctx: { readonly coalescedCount: number }) => void;
/**
* Required. Returns true when the REPL is idle and the scheduler
* may deliver a fire NOW. False during an active turn so we don't
* dump a cron fire mid-stream. When false, the tick returns without
* firing but does not lose tasks the next idle tick fires them
* with coalescedCount reflecting the gap.
*/
readonly isIdle: () => boolean;
/**
* Optional. Returns true when the global killswitch is on; tick()
* short-circuits to no-op.
*/
readonly isKilled?: () => boolean;
/**
* Optional. Called when a one-shot task fires and must be removed
* from the store. Defaults to a no-op (manager is responsible).
*/
readonly removeOneShot?: (id: string) => void;
/**
* Optional. Called after a recurring task fires successfully, with
* the wall-clock timestamp of the last ideal occurrence whose
* jittered delivery has just been delivered. The manager wires this
* to `store.markFired(id, ts)` + a per-id JSON write so a
* `kimi resume` does not replay the fire.
*
* Fire-and-forget: the scheduler does not wait for persistence to
* settle. One-shot tasks do not invoke this callback (the
* `removeOneShot` path handles them).
*/
readonly onAdvanceCursor?: (taskId: string, lastFiredAt: number) => void;
/**
* Optional. Poll interval for the auto-tick setInterval, in ms.
* - undefined (default) 1000ms.
* - 0 or null no automatic polling. Caller drives tick()
* manually.
*
* Used by P1.8 to wire `KIMI_CRON_MANUAL_TICK=1` to disable the
* timer.
*/
readonly pollIntervalMs?: number | null;
}
export interface CronScheduler {
/** Begin the auto-tick loop. Idempotent — calling twice is a no-op. */
start(): void;
/**
* Stop the auto-tick loop and clear any in-flight bookkeeping.
* Idempotent.
*/
stop(): Promise<void>;
/**
* Run one check cycle synchronously. Safe to call before start() or
* after stop().
*/
tick(): void;
/**
* Earliest theoretical (post-jitter) next fire across all current
* tasks, or null if there are no tasks or none have a future fire.
* Used by /cron and by external monitoring.
*/
getNextFireTime(): number | null;
/**
* Post-jitter next-fire for a single task using the scheduler's
* internal `lastSeenAt` baseline. Returns null if the task isn't in
* the current `source()` snapshot or its expression yields no future
* fire. Used by CronList so its rendered `nextFireAt` matches what
* the scheduler will actually deliver, including the in-flight
* jittered slot of the current period.
*/
getNextFireForTask(taskId: string): number | null;
}
const DEFAULT_POLL_INTERVAL_MS = 1_000;
/**
* Cap on how many ideal fires we attempt to enumerate when computing
* coalescedCount. With a 1-minute cron, this still covers 10 000
* minutes (~7 days). Beyond that we'd rather report 10 000 than spin
* the LLM only needs the order of magnitude.
*/
const MAX_COALESCE_ITERATIONS = 10_000;
export function createCronScheduler(opts: CronSchedulerOptions): CronScheduler {
const {
clocks,
source,
onFire,
isIdle,
isKilled,
removeOneShot,
onAdvanceCursor,
pollIntervalMs,
} = opts;
// Cached parsed cron expressions. Keyed by the raw expression
// string. Per-session task counts are tiny, so we never evict.
const parsedCache = new Map<string, ParsedCronExpression>();
// Per-task wall-clock baseline for "where did we last look from".
// Now persisted across `kimi resume` via `task.lastFiredAt`: when
// the scheduler first sees a task whose `lastFiredAt` is set and
// not in the future, that timestamp seeds this map so resume does
// not coalesce-replay already-delivered recurring fires. A bogus
// `lastFiredAt > now` (clock skew / corrupt store) is ignored and
// the scheduler falls back to `createdAt`, matching pre-persistence
// behaviour for that task.
const lastSeenAt = new Map<string, number>();
// Tracks which task ids have already had `lastFiredAt` consulted
// during this scheduler's lifetime, so the seeding above happens
// exactly once per task per scheduler instance. Without this, a
// task whose cursor was advanced *during* the session would have
// its in-memory map entry silently overwritten back to the
// persisted (older) value on the next tick.
const seededFromDisk = new Set<string>();
// Defensive re-entry guard for the duration of a single tick.
const inFlight = new Set<string>();
let timerHandle: ReturnType<typeof setInterval> | null = null;
function getParsed(expr: string): ParsedCronExpression {
const cached = parsedCache.get(expr);
if (cached !== undefined) return cached;
const parsed = parseCronExpression(expr);
parsedCache.set(expr, parsed);
return parsed;
}
function debugLog(message: string): void {
if (process.env['KIMI_CRON_DEBUG'] === '1') {
process.stderr.write(`[cron/scheduler] ${message}\n`);
}
}
/**
* Compute the jittered next-fire for a task, starting from `baseMs`.
* Returns null when the cron expression has no future fire within
* the search budget (legal-but-never-fires expression).
*/
function computeJitteredNext(
task: CronTask,
parsed: ParsedCronExpression,
baseMs: number,
): number | null {
const ideal = computeNextCronRun(parsed, baseMs);
if (ideal === null) return null;
if (task.recurring === false) {
return oneShotJitteredNextCronRunMs(task, ideal);
}
return jitteredNextCronRunMs(task, parsed, ideal);
}
/**
* Count how many ideal fires fall in `(firstFireMs, nowMs]` whose
* **jittered delivery time** is also `nowMs`. Returns the count
* plus the timestamp of the last ideal occurrence that satisfied the
* jittered-due test used by the caller as the new `lastSeenAt`
* baseline so the next scheduler pass still sees any later
* occurrence whose jittered delivery slipped past `nowMs`.
*
* Counting against `nowMs` alone (without re-applying jitter) over-
* counted on jobs whose jitter offset pushed the next ideal fire
* past the scheduler wake-up window; the caller would then advance
* `lastSeenAt` past that occurrence and the jittered delivery would
* never happen. The fix is to gate the counting loop on the same
* jitter the delivery path uses.
*
* Always returns at least 1 every actual fire is one occurrence.
* Capped at MAX_COALESCE_ITERATIONS as a defence against runaway
* loops; an expression that produces more than 10 000 fires in the
* gap is degenerate and the LLM only needs the order of magnitude.
*/
function countCoalesced(
task: CronTask,
parsed: ParsedCronExpression,
firstFireMs: number,
nowMs: number,
): { count: number; lastDueMs: number } {
let count = 1;
let cursor = firstFireMs;
let lastDueMs = firstFireMs;
while (count < MAX_COALESCE_ITERATIONS) {
const next = computeNextCronRun(parsed, cursor);
if (next === null) break;
if (next > nowMs) break;
// The scheduler delivers at the jittered time, not the ideal
// one. Counting an ideal fire whose jitter pushes its delivery
// past `nowMs` would leak the occurrence — the caller advances
// `lastSeenAt` past it and the next tick can never re-pick it.
const jitteredNext =
task.recurring === false
? oneShotJitteredNextCronRunMs(task, next)
: jitteredNextCronRunMs(task, parsed, next);
if (jitteredNext > nowMs) break;
count++;
cursor = next;
lastDueMs = next;
}
return { count, lastDueMs };
}
function tick(): void {
if (isKilled?.() === true) return;
if (!isIdle()) return;
const tasks = source();
if (tasks.length === 0) return;
const now = clocks.wallNow();
// We clear inFlight at the end of the tick; entry-time defence
// against re-entry handled by the `inFlight.has(id)` skip below.
try {
for (const task of tasks) {
try {
if (inFlight.has(task.id)) continue;
const parsed = getParsed(task.cron);
// First time we see this task in this scheduler instance,
// seed `lastSeenAt` from the persisted `task.lastFiredAt`
// (when present and sane). This is the one-line fix for
// "resume replays yesterday's already-fired 09:00 cron":
// without seeding, the baseline below would fall back to
// `task.createdAt` and `countCoalesced` would treat every
// ideal fire since creation as still due. A `lastFiredAt`
// strictly greater than `now` is treated as corrupt (clock
// skew, mis-set bench env) and ignored — never trust a
// stored cursor enough to *skip* a legitimately-due fire.
if (
!seededFromDisk.has(task.id) &&
task.lastFiredAt !== undefined &&
Number.isFinite(task.lastFiredAt) &&
task.lastFiredAt <= now &&
!lastSeenAt.has(task.id)
) {
lastSeenAt.set(task.id, task.lastFiredAt);
}
seededFromDisk.add(task.id);
// Base from which to compute the next ideal fire. For a
// freshly-added task this is its createdAt; once we've fired
// (or seen it pass), bump to the wall clock at that moment
// so we don't double-count the same fire on the next tick.
const seen = lastSeenAt.get(task.id);
const baseFromMs =
seen !== undefined && seen > task.createdAt ? seen : task.createdAt;
const nextFireAt = computeJitteredNext(task, parsed, baseFromMs);
if (nextFireAt === null) continue;
if (now < nextFireAt) continue;
// Due — compute coalescedCount starting from the first
// ideal fire (not the jittered one — jitter only shifts the
// delivery point, not the underlying schedule). One-shot
// tasks are removed after a single delivery and must always
// report `coalescedCount: 1`; multi-occurrence semantics
// make no sense for "remind me at X" reminders that were
// simply slept-through.
const ideal = computeNextCronRun(parsed, baseFromMs);
let coalescedCount = 1;
let lastDueMs: number | null = null;
if (task.recurring !== false && ideal !== null) {
const result = countCoalesced(task, parsed, ideal, now);
coalescedCount = Math.max(1, result.count);
lastDueMs = result.lastDueMs;
}
inFlight.add(task.id);
let delivered = false;
try {
onFire(task, { coalescedCount });
delivered = true;
} catch (error) {
debugLog(
`onFire threw for task ${task.id}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (!delivered) {
// Leave lastSeenAt/store untouched — next tick will
// re-detect this task as due. A persistently-throwing
// onFire becomes loud retry rather than silent loss; the
// manager is the layer responsible for ironing out
// persistence-level failures so they don't reach here.
continue;
}
if (task.recurring === false) {
// One-shot: ask the caller to remove and drop our memory of it.
try {
removeOneShot?.(task.id);
} catch (error) {
debugLog(
`removeOneShot threw for task ${task.id}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
lastSeenAt.delete(task.id);
seededFromDisk.delete(task.id);
} else {
// Recurring: advance the baseline to the last ideal fire
// whose jittered delivery has actually completed (or to
// `now` if no ideal fires were enumerated). Using the
// *delivered* timestamp — rather than `now` — keeps any
// later ideal fire whose jitter pushes its delivery past
// `now` reachable on the next tick. If `lastDueMs` is
// null (degenerate cron / no enumerated ideal) we fall
// back to `now`, matching the original behaviour.
const advancedTo = lastDueMs ?? now;
lastSeenAt.set(task.id, advancedTo);
// Mirror the cursor to the manager so it can persist
// through to disk. Fire-and-forget — the callback is
// expected to schedule the write asynchronously; throws
// are swallowed here so a flaky writer never poisons the
// tick loop. The persistence path is the manager's
// responsibility (consistent with addTask / removeTasks).
try {
onAdvanceCursor?.(task.id, advancedTo);
} catch (error) {
debugLog(
`onAdvanceCursor threw for task ${task.id}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
} catch (error) {
// A single bad task must not stop the rest of the loop.
debugLog(
`tick failed for task ${task.id}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
} finally {
// onFire is synchronous so re-entrant ticks within the same
// call stack are the only thing inFlight defends against. Clear
// at end-of-tick to keep the invariant simple.
inFlight.clear();
}
}
function start(): void {
if (timerHandle !== null) return;
const interval =
pollIntervalMs === undefined ? DEFAULT_POLL_INTERVAL_MS : pollIntervalMs;
// 0 and null both mean "no automatic polling".
if (interval === null || interval === 0) return;
const handle = setInterval(tick, interval);
// Don't keep the event loop alive on the scheduler alone — the
// user's REPL / agent owns lifetime.
if (typeof handle === 'object' && handle !== null && 'unref' in handle) {
(handle as { unref: () => void }).unref();
}
timerHandle = handle;
}
async function stop(): Promise<void> {
if (timerHandle !== null) {
clearInterval(timerHandle);
timerHandle = null;
}
inFlight.clear();
lastSeenAt.clear();
seededFromDisk.clear();
parsedCache.clear();
// Async signature for forward compatibility with Phase 2 (file
// I/O cleanup, lock release). Session-only resolves immediately.
}
function nextFireFor(task: CronTask): number | null {
try {
const parsed = getParsed(task.cron);
const seen = lastSeenAt.get(task.id);
// Mirror tick()'s seeding: when the scheduler has not yet ticked
// this session, consult `task.lastFiredAt` so CronList renders
// the resume-corrected nextFireAt instead of a value re-derived
// from `createdAt`. Bogus values (future timestamp) are ignored,
// identical to tick()'s sanity gate.
const persistedCursor =
task.lastFiredAt !== undefined &&
Number.isFinite(task.lastFiredAt) &&
task.lastFiredAt <= clocks.wallNow()
? task.lastFiredAt
: undefined;
const cursor =
seen !== undefined
? seen
: persistedCursor !== undefined
? persistedCursor
: undefined;
const baseFromMs =
cursor !== undefined && cursor > task.createdAt ? cursor : task.createdAt;
return computeJitteredNext(task, parsed, baseFromMs);
} catch (error) {
debugLog(
`getNextFireFor skipping task ${task.id}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
}
}
function getNextFireTime(): number | null {
const tasks = source();
if (tasks.length === 0) return null;
let min: number | null = null;
for (const task of tasks) {
const next = nextFireFor(task);
if (next === null) continue;
if (min === null || next < min) min = next;
}
return min;
}
function getNextFireForTask(taskId: string): number | null {
const tasks = source();
const task = tasks.find((t) => t.id === taskId);
if (task === undefined) return null;
return nextFireFor(task);
}
return {
start,
stop,
tick,
getNextFireTime,
getNextFireForTask,
};
}

View file

@ -0,0 +1,151 @@
/**
* SessionCronStore in-memory cron task store for a single CLI session.
*
* The store itself is purely in-memory; cross-restart persistence is
* layered on top by `CronManager.addTask` / `removeTasks`, which
* mirror every mutation to `<sessionDir>/cron/<id>.json`. On resume
* the manager calls {@link adopt} to put each persisted task back into
* the store with its original id and `createdAt` preserved.
*
* The store is intentionally clock-agnostic: it does NOT call
* `Date.now()` itself. Callers pass `nowMs` (which the cron manager
* sources from `ClockSources.wallNow()`), so injected clocks in tests
* and benches stay authoritative. The `no-date-now` guard does not
* currently list this file, but the discipline matches.
*
* Insertion order is preserved by relying on `Map` iteration order
* callers (CronList, scheduler `source: () => CronTask[]`) want a
* stable ordering that matches the user's mental model of "the one I
* added first comes first".
*/
import { randomBytes } from 'node:crypto';
import type { CronTask } from './types';
/**
* Input to {@link SessionCronStore.add}: everything the caller supplies,
* minus `id` and `createdAt` which the store generates.
*/
export type SessionCronTaskInit = Omit<CronTask, 'id' | 'createdAt'>;
/** Matches the canonical cron task id shape (8 lower-hex chars). */
const ID_REGEX = /^[0-9a-f]{8}$/;
/**
* Upper bound on id-collision retries. With 32 bits of entropy and at
* most a few dozen live tasks per session, the probability of even one
* collision is on the order of 1e-8. Eight attempts is a hard ceiling
* to surface a real bug (e.g. PRNG degeneration) rather than silently
* spinning.
*/
const MAX_ID_ATTEMPTS = 8;
export class SessionCronStore {
/**
* Backing map. `Map` preserves insertion order in JS, which we rely on
* for {@link list}.
*/
private readonly tasks = new Map<string, CronTask>();
/**
* Generate a fresh 8-hex id and add the task. `createdAt` is set to
* the supplied `nowMs` the store never reads its own clock.
*
* Throws if the PRNG fails to produce an unused id within
* {@link MAX_ID_ATTEMPTS} attempts. That should be unreachable in
* practice; surfacing it as a throw beats silently retrying forever.
*/
add(init: SessionCronTaskInit, nowMs: number): CronTask {
const id = this.generateUniqueId();
const task: CronTask = {
...init,
id,
createdAt: nowMs,
};
this.tasks.set(id, task);
return task;
}
/**
* Insert a previously-persisted task verbatim id and createdAt
* stay as they are on disk. Used by `CronManager.loadFromDisk()` to
* rehydrate the store on resume. Unlike {@link add}, this does NOT
* generate a new id; the caller is responsible for ensuring the id
* matches the expected shape (the persistence layer's regex /
* shape-guard handle this upstream).
*
* Overwrites any existing in-memory task with the same id reload
* is a "replace" operation, not a "merge". Callers that want merge
* semantics should clear the store first.
*/
adopt(task: CronTask): void {
this.tasks.set(task.id, task);
}
/**
* Stamp `lastFiredAt` on the in-memory task. Used by the scheduler
* cursor-advance callback so the value flows back to disk via the
* manager's persistence path. Returns the updated record (so the
* manager can hand it straight to the per-id JSON writer), or
* `undefined` when no task with that id is present the latter
* happens harmlessly if a task was concurrently removed between the
* scheduler's fire and the cursor callback.
*/
markFired(id: string, lastFiredAt: number): CronTask | undefined {
const existing = this.tasks.get(id);
if (existing === undefined) return undefined;
const updated: CronTask = { ...existing, lastFiredAt };
this.tasks.set(id, updated);
return updated;
}
/** Returns the task or `undefined`. */
get(id: string): CronTask | undefined {
return this.tasks.get(id);
}
/**
* Snapshot in insertion order. Returns a fresh array on every call
* callers may mutate the returned array without affecting the store,
* and successive calls return distinct array references.
*/
list(): readonly CronTask[] {
return Array.from(this.tasks.values());
}
/**
* Remove the given ids. Returns the subset that were actually present
* (so the caller can detect already-missing ids and report them).
* Order of the returned ids follows the input order, not insertion
* order.
*/
remove(ids: readonly string[]): readonly string[] {
const removed: string[] = [];
for (const id of ids) {
if (this.tasks.delete(id)) {
removed.push(id);
}
}
return removed;
}
/** Empty the store. Convenience for tests / shutdown. */
clear(): void {
this.tasks.clear();
}
private generateUniqueId(): string {
for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt++) {
const candidate = randomBytes(4).toString('hex');
// randomBytes(4).toString('hex') is always 8 lowercase hex chars,
// so the regex check is belt-and-braces against future refactors
// that swap the id source.
if (!ID_REGEX.test(candidate)) continue;
if (!this.tasks.has(candidate)) return candidate;
}
throw new Error(
`SessionCronStore: failed to generate a unique 8-hex id after ${MAX_ID_ATTEMPTS} attempts`,
);
}
}

View file

@ -0,0 +1,11 @@
/** Telemetry event names emitted by the cron subsystem. Centralised so a typo can't drift a metric. */
export const CRON_SCHEDULED = 'cron_scheduled' as const;
export const CRON_FIRED = 'cron_fired' as const;
export const CRON_MISSED = 'cron_missed' as const;
export const CRON_DELETED = 'cron_deleted' as const;
export type CronTelemetryEvent =
| typeof CRON_SCHEDULED
| typeof CRON_FIRED
| typeof CRON_MISSED
| typeof CRON_DELETED;

View file

@ -0,0 +1,28 @@
/**
* Persistent representation of a cron task.
*
* - `id` 8-hex; jitter is keyed off this hash, so stable id == stable
* jitter across schedule rewrites.
* - `cron` 5-field expression, evaluated in local time.
* - `createdAt` wall-clock epoch ms at original scheduling. NOT updated
* when the scheduler fires; recurring uses it as the baseline floor
* when no `lastFiredAt` has been recorded. Also the input to the
* 7-day stale judgment.
* - `recurring` undefined / true means "fire repeatedly until deleted
* or auto-expired"; false means "fire once then auto-delete".
* - `lastFiredAt` wall-clock epoch ms of the last ideal occurrence
* whose jittered delivery has actually completed. Persisted so a
* `kimi resume` does not replay already-delivered recurring fires:
* without it, the scheduler would fall back to `createdAt` and
* coalesce yesterday's already-fired 09:00 into today's tick. A
* value greater than the current wall clock is treated as corrupt
* and the scheduler falls back to `createdAt` for that task.
*/
export interface CronTask {
readonly id: string;
readonly cron: string;
readonly prompt: string;
readonly createdAt: number;
readonly recurring?: boolean;
readonly lastFiredAt?: number;
}

View file

@ -0,0 +1,147 @@
/**
* Per-id JSON record store write each value as `<rootDir>/<subdir>/<id>.json`.
*
* Hoisted out of `tools/background/persist.ts` so cron / background / any
* future "session-scoped, per-id, small-JSON" persistence can share the
* same atomic-write + path-traversal-guarded readdir loop. The store has
* no opinion on `T` callers supply an id regex (also the basename
* validator) and a cheap shape guard for ignoring corrupt files on
* `list()`.
*
* Crash safety: writes go through `atomicWrite` (write-tmp, fsync,
* rename) so a kill mid-write never leaves a torn file. `list()`
* silently drops basenames that don't match `idRegex`, files that fail
* to read, JSON parse errors, and values that fail `isValid` the
* caller wants "everything that's safely loadable", not a partial throw.
*
* Not concurrent-process-safe by itself: two CLI processes writing to
* the same id will race on the rename. We accept this because the
* session model already assumes one live process per session at a time
* (resume kills the previous).
*/
import { mkdir, readdir, readFile, unlink } from 'node:fs/promises';
import { join } from 'pathe';
import { atomicWrite } from './fs';
export interface PerIdJsonStore<T> {
/**
* Atomically write `value` to `<rootDir>/<subdir>/<id>.json`. Creates
* the subdir on demand. Throws if `id` doesn't match `idRegex`
* (path-traversal guard fires before any FS call) or if the write
* itself fails.
*/
write(id: string, value: T): Promise<void>;
/**
* Read a single record. Returns `undefined` for missing files,
* unreadable files, parse errors, or values that fail `isValid`.
* Throws only for an invalid id (path-traversal guard).
*/
read(id: string): Promise<T | undefined>;
/**
* Enumerate every record in the subdir whose basename matches
* `idRegex` AND whose parsed content satisfies `isValid`. Silently
* drops everything else (corrupt JSON, stray files, partial writes).
*/
list(): Promise<readonly T[]>;
/**
* Idempotently delete `<rootDir>/<subdir>/<id>.json`. ENOENT is not an
* error. Throws for an invalid id or any other FS failure.
*/
remove(id: string): Promise<void>;
}
export interface PerIdJsonStoreOptions<T> {
/** Session-scoped root directory (e.g. the agent's homedir). */
readonly rootDir: string;
/** Per-feature leaf directory under `rootDir` (e.g. `'cron'`, `'tasks'`). */
readonly subdir: string;
/**
* Strict id shape. Doubles as the path-traversal guard anything
* with `..` / `/` / a stray dot is rejected by the regex before the
* filename hits the filesystem.
*/
readonly idRegex: RegExp;
/**
* Cheap structural validator. Run on every parsed JSON value; failing
* values are silently dropped from `list()` (and `read()` returns
* `undefined`). Should be inexpensive it runs once per file per
* `list()`.
*/
readonly isValid: (obj: unknown) => obj is T;
/**
* Human-readable name used in path-traversal rejection errors
* `Invalid <entityName>: "<id>"`. Lets each caller preserve its own
* pre-refactor wording (`'task id'`, `'cron job id'`, ...) so error
* messages stay stable across the abstraction. Defaults to `'id'`.
*/
readonly entityName?: string;
}
export function createPerIdJsonStore<T>(
opts: PerIdJsonStoreOptions<T>,
): PerIdJsonStore<T> {
const { rootDir, subdir, idRegex, isValid, entityName = 'id' } = opts;
const dir = join(rootDir, subdir);
function fileFor(id: string): string {
if (!idRegex.test(id)) {
throw new Error(`Invalid ${entityName}: "${id}"`);
}
return join(dir, `${id}.json`);
}
async function write(id: string, value: T): Promise<void> {
const target = fileFor(id);
await mkdir(dir, { recursive: true, mode: 0o700 });
await atomicWrite(target, JSON.stringify(value, null, 2));
}
async function read(id: string): Promise<T | undefined> {
const path = fileFor(id);
let raw: string;
try {
raw = await readFile(path, 'utf-8');
} catch {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return undefined;
}
return isValid(parsed) ? parsed : undefined;
}
async function list(): Promise<readonly T[]> {
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return [];
}
const out: T[] = [];
for (const entry of entries) {
if (!entry.endsWith('.json')) continue;
const id = entry.slice(0, -'.json'.length);
if (!idRegex.test(id)) continue;
const value = await read(id);
if (value === undefined) continue;
out.push(value);
}
return out;
}
async function remove(id: string): Promise<void> {
const path = fileFor(id);
try {
await unlink(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
return { write, read, list, remove };
}

View file

@ -534,6 +534,24 @@ describe('Agent context notification projection', () => {
expect(textOf(messages[0]!)).toContain('Task done');
expect(textOf(messages[1]!)).toBe('Actual user prompt');
});
it('does not merge a cron-fire envelope into an adjacent user message', () => {
// Cron fires arrive as user-role messages whose text starts with
// `<cron-fire `. `mergeAdjacentUserMessages` must treat them like
// <notification>/<system-reminder>/<hook_result> and keep them in
// separate messages — otherwise the envelope XML smears into a
// real user turn and confuses the LLM about where the system
// annotation ends.
const cronEnvelope =
'<cron-fire jobId="deadbeef" cron="*/5 * * * *" recurring="true" coalescedCount="1" stale="false">\n<prompt>\ncheck the deploy\n</prompt>\n</cron-fire>';
const messages = project([
userMessage(cronEnvelope),
userMessage('Actual follow-up from the user'),
]);
expect(messages).toHaveLength(2);
expect(textOf(messages[0]!)).toBe(cronEnvelope);
expect(textOf(messages[1]!)).toBe('Actual follow-up from the user');
});
});
function userMessage(text: string): Message {

View file

@ -0,0 +1,81 @@
/**
* Agent + cron wiring smoke: verifies `new Agent(...)` constructs and
* starts a CronManager, registers the three cron tools, and that
* `KIMI_DISABLE_CRON=1` short-circuits `CronCreate`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CronCreateTool,
type CronCreateInput,
} from '../../../src/tools/cron/cron-create';
import { testAgent, type AgentTestContext } from '../harness/agent';
describe('Agent + Cron integration (P1.7)', () => {
let ctx: AgentTestContext;
beforeEach(() => {
ctx = testAgent();
// `configure({ tools: [...] })` triggers `agent.config.update(...)`,
// which is the only path that calls `initializeBuiltinTools()`.
// Listing all three cron tools turns them on in `enabledTools` so
// `agent.tools.data()[i].active` is true — useful for callers that
// want to confirm the model would actually see the tool, not just
// that we registered it.
ctx.configure({ tools: ['CronCreate', 'CronList', 'CronDelete'] });
});
afterEach(async () => {
await ctx.agent.cron.stop();
vi.unstubAllEnvs();
});
it('exposes agent.cron with its session store on construction', () => {
expect(ctx.agent.cron).toBeDefined();
expect(ctx.agent.cron.store).toBeDefined();
expect(ctx.agent.cron.store.list()).toEqual([]);
});
it('registers CronCreate / CronList / CronDelete in the tool manager', () => {
const toolNames = ctx.agent.tools.data().map((info) => info.name);
expect(toolNames).toContain('CronCreate');
expect(toolNames).toContain('CronList');
expect(toolNames).toContain('CronDelete');
// All three came in through the builtin barrel.
for (const name of ['CronCreate', 'CronList', 'CronDelete'] as const) {
const info = ctx.agent.tools.data().find((i) => i.name === name);
expect(info?.source).toBe('builtin');
expect(info?.active).toBe(true);
}
});
it('KIMI_DISABLE_CRON=1 short-circuits CronCreate with a disabled error', () => {
vi.stubEnv('KIMI_DISABLE_CRON', '1');
// We construct a fresh CronCreateTool against the agent's cron
// manager rather than driving a full tool-dispatch loop — the
// killswitch lives in `resolveExecution`, so a direct call is the
// precise unit being asserted, and it stays robust if the loop /
// dispatch surface changes around it (P1.8 onwards).
const tool = new CronCreateTool(ctx.agent.cron);
const args: CronCreateInput = {
cron: '*/5 * * * *',
prompt: 'x',
recurring: true,
};
const result = tool.resolveExecution(args);
// resolveExecution returns a `ToolExecution` — when it errors
// up-front the shape is `{ isError: true, output: string }` with no
// `execute` callback (see CronCreate's killswitch branch).
expect(result).toMatchObject({ isError: true });
expect('output' in result ? result.output : '').toMatch(/disabled/i);
expect('execute' in result ? typeof result.execute : 'no-execute').toBe(
'no-execute',
);
// And no task slipped into the store.
expect(ctx.agent.cron.store.list()).toEqual([]);
});
});

View file

@ -0,0 +1,207 @@
/**
* Session-level cron end-to-end smoke: exercises the full
* `CronCreateTool → SessionCronStore → CronScheduler → CronManager →
* agent.turn.steer` pipeline through the real `AgentTestContext`,
* with a swapped CronManager wired to an injected clock so the
* `coalescedCount = 3` calibration after a 15-minute advance is
* deterministic regardless of host TZ.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CronManager } from '../../../src/agent/cron';
import { CronCreateTool } from '../../../src/tools/cron/cron-create';
import { CronDeleteTool } from '../../../src/tools/cron/cron-delete';
import { CronListTool } from '../../../src/tools/cron/cron-list';
import type { ExecutableToolOutput } from '../../../src/loop/types';
import { testAgent, type AgentTestContext } from '../harness/agent';
import { createClocks } from './harness/stub';
// Local-time anchor (cron-expr matches on local fields, so a UTC anchor
// would shift the result by the host's offset). At noon + 15 min the
// `*\/5 * * * *` ideal fires are 12:05/12:10/12:15 → coalescedCount=3.
const LOCAL_ANCHOR_MS = new Date(2024, 5, 1, 12, 0, 0, 0).getTime();
/**
* Coerce an `ExecutableToolOutput` (string | ContentPart[]) into a
* single string. The cron tools always return a string body, but the
* union forces us to handle the structured-content path JSON keeps
* future-tool assertions safe and the `no-base-to-string` rule happy.
*/
function outputText(out: ExecutableToolOutput): string {
return typeof out === 'string' ? out : JSON.stringify(out);
}
describe('Cron — session E2E (P1.9)', () => {
let ctx: AgentTestContext;
beforeEach(() => {
// Pin jitter off so the recurring fire lands at the ideal 12:05:00
// mark (not 12:05:00 + up-to-30s) and the 15-minute advance is more
// than enough to clear it. Note: `coalescedCount` is computed from
// the unjittered schedule, so jitter has no effect on the count
// itself — this flag is belt-and-braces against any future refactor
// that widens the jitter window past 10 minutes.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
ctx = testAgent();
});
afterEach(async () => {
// The harness's `onTestFinished` cleanup already calls
// `agent.cron.stop()`, but doing it here as well keeps the test
// self-contained against future harness changes and ensures the
// SIGUSR1 handler (if any) is unbound before the next test.
await ctx.agent.cron.stop();
vi.unstubAllEnvs();
});
it('recurring */5 task advances 15min → exactly one steer with coalescedCount=3', async () => {
// Swap the auto-built CronManager (which uses SYSTEM_CLOCKS) for one
// bound to our mock clock. The `as any` cast is the documented
// test-only escape hatch — Agent.cron is `readonly` precisely
// because production code must never overwrite it; tests are the
// only legitimate exception.
await ctx.agent.cron.stop();
const harness = createClocks(LOCAL_ANCHOR_MS);
(ctx.agent as unknown as { cron: CronManager }).cron = new CronManager(
ctx.agent,
{
clocks: harness.clocks,
// `null` → no setInterval; we drive `tick()` ourselves to keep
// the test free of timing races.
pollIntervalMs: null,
},
);
ctx.agent.cron.start();
// Spy on agent.turn.steer. We wrap rather than replace so the real
// steer logic still runs (and the `turn.steer` record is written /
// a turn is launched against the scripted-generate harness). A pure
// replacement would silence interesting failure modes — e.g. a
// regression that emits the wrong record type but still calls
// handleFire.
const steerCalls: Array<{
readonly content: readonly unknown[];
readonly origin: unknown;
}> = [];
const originalSteer = ctx.agent.turn.steer.bind(ctx.agent.turn);
(ctx.agent.turn as unknown as { steer: typeof ctx.agent.turn.steer }).steer =
(content, origin) => {
steerCalls.push({ content, origin });
return originalSteer(content, origin);
};
// Schedule via the full tool surface — the scheduling path goes
// through validation (parse, 5-year window, cap, byte length) just
// like the LLM-driven path. A back-door `store.add(...)` would
// bypass `emitScheduled` telemetry and skip the byte-length /
// expression checks; that would not be the production code path
// this commit is meant to smoke.
const createTool = new CronCreateTool(ctx.agent.cron);
const execution = createTool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'cron-fired prompt',
recurring: true,
});
if (execution.isError === true) {
throw new Error(
`CronCreate unexpectedly errored: ${outputText(execution.output)}`,
);
}
const createResult = await execution.execute({
turnId: 'p19-turn',
toolCallId: 'p19-call',
signal: new AbortController().signal,
});
expect(createResult.isError ?? false).toBe(false);
expect(ctx.agent.cron.store.list().length).toBe(1);
// Advance 15 minutes — exactly three ideal */5 fires across the gap
// (12:05, 12:10, 12:15). See the file header for the calibration
// derivation.
harness.advance(15 * 60_000);
ctx.agent.cron.tick();
// ── Steer was called exactly once ─────────────────────────────────
expect(steerCalls.length).toBe(1);
const fire = steerCalls[0]!;
// ── Content carries the user prompt wrapped in the cron-fire envelope ─
expect(fire.content).toHaveLength(1);
const fireText = (fire.content[0] as { type: 'text'; text: string }).text;
expect(fireText).toContain('<cron-fire ');
expect(fireText).toContain('cron-fired prompt');
// ── Origin carries the full CronJobOrigin contract ───────────────
expect(fire.origin).toMatchObject({
kind: 'cron_job',
cron: '*/5 * * * *',
recurring: true,
coalescedCount: 3,
stale: false,
});
// jobId comes back as the same 8-hex shape the store guarantees.
const origin = fire.origin as { readonly jobId: string };
expect(typeof origin.jobId).toBe('string');
expect(origin.jobId).toMatch(/^[0-9a-f]{8}$/);
});
it('CronCreate → CronList → CronDelete cycle returns sensible output', async () => {
// Optional second case from the P1.9 plan: prove the three-tool
// surface composes correctly end-to-end on the real manager. No
// clock manipulation needed — list/delete are time-invariant.
const createTool = new CronCreateTool(ctx.agent.cron);
const listTool = new CronListTool(ctx.agent.cron);
const deleteTool = new CronDeleteTool(ctx.agent.cron);
const ctxArgs = {
turnId: 'p19-tools',
toolCallId: 'p19-tools-call',
signal: new AbortController().signal,
};
// 1. Create.
const createExec = createTool.resolveExecution({
cron: '*/10 * * * *',
prompt: 'noop',
recurring: true,
});
if (createExec.isError === true) {
throw new Error(`CronCreate failed: ${outputText(createExec.output)}`);
}
const createOut = await createExec.execute(ctxArgs);
expect(createOut.isError ?? false).toBe(false);
const idMatch = /id:\s*([0-9a-f]{8})/.exec(outputText(createOut.output));
expect(idMatch).not.toBeNull();
const id = idMatch![1]!;
// 2. List — should show one record carrying the id we just got.
const listExec = listTool.resolveExecution({});
if (listExec.isError === true) {
throw new Error(`CronList failed: ${outputText(listExec.output)}`);
}
const listOut = await listExec.execute(ctxArgs);
expect(listOut.isError ?? false).toBe(false);
const listText = outputText(listOut.output);
expect(listText).toContain('cron_jobs: 1');
expect(listText).toContain(`id: ${id}`);
expect(listText).toContain('cron: */10 * * * *');
// 3. Delete the task we just created.
const deleteExec = deleteTool.resolveExecution({ id });
if (deleteExec.isError === true) {
throw new Error(`CronDelete failed: ${outputText(deleteExec.output)}`);
}
const deleteOut = await deleteExec.execute(ctxArgs);
expect(deleteOut.isError ?? false).toBe(false);
expect(outputText(deleteOut.output)).toContain(`Deleted cron job ${id}`);
// 4. List again — empty.
const listExec2 = listTool.resolveExecution({});
if (listExec2.isError === true) {
throw new Error(`CronList failed: ${outputText(listExec2.output)}`);
}
const listOut2 = await listExec2.execute(ctxArgs);
expect(listOut2.isError ?? false).toBe(false);
expect(outputText(listOut2.output)).toContain('cron_jobs: 0');
expect(outputText(listOut2.output)).toContain('No cron jobs scheduled.');
});
});

View file

@ -0,0 +1,123 @@
/**
* Shared cron test stub: lightweight Agent stub + injectable ClockSources.
*
* Kept out of the broader `test/agent/harness/agent.ts` because cron unit
* tests only need three Agent surfaces (turn.hasActiveTurn, turn.steer,
* telemetry.track) and inflating them through `testAgent()` would drag
* kosong / records / context into every unit-level assertion.
*/
import type { ContentPart } from '@moonshot-ai/kosong';
import type { Agent } from '../../../../src/agent';
import type { PromptOrigin } from '../../../../src/agent/context/types';
import type { ClockSources } from '../../../../src/tools/cron/clock';
/**
* Stable wall-clock anchor (Nov 14 2023, 22:13:20 UTC). Deliberately
* off any round minute so the next `*\/5 * * * *` ideal fire is not
* exactly five minutes ahead, exercising the strict-greater-than
* branch of `computeNextCronRun`.
*/
export const WALL_ANCHOR = 1_700_000_000_000;
export interface SteerCall {
readonly content: readonly ContentPart[];
readonly origin: PromptOrigin;
}
export interface TelemetryCall {
readonly event: string;
readonly props: unknown;
}
export interface AgentStubOptions {
/** Initial value of `agent.turn.hasActiveTurn`. Default false (idle). */
readonly hasActiveTurn?: boolean;
/**
* Value returned by `agent.turn.steer`. Default 42. Explicit `null`
* encodes "input was buffered into the steer queue" (turn in flight).
*/
readonly steerReturns?: number | null;
}
export interface AgentStub {
readonly agent: Agent;
readonly steerCalls: SteerCall[];
readonly telemetryCalls: TelemetryCall[];
setHasActiveTurn(v: boolean): void;
}
export function createAgentStub(opts: AgentStubOptions = {}): AgentStub {
const steerCalls: SteerCall[] = [];
const telemetryCalls: TelemetryCall[] = [];
let hasActiveTurn = opts.hasActiveTurn ?? false;
// `?? 42` would collapse explicit `null` (buffered) into 42, so probe
// the property's presence instead of relying on nullish coalescing.
const steerReturns: number | null =
'steerReturns' in opts ? (opts.steerReturns as number | null) : 42;
const turn = {
get hasActiveTurn(): boolean {
return hasActiveTurn;
},
steer: (content: readonly ContentPart[], origin: PromptOrigin) => {
steerCalls.push({ content, origin });
return steerReturns;
},
};
const telemetry = {
track: (event: string, props: unknown) => {
telemetryCalls.push({ event, props });
},
};
const agent = { turn, telemetry } as unknown as Agent;
return {
agent,
steerCalls,
telemetryCalls,
setHasActiveTurn: (v: boolean) => {
hasActiveTurn = v;
},
};
}
export interface ClockHarness {
readonly clocks: ClockSources;
/** Set wall + mono to a specific epoch ms. */
setNow(v: number): void;
/** Advance wall + mono by `ms`. */
advance(ms: number): void;
/** Current wall-clock value. */
now(): number;
}
export function createClocks(initial: number = WALL_ANCHOR): ClockHarness {
let wall = initial;
let mono = 1_000_000;
return {
clocks: {
wallNow: () => wall,
monoNowMs: () => mono,
},
setNow: (v) => {
wall = v;
mono = v;
},
advance: (ms) => {
wall += ms;
mono += ms;
},
now: () => wall,
};
}
/**
* Normalise non-deterministic fields out of a cron tool's output so it
* can be fed to `toMatchInlineSnapshot`. Replaces randomly-generated
* 8-hex ids and ISO timestamps with stable placeholders.
*/
export function scrubCronOutput(out: string): string {
return out
.replaceAll(/\b[0-9a-f]{8}\b/g, '<id>')
.replaceAll(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, '<iso>');
}

View file

@ -0,0 +1,452 @@
/**
* Tests for `agent/cron/manager.ts`. Uses a lightweight Agent stub
* (see ./harness/stub) only the three surfaces the manager touches
* (turn.hasActiveTurn, turn.steer, telemetry.track) need to look real.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ContentPart } from '@moonshot-ai/kosong';
import { CronManager } from '../../../src/agent/cron/manager';
import type { ClockSources } from '../../../src/tools/cron/clock';
import {
CRON_FIRED,
CRON_MISSED,
} from '../../../src/tools/cron/telemetry-events';
import type { CronTask } from '../../../src/tools/cron/types';
import {
createAgentStub,
createClocks,
WALL_ANCHOR,
} from './harness/stub';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
describe('CronManager', () => {
beforeEach(() => {
// Pin jitter off so fire-count assertions are deterministic. Each
// test that actually exercises fires resets the env via stubEnv,
// but setting it here as well shields the construction-path tests
// from any leaked state.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('construction', () => {
it('does not throw with default clocks and supports start/stop', async () => {
const { agent } = createAgentStub();
// Disable the auto-tick timer so the test doesn't have to wait
// for setInterval / clean it up; we just want start() and stop()
// to be wired and idempotent.
const manager = new CronManager(agent, { pollIntervalMs: null });
expect(() => manager.start()).not.toThrow();
expect(() => manager.start()).not.toThrow(); // idempotent
await expect(manager.stop()).resolves.toBeUndefined();
await expect(manager.stop()).resolves.toBeUndefined();
});
it('exposes the session store as an empty list on construction', () => {
const { agent } = createAgentStub();
const manager = new CronManager(agent, { pollIntervalMs: null });
expect(manager.store.list()).toEqual([]);
expect(manager.getNextFireTime()).toBeNull();
});
it('getNextFireForTask delegates to the scheduler', () => {
const { agent } = createAgentStub();
const manager = new CronManager(agent, { pollIntervalMs: null });
const scheduler = (manager as unknown as {
scheduler: { getNextFireForTask: (id: string) => number | null };
}).scheduler;
const spy = vi.spyOn(scheduler, 'getNextFireForTask').mockReturnValue(123);
expect(manager.getNextFireForTask('deadbeef')).toBe(123);
expect(spy).toHaveBeenCalledWith('deadbeef');
});
});
describe('handleFire — recurring', () => {
it('steers with cron_job origin and emits cron_fired telemetry', () => {
const stub = createAgentStub({ steerReturns: 7 });
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'check the deploy' },
harness.now() - 1,
);
// `*/5 * * * *` lands every 5 minutes; bump 6 minutes so we are
// safely past exactly one ideal fire.
harness.advance(6 * 60_000);
manager.tick();
expect(stub.steerCalls.length).toBe(1);
const call = stub.steerCalls[0]!;
expect(call.origin.kind).toBe('cron_job');
if (call.origin.kind !== 'cron_job') throw new Error('unreachable');
expect(call.origin.recurring).toBe(true);
expect(call.origin.stale).toBe(false);
expect(call.origin.coalescedCount).toBeGreaterThanOrEqual(1);
expect(call.origin.cron).toBe('*/5 * * * *');
expect(call.origin.jobId).toMatch(/^[0-9a-f]{8}$/);
// Content is wrapped in the cron-fire envelope (Bug A fix).
expect(call.content).toHaveLength(1);
const text = (call.content[0] as { type: 'text'; text: string }).text;
expect(text).toContain('<cron-fire ');
expect(text).toContain('<prompt>\ncheck the deploy\n</prompt>');
// Exactly one envelope — guards against an accidental double-wrap
// (e.g. handleFire calling renderCronFireXml on already-rendered
// content from a future refactor).
expect((text.match(/<cron-fire /g) ?? []).length).toBe(1);
expect(stub.telemetryCalls.length).toBe(1);
const tc = stub.telemetryCalls[0]!;
expect(tc.event).toBe(CRON_FIRED);
expect(tc.props).toMatchObject({
recurring: true,
stale: false,
buffered: false,
});
});
});
describe('handleFire — one-shot', () => {
it('uses recurring=false in origin and telemetry', () => {
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
// Add a one-shot task that fires at the very next */5 mark, then
// advance the wall clock past it.
const task = manager.store.add(
{
cron: '*/5 * * * *',
prompt: 'one-shot ping',
recurring: false,
},
harness.now() - 1,
);
harness.advance(6 * 60_000);
manager.tick();
expect(stub.steerCalls.length).toBe(1);
const origin = stub.steerCalls[0]!.origin;
expect(origin.kind).toBe('cron_job');
if (origin.kind !== 'cron_job') throw new Error('unreachable');
expect(origin.recurring).toBe(false);
expect(origin.stale).toBe(false);
// Content carries the cron-fire envelope around the verbatim prompt.
const content = stub.steerCalls[0]!.content;
const text = (content[0] as { type: 'text'; text: string }).text;
expect(text).toContain('<cron-fire ');
expect(text).toContain('recurring="false"');
expect(text).toContain('<prompt>\none-shot ping\n</prompt>');
const tc = stub.telemetryCalls[0]!;
expect(tc.props).toMatchObject({ recurring: false });
// One-shot was removed from the store after fire.
expect(manager.store.get(task.id)).toBeUndefined();
});
});
describe('isStale', () => {
it('flags recurring tasks older than 7 days as stale', () => {
const { agent } = createAgentStub();
const harness = createClocks();
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
const task: CronTask = {
id: 'deadbeef',
cron: '0 9 * * *',
prompt: 'morning report',
createdAt: harness.now() - 8 * ONE_DAY_MS,
recurring: true,
};
expect(manager.isStale(task)).toBe(true);
});
it('does not flag recurring tasks younger than 7 days', () => {
const { agent } = createAgentStub();
const harness = createClocks();
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
const task: CronTask = {
id: 'deadbeef',
cron: '0 9 * * *',
prompt: 'morning report',
createdAt: harness.now() - 6 * ONE_DAY_MS,
recurring: true,
};
expect(manager.isStale(task)).toBe(false);
});
it('treats undefined recurring as recurring for stale purposes', () => {
const { agent } = createAgentStub();
const harness = createClocks();
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
const task: CronTask = {
id: 'deadbeef',
cron: '0 9 * * *',
prompt: 'morning report',
createdAt: harness.now() - 8 * ONE_DAY_MS,
// recurring intentionally omitted
};
expect(manager.isStale(task)).toBe(true);
});
it('one-shot tasks are never stale even if old', () => {
const { agent } = createAgentStub();
const harness = createClocks();
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
const task: CronTask = {
id: 'deadbeef',
cron: '0 9 * * *',
prompt: 'morning report',
createdAt: harness.now() - 8 * ONE_DAY_MS,
recurring: false,
};
expect(manager.isStale(task)).toBe(false);
});
it('KIMI_CRON_NO_STALE=1 disables stale judgment for recurring', () => {
vi.stubEnv('KIMI_CRON_NO_STALE', '1');
const { agent } = createAgentStub();
const harness = createClocks();
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
const task: CronTask = {
id: 'deadbeef',
cron: '0 9 * * *',
prompt: 'morning report',
createdAt: harness.now() - 8 * ONE_DAY_MS,
recurring: true,
};
expect(manager.isStale(task)).toBe(false);
});
it('non-finite age (broken clock) is treated as not stale', () => {
const { agent } = createAgentStub();
const brokenClocks: ClockSources = {
wallNow: () => Number.NaN,
monoNowMs: () => 0,
};
const manager = new CronManager(agent, {
clocks: brokenClocks,
pollIntervalMs: null,
});
const task: CronTask = {
id: 'deadbeef',
cron: '0 9 * * *',
prompt: 'morning report',
createdAt: 0,
recurring: true,
};
expect(manager.isStale(task)).toBe(false);
});
});
describe('stale propagation into fire origin', () => {
it('origin.stale === true for a recurring task older than 7 days', () => {
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
// Add a recurring task whose createdAt is 8 days ago. Note: the
// scheduler uses createdAt as the starting baseline for next-fire
// computation, so a task that's been "alive" for 8 days will be
// very overdue and will coalesce a lot of fires into one. That's
// fine for this test — we only assert on `stale` (which is
// computed from createdAt vs now) and `coalescedCount >= 1`.
manager.store.add(
{ cron: '0 9 * * *', prompt: 'morning report', recurring: true },
harness.now() - 8 * ONE_DAY_MS,
);
manager.tick();
expect(stub.steerCalls.length).toBe(1);
const origin = stub.steerCalls[0]!.origin;
if (origin.kind !== 'cron_job') throw new Error('expected cron_job');
expect(origin.stale).toBe(true);
expect(stub.telemetryCalls[0]!.props).toMatchObject({ stale: true });
// Rendered envelope carries the stale flag too.
const text = (stub.steerCalls[0]!.content[0] as {
type: 'text';
text: string;
}).text;
expect(text).toContain('stale="true"');
});
it('stale recurring tasks get one final fire and are then removed', () => {
// Mirrors the documented contract on `CronCreate.description`:
// recurring tasks auto-expire after 7 days — they fire one final
// time, then are deleted. Without this branch a session that
// stays up past the stale threshold keeps re-injecting an old
// cron prompt forever.
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'stale-recurring', recurring: true },
harness.now() - 8 * ONE_DAY_MS,
);
expect(manager.store.list()).toHaveLength(1);
manager.tick();
expect(stub.steerCalls.length).toBe(1);
expect(manager.store.list()).toHaveLength(0);
// A `cron_deleted` event closes the lifecycle in telemetry,
// symmetric with manual `CronDelete` calls.
const events = stub.telemetryCalls.map((c) => c.event);
expect(events).toContain('cron_fired');
expect(events).toContain('cron_deleted');
// No further fires after the task is gone.
harness.advance(6 * 60_000);
manager.tick();
expect(stub.steerCalls.length).toBe(1);
});
});
describe('buffered semantics', () => {
it('reports buffered=true on the telemetry event when steer returns null', () => {
const stub = createAgentStub({ steerReturns: null });
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'while-active' },
harness.now() - 1,
);
harness.advance(6 * 60_000);
manager.tick();
expect(stub.telemetryCalls.length).toBe(1);
expect(stub.telemetryCalls[0]!.props).toMatchObject({ buffered: true });
});
});
describe('idle gating', () => {
it('does not fire while a turn is active', () => {
const stub = createAgentStub({ hasActiveTurn: true });
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'ping' },
harness.now() - 1,
);
harness.advance(6 * 60_000);
manager.tick();
expect(stub.steerCalls.length).toBe(0);
expect(stub.telemetryCalls.length).toBe(0);
// Flip back to idle and the next tick fires.
stub.setHasActiveTurn(false);
manager.tick();
expect(stub.steerCalls.length).toBe(1);
});
});
describe('end-to-end via scheduler', () => {
it('fires once with coalescedCount=1 after a 6-minute gap on */5', () => {
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'every five' },
harness.now() - 1,
);
// Six minutes past the anchor — exactly one ideal fire in the gap.
harness.advance(6 * 60_000);
manager.tick();
expect(stub.steerCalls.length).toBe(1);
const origin = stub.steerCalls[0]!.origin;
if (origin.kind !== 'cron_job') throw new Error('expected cron_job');
expect(origin.coalescedCount).toBe(1);
});
});
describe('handleMissed', () => {
it('no-ops on an empty task list', () => {
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
manager.handleMissed([], () => [{ type: 'text', text: 'should not run' }]);
expect(stub.steerCalls.length).toBe(0);
expect(stub.telemetryCalls.length).toBe(0);
});
it('steers cron_missed origin and emits cron_missed telemetry', () => {
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
const tasks: CronTask[] = [
{
id: '11111111',
cron: '0 9 * * *',
prompt: 'a',
createdAt: 1,
recurring: false,
},
{
id: '22222222',
cron: '0 10 * * *',
prompt: 'b',
createdAt: 2,
recurring: false,
},
];
const rendered: ContentPart[] = [
{ type: 'text', text: 'You missed 2 one-shot tasks.' },
];
manager.handleMissed(tasks, () => rendered);
expect(stub.steerCalls.length).toBe(1);
const call = stub.steerCalls[0]!;
expect(call.content).toBe(rendered);
expect(call.origin.kind).toBe('cron_missed');
if (call.origin.kind !== 'cron_missed') throw new Error('unreachable');
expect(call.origin.count).toBe(2);
expect(stub.telemetryCalls.length).toBe(1);
expect(stub.telemetryCalls[0]!.event).toBe(CRON_MISSED);
expect(stub.telemetryCalls[0]!.props).toEqual({ count: 2 });
});
});
});

View file

@ -0,0 +1,229 @@
/**
* Tests for `agent/cron/manager.ts` P1.8 affordances: the
* `KIMI_CRON_MANUAL_TICK=1` env disables the auto-tick interval and,
* in the same gate, binds SIGUSR1 to a no-throw `tick()` for benches.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CronManager } from '../../../src/agent/cron/manager';
import { createAgentStub, createClocks } from './harness/stub';
describe('CronManager — P1.8 manual tick + SIGUSR1', () => {
beforeEach(() => {
// Disable jitter so fire-count assertions are deterministic.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
});
afterEach(() => {
vi.unstubAllEnvs();
vi.useRealTimers();
});
describe('KIMI_CRON_MANUAL_TICK=1', () => {
it('does not install setInterval; tick() must be called manually', async () => {
vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1');
const stub = createAgentStub();
const harness = createClocks();
// Caller passes pollIntervalMs: 50 — but the env flag overrides
// it, so no auto-tick should run even after we wait real time.
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: 50,
});
try {
manager.start();
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'manual-only' },
harness.now() - 1,
);
harness.advance(6 * 60_000);
// Real-time wait: if an interval were registered, 50ms is more
// than enough to fire at least once. We do NOT use fake timers
// here because the whole point is to prove no timer exists.
await new Promise((r) => setTimeout(r, 50));
expect(stub.steerCalls.length).toBe(0);
// Manual drive → fires.
manager.tick();
expect(stub.steerCalls.length).toBe(1);
} finally {
await manager.stop();
}
});
});
describe('without KIMI_CRON_MANUAL_TICK', () => {
it('auto-tick fires when fake timers advance past pollIntervalMs', async () => {
// Fake timers must be in place BEFORE the manager calls
// setInterval, otherwise the scheduler captures the real one.
vi.useFakeTimers();
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: 50,
});
try {
manager.start();
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'auto-tick' },
harness.now() - 1,
);
// Move the injected wall clock past one ideal fire, then let the
// setInterval drain by advancing fake timers past one poll.
harness.advance(6 * 60_000);
vi.advanceTimersByTime(60);
expect(stub.steerCalls.length).toBe(1);
} finally {
await manager.stop();
}
});
});
describe('SIGUSR1', () => {
// SIGUSR1 binding is opt-in via KIMI_CRON_MANUAL_TICK=1 so that
// production (1 main agent + N subagents) doesn't pile up listeners
// and trip Node's MaxListenersExceededWarning cap. All four SIGUSR1
// tests stub the env before constructing the manager.
beforeEach(() => {
vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1');
});
it('triggers manager.tick() once per emit (POSIX only)', async () => {
if (process.platform === 'win32') return;
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
try {
manager.start();
const spy = vi.spyOn(manager, 'tick');
process.emit('SIGUSR1', 'SIGUSR1');
expect(spy).toHaveBeenCalledTimes(1);
} finally {
await manager.stop();
}
});
it('swallows throws from tick() so the host process never crashes', async () => {
if (process.platform === 'win32') return;
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
try {
manager.start();
vi.spyOn(manager, 'tick').mockImplementation(() => {
throw new Error('boom');
});
// If the handler re-threw, this `emit` would propagate. The
// assertion below is the "no throw" side-effect.
expect(() => process.emit('SIGUSR1', 'SIGUSR1')).not.toThrow();
} finally {
await manager.stop();
}
});
it('logs swallowed tick() throws to stderr when KIMI_CRON_DEBUG=1', async () => {
if (process.platform === 'win32') return;
vi.stubEnv('KIMI_CRON_DEBUG', '1');
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
manager.start();
vi.spyOn(manager, 'tick').mockImplementation(() => {
throw new Error('debug-boom');
});
process.emit('SIGUSR1', 'SIGUSR1');
expect(writeSpy).toHaveBeenCalled();
const calls = writeSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((s) => /cron\/manager.*SIGUSR1/.test(s))).toBe(true);
expect(calls.some((s) => s.includes('debug-boom'))).toBe(true);
} finally {
writeSpy.mockRestore();
await manager.stop();
}
});
it('does not write to stderr on tick() throw when KIMI_CRON_DEBUG is unset', async () => {
if (process.platform === 'win32') return;
// KIMI_CRON_DEBUG intentionally NOT set in this test.
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
manager.start();
vi.spyOn(manager, 'tick').mockImplementation(() => {
throw new Error('silent-boom');
});
process.emit('SIGUSR1', 'SIGUSR1');
// No cron/manager line was emitted because debug is off.
const calls = writeSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((s) => /cron\/manager/.test(s))).toBe(false);
} finally {
writeSpy.mockRestore();
await manager.stop();
}
});
it('stop() removes the SIGUSR1 listener (no leak)', async () => {
if (process.platform === 'win32') return;
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
const before = process.listenerCount('SIGUSR1');
manager.start();
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
await manager.stop();
expect(process.listenerCount('SIGUSR1')).toBe(before);
});
it('start() is idempotent — second call does not double-bind', async () => {
if (process.platform === 'win32') return;
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
const before = process.listenerCount('SIGUSR1');
try {
manager.start();
manager.start();
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
} finally {
await manager.stop();
}
});
it('does not bind when KIMI_CRON_MANUAL_TICK is unset', async () => {
if (process.platform === 'win32') return;
// Override the describe-scope stub so the env is genuinely unset.
vi.unstubAllEnvs();
// Re-pin jitter so other describe-scope state stays consistent.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
const before = process.listenerCount('SIGUSR1');
try {
manager.start();
expect(process.listenerCount('SIGUSR1')).toBe(before);
} finally {
await manager.stop();
}
});
});
});

View file

@ -0,0 +1,361 @@
/**
* Resume / cross-restart persistence for CronManager.
*
* The manager's `addTask` / `removeTasks` wrappers mirror every mutation
* to `<sessionDir>/cron/<id>.json`, and `loadFromDisk()` re-populates
* the in-memory store on `kimi resume`. The scheduler's
* `createdAt`-based baseline is what makes a reloaded task fire
* correctly even when ideal fire times landed during downtime these
* tests pin down both sides of the contract.
*/
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CronManager } from '../../../src/agent/cron/manager';
import { createCronPersistStore } from '../../../src/tools/cron/persist';
import {
createAgentStub,
createClocks,
WALL_ANCHOR,
} from './harness/stub';
let sessionDir: string;
beforeEach(async () => {
// Disable jitter so the scheduler delivers on exact ideal fires.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
sessionDir = await mkdtemp(join(tmpdir(), 'kimi-cron-resume-'));
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(sessionDir, { recursive: true, force: true });
});
async function readDiskIds(): Promise<readonly string[]> {
try {
const entries = await readdir(join(sessionDir, 'cron'));
return entries
.filter((e) => e.endsWith('.json'))
.map((e) => e.slice(0, -'.json'.length))
.toSorted();
} catch {
return [];
}
}
describe('CronManager — persistence and resume', () => {
it('addTask writes a JSON record to <sessionDir>/cron/<id>.json', async () => {
const { agent } = createAgentStub();
const harness = createClocks();
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
sessionDir,
});
const task = manager.addTask({
cron: '*/5 * * * *',
prompt: 'ping',
});
await manager.flushPersist();
const store = createCronPersistStore(sessionDir);
const loaded = await store.read(task.id);
expect(loaded).toEqual({
id: task.id,
cron: '*/5 * * * *',
prompt: 'ping',
createdAt: harness.now(),
// `recurring` defaults to recurring; the store omits it iff the
// caller did. We pass through `init` as-is, so an unset field
// round-trips as undefined.
recurring: undefined,
});
expect(await readDiskIds()).toEqual([task.id]);
await manager.stop();
});
it('removeTasks deletes the JSON record', async () => {
const { agent } = createAgentStub();
const harness = createClocks();
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
sessionDir,
});
const task = manager.addTask({ cron: '*/5 * * * *', prompt: 'a' });
await manager.flushPersist();
expect((await readDiskIds()).length).toBe(1);
manager.removeTasks([task.id]);
await manager.flushPersist();
expect(await readDiskIds()).toEqual([]);
await manager.stop();
});
it('loadFromDisk re-adopts tasks with original id and createdAt', async () => {
// First "session": schedule two recurring tasks.
const stubA = createAgentStub();
const clockA = createClocks();
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
const t1 = managerA.addTask({ cron: '*/5 * * * *', prompt: 'a' });
const t2 = managerA.addTask({
cron: '0 9 * * *',
prompt: 'b',
recurring: true,
});
await managerA.flushPersist();
await managerA.stop();
// Second "session": fresh manager, same sessionDir.
const stubB = createAgentStub();
const clockB = createClocks(clockA.now() + 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
expect(managerB.store.list()).toEqual([]);
await managerB.loadFromDisk();
const loaded = managerB.store.list().slice().toSorted((a, b) => a.id.localeCompare(b.id));
const expected = [t1, t2].toSorted((a, b) => a.id.localeCompare(b.id));
expect(loaded.map((t) => t.id)).toEqual(expected.map((t) => t.id));
for (const original of expected) {
const reloaded = managerB.store.get(original.id);
expect(reloaded).toBeDefined();
expect(reloaded?.cron).toBe(original.cron);
expect(reloaded?.prompt).toBe(original.prompt);
expect(reloaded?.createdAt).toBe(original.createdAt);
}
await managerB.stop();
});
it('recurring task missed during downtime fires once with coalescedCount > 1', async () => {
// Session A: create a `*/5 * * * *` task.
const stubA = createAgentStub();
const clockA = createClocks();
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
managerA.addTask({ cron: '*/5 * * * *', prompt: 'check' });
await managerA.flushPersist();
await managerA.stop();
// Session B: 23 minutes later (≈ 4 ideal fires missed: t+5, +10,
// +15, +20). With createdAt as the baseline the scheduler must
// collapse them into one fire.
const stubB = createAgentStub();
const clockB = createClocks(clockA.now() + 23 * 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
await managerB.loadFromDisk();
managerB.tick();
expect(stubB.steerCalls.length).toBe(1);
const origin = stubB.steerCalls[0]!.origin;
if (origin.kind !== 'cron_job') throw new Error('unreachable');
expect(origin.coalescedCount).toBeGreaterThan(1);
expect(origin.stale).toBe(false); // < 7 days old
expect(origin.recurring).toBe(true);
await managerB.stop();
});
it('one-shot scheduled in the past fires once on resume and the file is removed', async () => {
// Session A: schedule a one-shot 5 minutes ahead of clockA's now,
// then quit before it fires.
const stubA = createAgentStub();
const clockA = createClocks(WALL_ANCHOR);
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
// Compute a cron expression that lands 5 minutes in the future
// using the harness's anchor (Nov 14 2023 22:13:20 UTC). The next
// `*/5 * * * *` ideal after the anchor is 22:15:00 UTC.
const oneShot = managerA.addTask({
cron: '*/5 * * * *',
prompt: 'remind once',
recurring: false,
});
await managerA.flushPersist();
expect(await readDiskIds()).toEqual([oneShot.id]);
await managerA.stop();
// Session B: 10 minutes after the anchor — the one-shot's ideal
// fire (anchor + 100s ≈ 22:15:00) is in the past.
const stubB = createAgentStub();
const clockB = createClocks(clockA.now() + 10 * 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
await managerB.loadFromDisk();
managerB.tick();
expect(stubB.steerCalls.length).toBe(1);
const origin = stubB.steerCalls[0]!.origin;
if (origin.kind !== 'cron_job') throw new Error('unreachable');
expect(origin.recurring).toBe(false);
// One-shots always report coalescedCount = 1 regardless of how
// long ago they should have fired (their semantics are "remind
// me once", not "remind me N times").
expect(origin.coalescedCount).toBe(1);
// After firing, the scheduler asks the manager to remove the
// one-shot, which clears both in-memory and on-disk records.
await managerB.flushPersist();
expect(managerB.store.list()).toEqual([]);
expect(await readDiskIds()).toEqual([]);
await managerB.stop();
});
it('recurring task fired before shutdown does NOT replay on resume', async () => {
// Session A: schedule a `*/5 * * * *` task, advance past the first
// ideal fire, tick once. The scheduler's onAdvanceCursor callback
// must stamp `lastFiredAt` on the persisted record so session B
// doesn't re-coalesce that already-delivered occurrence.
const stubA = createAgentStub();
const clockA = createClocks(WALL_ANCHOR);
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
const task = managerA.addTask({ cron: '*/5 * * * *', prompt: 'check' });
await managerA.flushPersist();
// Advance ~6 minutes — the first */5 ideal (anchor + 100s) is now
// due. Tick once to deliver and stamp lastFiredAt.
clockA.advance(6 * 60_000);
managerA.tick();
expect(stubA.steerCalls.length).toBe(1);
// Drain the cursor persistence write before shutdown so session B
// observes the stamped lastFiredAt.
await managerA.flushPersist();
await managerA.stop();
// Sanity: the persisted JSON now carries a non-undefined lastFiredAt.
const onDisk = await createCronPersistStore(sessionDir).read(task.id);
expect(typeof onDisk?.lastFiredAt).toBe('number');
// It must be at or before session A's last wall clock (not in the future).
expect(onDisk!.lastFiredAt!).toBeLessThanOrEqual(clockA.now());
// Session B: resume 23 minutes after the anchor (matching the
// existing "missed during downtime" test). Without persisted
// lastFiredAt, session B would coalesce-replay the already-fired
// first ideal occurrence (count = 5: 5/10/15/20 minute marks plus
// the one that was already delivered in session A). With the
// persistence, session A's fire is skipped on resume so the
// count is strictly lower.
const stubB = createAgentStub();
const clockB = createClocks(WALL_ANCHOR + 23 * 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
await managerB.loadFromDisk();
managerB.tick();
expect(stubB.steerCalls.length).toBe(1);
const resumeOrigin = stubB.steerCalls[0]!.origin;
if (resumeOrigin.kind !== 'cron_job') throw new Error('unreachable');
// 23 min window contains 5 ideal */5 fires. Session A consumed 1.
// Session B should coalesce at most 4 fires.
expect(resumeOrigin.coalescedCount).toBeLessThanOrEqual(4);
expect(resumeOrigin.coalescedCount).toBeGreaterThanOrEqual(1);
await managerB.stop();
});
it('treats a future lastFiredAt as corrupt and falls back to createdAt', async () => {
// If the persisted cursor lands ahead of the current wall clock
// (clock skew or a bench env mistake) the scheduler must not skip
// legitimately-due fires. The sanity gate ignores the bogus value
// and falls back to `createdAt`, matching pre-persistence behaviour.
const stubA = createAgentStub();
const clockA = createClocks();
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
const task = managerA.addTask({ cron: '*/5 * * * *', prompt: 'check' });
await managerA.flushPersist();
await managerA.stop();
// Manually corrupt the on-disk record with a lastFiredAt set far in
// the future relative to session B's clock.
const store = createCronPersistStore(sessionDir);
const original = await store.read(task.id);
if (original === undefined) throw new Error('expected persisted task');
await store.write(task.id, {
...original,
lastFiredAt: clockA.now() + 365 * 24 * 60 * 60 * 1000,
});
// Session B: 23 minutes later. Even though lastFiredAt is in the
// future, the scheduler must still fire (sanity gate ignores it).
const stubB = createAgentStub();
const clockB = createClocks(clockA.now() + 23 * 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
await managerB.loadFromDisk();
managerB.tick();
expect(stubB.steerCalls.length).toBe(1);
const origin = stubB.steerCalls[0]!.origin;
if (origin.kind !== 'cron_job') throw new Error('unreachable');
expect(origin.coalescedCount).toBeGreaterThan(1);
await managerB.stop();
});
it('no sessionDir = pure in-memory: no FS side effects, loadFromDisk is a no-op', async () => {
const { agent } = createAgentStub();
const harness = createClocks();
// Construct without sessionDir.
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
manager.addTask({ cron: '*/5 * * * *', prompt: 'a' });
await manager.flushPersist();
expect(await readDiskIds()).toEqual([]); // sessionDir untouched
// loadFromDisk must not pollute the existing in-memory list.
expect(manager.store.list().length).toBe(1);
await manager.loadFromDisk();
expect(manager.store.list().length).toBe(1);
await manager.stop();
});
});

View file

@ -0,0 +1,88 @@
/**
* Subagent cron suppression: each session can spawn many subagents, and
* unconditionally starting a CronManager per agent leaks 1s setInterval
* timers and SIGUSR1 listeners (under KIMI_CRON_MANUAL_TICK=1) that
* never serve any purpose default subagent profiles don't expose the
* Cron tools to the LLM. This test pins both halves of the fix:
*
* 1. `agent.cron` is still constructed (consumers reach into
* `agent.cron.store` and the field is `readonly` on the type).
* 2. `cron.start()` is skipped for `type: 'sub'` so the SIGUSR1
* listener count stays put.
* 3. The three Cron tools (`CronCreate` / `CronList` / `CronDelete`)
* are NOT registered in the subagent's tool manager.
* 4. `type: 'main'` and `type: 'independent'` keep the old behaviour
* listener bound, tools registered.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { testAgent } from '../harness/agent';
const CRON_TOOL_NAMES = ['CronCreate', 'CronList', 'CronDelete'] as const;
describe('Agent + Cron — subagent suppression', () => {
beforeEach(() => {
// SIGUSR1 binding only happens under KIMI_CRON_MANUAL_TICK=1
// (see manager.ts bindSigusr1). Using it as the probe lets us
// observe `start()` vs no-start without poking private fields.
vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1');
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("type='sub': cron exists, start() is skipped, tools not registered", () => {
if (process.platform === 'win32') return;
const before = process.listenerCount('SIGUSR1');
const ctx = testAgent({ type: 'sub' });
// Manager is still wired so `agent.cron.store` access stays safe.
expect(ctx.agent.cron).toBeDefined();
expect(ctx.agent.cron.store).toBeDefined();
// start() was not called — no SIGUSR1 binding accrued.
expect(process.listenerCount('SIGUSR1')).toBe(before);
// Configure with the cron tool names in the whitelist; even with
// the LLM allowlist explicitly listing them, the BuiltinToolManager
// must not have constructed the instances for a subagent.
ctx.configure({ tools: [...CRON_TOOL_NAMES] });
const toolNames = ctx.agent.tools.data().map((info) => info.name);
for (const name of CRON_TOOL_NAMES) {
expect(toolNames).not.toContain(name);
}
});
it("type='main': start() runs, tools registered", () => {
if (process.platform === 'win32') return;
const before = process.listenerCount('SIGUSR1');
const ctx = testAgent({ type: 'main' });
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
ctx.configure({ tools: [...CRON_TOOL_NAMES] });
const toolNames = ctx.agent.tools.data().map((info) => info.name);
for (const name of CRON_TOOL_NAMES) {
expect(toolNames).toContain(name);
}
});
it("type='independent': start() runs, tools registered", () => {
if (process.platform === 'win32') return;
const before = process.listenerCount('SIGUSR1');
const ctx = testAgent({ type: 'independent' });
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
ctx.configure({ tools: [...CRON_TOOL_NAMES] });
const toolNames = ctx.agent.tools.data().map((info) => info.name);
for (const name of CRON_TOOL_NAMES) {
expect(toolNames).toContain(name);
}
});
});

View file

@ -4,7 +4,7 @@ import { Readable, type Writable } from 'node:stream';
import { createControlledPromise } from '@antfu/utils';
import { type Environment, type Kaos, type KaosProcess } from '@moonshot-ai/kaos';
import type { ModelCapability, ProviderConfig } from '@moonshot-ai/kosong';
import { expect, vi } from 'vitest';
import { expect, onTestFinished, vi } from 'vitest';
import {
Agent,
@ -190,6 +190,16 @@ export class AgentTestContext {
log: options.log,
});
this.rpc = this.createPromiseAgentApi(this.agent);
// The Agent constructor now eagerly binds a SIGUSR1 listener via
// CronManager.start(). Without per-test cleanup, every Agent built
// by this harness leaks one listener — Node prints a
// MaxListenersExceededWarning once the suite crosses 10 agents.
// onTestFinished is a vitest API callable from non-hook scopes, so
// we register cleanup transparently without forcing every test to
// remember an afterEach.
onTestFinished(async () => {
await this.agent.cron.stop();
});
}
configure({

View file

@ -0,0 +1,47 @@
import type { ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import type { PermissionPolicyContext } from '../../../src/agent/permission';
import { DefaultToolApprovePermissionPolicy } from '../../../src/agent/permission/policies/default-tool-approve';
import { ToolAccesses } from '../../../src/loop';
const signal = new AbortController().signal;
function policyContext(toolName: string, args: unknown): PermissionPolicyContext {
return {
turnId: '0',
stepNumber: 1,
signal,
llm: {},
args,
toolCall: {
type: 'function',
id: `call_${toolName}`,
name: toolName,
arguments: JSON.stringify(args),
} satisfies ToolCall,
execution: {
accesses: ToolAccesses.none(),
approvalRule: toolName,
execute: async () => ({ output: '' }),
},
} as unknown as PermissionPolicyContext;
}
describe('DefaultToolApprovePermissionPolicy', () => {
const policy = new DefaultToolApprovePermissionPolicy();
it('auto-approves CronList', () => {
expect(policy.evaluate(policyContext('CronList', {}))).toEqual({ kind: 'approve' });
});
it('does not approve CronCreate', () => {
expect(
policy.evaluate(policyContext('CronCreate', { cron: '*/5 * * * *', prompt: 'ping' })),
).toBeUndefined();
});
it('does not approve CronDelete', () => {
expect(policy.evaluate(policyContext('CronDelete', { id: 'job_1' }))).toBeUndefined();
});
});

View file

@ -0,0 +1,94 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { testKaos } from '../fixtures/test-kaos';
import type { SDKSessionRPC } from '../../src/rpc';
import { Session } from '../../src/session';
const OS_ENV = {
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
} as const;
const tempDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 });
}
});
describe('Session.close stops cron', () => {
it('stops each agent cron scheduler on close', async () => {
const { sessionDir, workDir } = await sessionFixture();
const session = new Session({
runtime: { kaos: testKaos.withCwd(workDir) },
id: 'session-cron-stop',
homedir: sessionDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
});
const main = await session.createMain();
const stopSpy = vi.spyOn(main.cron, 'stop');
await session.close();
expect(stopSpy).toHaveBeenCalledTimes(1);
});
it('observably tears down cron side effects (SIGUSR1 listener cleared)', async () => {
// The spy-only test above proves `stop()` was called but would
// still pass if `stop()` no-op'd. Gate manual-tick mode so the
// CronManager binds a real SIGUSR1 listener, then assert the
// listener count returns to its pre-construction baseline after
// `session.close()`. Anything short of `unbindSigusr1` running
// would leak a listener.
if (process.platform === 'win32') return;
vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1');
const before = process.listenerCount('SIGUSR1');
const { sessionDir, workDir } = await sessionFixture();
const session = new Session({
runtime: { kaos: testKaos.withCwd(workDir) },
id: 'session-cron-stop-sigusr1',
homedir: sessionDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
});
await session.createMain();
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
await session.close();
expect(process.listenerCount('SIGUSR1')).toBe(before);
});
});
async function sessionFixture(): Promise<{
readonly sessionDir: string;
readonly workDir: string;
}> {
const dir = await mkdtemp(join(tmpdir(), 'kimi-session-cron-stop-'));
tempDirs.push(dir);
const workDir = join(dir, 'work');
const sessionDir = join(dir, 'session');
return { sessionDir, workDir };
}
function createSessionRpc(): SDKSessionRPC {
return {
emitEvent: vi.fn(async () => {}),
requestApproval: vi.fn(async () => ({ decision: 'cancelled' })),
requestQuestion: vi.fn(async () => null),
toolCall: vi.fn(async () => ({
output: 'custom tools are not supported in this test',
isError: true,
})),
} as SDKSessionRPC;
}

View file

@ -0,0 +1,147 @@
/**
* Tests for `tools/cron/clock.ts`.
*/
import { mkdtempSync, writeFileSync } from 'node:fs';
import * as os from 'node:os';
import { describe, expect, it } from 'vitest';
import { join } from 'pathe';
import { resolveClockSources, SYSTEM_CLOCKS } from '../../../src/tools/cron/clock';
describe('clock.ts', () => {
describe('SYSTEM_CLOCKS', () => {
it('monoNowMs is strictly non-decreasing across 1000 calls', () => {
let prev = SYSTEM_CLOCKS.monoNowMs();
for (let i = 0; i < 1000; i++) {
const next = SYSTEM_CLOCKS.monoNowMs();
expect(next).toBeGreaterThanOrEqual(prev);
prev = next;
}
});
it('wallNow is close to Date.now', () => {
const before = Date.now();
const sample = SYSTEM_CLOCKS.wallNow();
const after = Date.now();
expect(sample).toBeGreaterThanOrEqual(before);
expect(sample).toBeLessThanOrEqual(after);
});
it('monoNowMs returns finite positive numbers', () => {
const sample = SYSTEM_CLOCKS.monoNowMs();
expect(Number.isFinite(sample)).toBe(true);
expect(sample).toBeGreaterThan(0);
});
});
describe('resolveClockSources — default / system', () => {
it('undefined spec returns SYSTEM_CLOCKS', () => {
expect(resolveClockSources(undefined)).toBe(SYSTEM_CLOCKS);
});
it('empty string returns SYSTEM_CLOCKS', () => {
expect(resolveClockSources('')).toBe(SYSTEM_CLOCKS);
});
it('"system" returns SYSTEM_CLOCKS', () => {
expect(resolveClockSources('system')).toBe(SYSTEM_CLOCKS);
});
it('unrecognised scheme falls back to SYSTEM_CLOCKS', () => {
expect(resolveClockSources('garbage:foo')).toBe(SYSTEM_CLOCKS);
expect(resolveClockSources('foobar')).toBe(SYSTEM_CLOCKS);
});
});
describe('resolveClockSources — file:<path>', () => {
it('reads file first line on every wallNow call', () => {
const tmpDir = mkdtempSync(join(os.tmpdir(), 'kimi-cron-clock-'));
const filePath = join(tmpDir, 'now.txt');
writeFileSync(filePath, '1000\n', 'utf8');
const clocks = resolveClockSources(`file:${filePath}`);
expect(clocks.wallNow()).toBe(1000);
writeFileSync(filePath, '2500', 'utf8');
expect(clocks.wallNow()).toBe(2500);
// Multi-line — only first line counts.
writeFileSync(filePath, '4242\ngarbage\n', 'utf8');
expect(clocks.wallNow()).toBe(4242);
});
it('missing file falls back to Date.now', () => {
const tmpDir = mkdtempSync(join(os.tmpdir(), 'kimi-cron-clock-'));
const filePath = join(tmpDir, 'never-created.txt');
const clocks = resolveClockSources(`file:${filePath}`);
const before = Date.now();
const sample = clocks.wallNow();
const after = Date.now();
expect(sample).toBeGreaterThanOrEqual(before);
expect(sample).toBeLessThanOrEqual(after);
});
it('unparseable content falls back to Date.now', () => {
const tmpDir = mkdtempSync(join(os.tmpdir(), 'kimi-cron-clock-'));
const filePath = join(tmpDir, 'now.txt');
writeFileSync(filePath, 'not-a-number\n', 'utf8');
const clocks = resolveClockSources(`file:${filePath}`);
const before = Date.now();
const sample = clocks.wallNow();
const after = Date.now();
expect(sample).toBeGreaterThanOrEqual(before);
expect(sample).toBeLessThanOrEqual(after);
});
it('empty file falls back to Date.now', () => {
const tmpDir = mkdtempSync(join(os.tmpdir(), 'kimi-cron-clock-'));
const filePath = join(tmpDir, 'now.txt');
writeFileSync(filePath, '', 'utf8');
const clocks = resolveClockSources(`file:${filePath}`);
const before = Date.now();
const sample = clocks.wallNow();
const after = Date.now();
expect(sample).toBeGreaterThanOrEqual(before);
expect(sample).toBeLessThanOrEqual(after);
});
it('monoNowMs is not affected by file clock', () => {
const tmpDir = mkdtempSync(join(os.tmpdir(), 'kimi-cron-clock-'));
const filePath = join(tmpDir, 'now.txt');
writeFileSync(filePath, '1000', 'utf8');
const clocks = resolveClockSources(`file:${filePath}`);
const a = clocks.monoNowMs();
const b = clocks.monoNowMs();
expect(a).not.toBe(1000);
expect(b).toBeGreaterThanOrEqual(a);
});
it('empty file path in spec falls back to SYSTEM_CLOCKS', () => {
expect(resolveClockSources('file:')).toBe(SYSTEM_CLOCKS);
});
it('caps file reads at 64 bytes and parses the prefix', () => {
const tmpDir = mkdtempSync(join(os.tmpdir(), 'kimi-cron-clock-'));
const filePath = join(tmpDir, 'now.txt');
// First line is a valid epoch-ms within the 64-byte window; the
// remainder is garbage that would break Number(...) if read.
const prefix = '1234567890\n';
const garbage = 'x'.repeat(10_000);
writeFileSync(filePath, prefix + garbage, 'utf8');
const clocks = resolveClockSources(`file:${filePath}`);
expect(clocks.wallNow()).toBe(1234567890);
});
it('rejects garbage longer than the 64-byte cap and falls back to Date.now', () => {
const tmpDir = mkdtempSync(join(os.tmpdir(), 'kimi-cron-clock-'));
const filePath = join(tmpDir, 'now.txt');
writeFileSync(filePath, 'x'.repeat(100), 'utf8');
const clocks = resolveClockSources(`file:${filePath}`);
const before = Date.now();
const sample = clocks.wallNow();
const after = Date.now();
expect(sample).toBeGreaterThanOrEqual(before);
expect(sample).toBeLessThanOrEqual(after);
});
});
});

View file

@ -0,0 +1,582 @@
/**
* Tests for `tools/cron/cron-create.ts`.
*
* Empty-prompt handling lives in the loop's AJV layer (`prompt.min(1)`
* runs before `resolveExecution`), so we document the path instead of
* asserting a false positive.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CronManager } from '../../../src/agent/cron/manager';
import {
CronCreateTool,
MAX_CRON_JOBS_PER_SESSION,
type CronCreateInput,
} from '../../../src/tools/cron/cron-create';
import { CRON_SCHEDULED } from '../../../src/tools/cron/telemetry-events';
import type {
ExecutableToolErrorResult,
ExecutableToolResult,
RunnableToolExecution,
ToolExecution,
} from '../../../src/loop/types';
import {
createAgentStub,
createClocks,
scrubCronOutput,
type AgentStub,
} from '../../agent/cron/harness/stub';
interface Harness {
readonly stub: AgentStub;
readonly manager: CronManager;
readonly tool: CronCreateTool;
}
function makeHarness(): Harness {
const stub = createAgentStub();
const manager = new CronManager(stub.agent, {
clocks: createClocks().clocks,
pollIntervalMs: null,
});
const tool = new CronCreateTool(manager);
return { stub, manager, tool };
}
/**
* `resolveExecution` returns either a synchronous error (no `execute`)
* or a runnable execution. This narrows to the runnable case and runs
* `execute` with a minimal context.
*/
async function runTool(
tool: CronCreateTool,
input: CronCreateInput,
): Promise<ExecutableToolResult> {
const execution = tool.resolveExecution(input);
if (isErrorExecution(execution)) {
return execution;
}
return execution.execute({
turnId: 'test-turn',
toolCallId: 'test-call',
signal: new AbortController().signal,
});
}
function isErrorExecution(
execution: ToolExecution,
): execution is ExecutableToolErrorResult {
return (execution as RunnableToolExecution).execute === undefined;
}
function assertSuccess(result: ExecutableToolResult): string {
expect(result.isError ?? false).toBe(false);
expect(typeof result.output).toBe('string');
return result.output as string;
}
function assertError(result: ExecutableToolResult): string {
expect(result.isError).toBe(true);
expect(typeof result.output).toBe('string');
return result.output as string;
}
function extractApprovalRule(execution: ToolExecution): string {
if (isErrorExecution(execution)) {
throw new Error('expected runnable execution, got error');
}
const rule = (execution as RunnableToolExecution).approvalRule;
if (typeof rule !== 'string') {
throw new Error('expected approvalRule to be a string');
}
return rule;
}
describe('CronCreateTool', () => {
beforeEach(() => {
// Disable jitter so the nextFireAt string we render is the bare
// ideal time — keeps the format assertions readable without
// dragging in a jittered offset.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('schedules a recurring task and emits cron_scheduled', async () => {
const { stub, manager, tool } = makeHarness();
const result = await runTool(tool, {
cron: '*/5 * * * *',
prompt: 'hi',
recurring: true,
});
const out = assertSuccess(result);
// id is randomly generated, nextFireAt depends on the host TZ —
// both are scrubbed so the snapshot pins the structural format.
expect(scrubCronOutput(out)).toMatchInlineSnapshot(`
"id: <id>
cron: */5 * * * *
humanSchedule: every 5 minutes
recurring: true
nextFireAt: <iso>"
`);
expect(manager.store.list()).toHaveLength(1);
expect(stub.telemetryCalls).toHaveLength(1);
expect(stub.telemetryCalls[0]!.event).toBe(CRON_SCHEDULED);
expect(stub.telemetryCalls[0]!.props).toEqual({
recurring: true,
});
});
it('schedules a one-shot task with recurring=false in the stored record', async () => {
const { manager, tool, stub } = makeHarness();
const result = await runTool(tool, {
cron: '0 12 * * *',
prompt: 'noon',
recurring: false,
});
const out = assertSuccess(result);
expect(scrubCronOutput(out)).toMatchInlineSnapshot(`
"id: <id>
cron: 0 12 * * *
humanSchedule: at 12:00 every day
recurring: false
nextFireAt: <iso>"
`);
const tasks = manager.store.list();
expect(tasks).toHaveLength(1);
expect(tasks[0]!.recurring).toBe(false);
expect(stub.telemetryCalls).toHaveLength(1);
expect(stub.telemetryCalls[0]!.props).toMatchObject({ recurring: false });
});
it('rejects an unparseable cron expression', async () => {
const { manager, tool, stub } = makeHarness();
const msg = assertError(
await runTool(tool, {
cron: 'not a cron',
prompt: 'x',
recurring: true,
}),
);
expect(msg).toMatchInlineSnapshot(
`"Invalid cron expression: cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week); got 3"`,
);
expect(manager.store.list()).toHaveLength(0);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('rejects a legal-but-never-fires cron expression', async () => {
const { manager, tool, stub } = makeHarness();
// Feb 31st — parses fine, never fires.
const msg = assertError(
await runTool(tool, {
cron: '0 0 31 2 *',
prompt: 'never',
recurring: false,
}),
);
expect(msg).toMatchInlineSnapshot(
`"Cron expression "0 0 31 2 *" has no fire within 5 years; refusing to schedule."`,
);
expect(manager.store.list()).toHaveLength(0);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('returns an error when KIMI_DISABLE_CRON=1', async () => {
vi.stubEnv('KIMI_DISABLE_CRON', '1');
const { manager, tool, stub } = makeHarness();
const msg = assertError(
await runTool(tool, {
cron: '*/5 * * * *',
prompt: 'hi',
recurring: true,
}),
);
expect(msg).toMatchInlineSnapshot(
`"Cron scheduling is disabled (KIMI_DISABLE_CRON=1)."`,
);
expect(manager.store.list()).toHaveLength(0);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('refuses to schedule past the session cap', async () => {
const { manager, tool, stub } = makeHarness();
// Pre-fill the store with the max number of tasks. The cap reads
// `store.list().length`, so any well-formed task seeds it.
const seedNow = manager.clocks.wallNow();
for (let i = 0; i < MAX_CRON_JOBS_PER_SESSION; i++) {
manager.store.add(
{ cron: '*/5 * * * *', prompt: `seed-${String(i)}`, recurring: true },
seedNow,
);
}
expect(manager.store.list()).toHaveLength(MAX_CRON_JOBS_PER_SESSION);
const msg = assertError(
await runTool(tool, {
cron: '*/5 * * * *',
prompt: 'overflow',
recurring: true,
}),
);
expect(msg).toMatchInlineSnapshot(
`"Cron job cap reached (max 50 per session)."`,
);
expect(manager.store.list()).toHaveLength(MAX_CRON_JOBS_PER_SESSION);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('rejects prompts above the 8 KiB byte budget (multi-byte input)', async () => {
const { manager, tool, stub } = makeHarness();
// '汉' is 3 bytes in UTF-8; 3000 repetitions = 9000 bytes > 8192.
// zod's `.max(8192)` is in code units and would accept this — the
// byte check inside the tool catches it.
const prompt = '汉'.repeat(3000);
const msg = assertError(
await runTool(tool, {
cron: '*/5 * * * *',
prompt,
recurring: true,
}),
);
expect(msg).toMatchInlineSnapshot(
`"Prompt exceeds 8192 bytes (got 9000)."`,
);
expect(manager.store.list()).toHaveLength(0);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('documents empty-prompt handling as a loop-layer concern', () => {
// zod's `.min(1)` on `prompt` lives in the input schema, which
// the loop's AJV validator enforces before `resolveExecution` is
// ever invoked. The tool itself does not re-check that — see the
// module header for the rationale. This test exists as
// documentation rather than as a real assertion, so the rationale
// is co-located with the test list called out in the spec.
expect(true).toBe(true);
});
describe('whitespace normalization', () => {
it('normalizes newline-separated cron fields to single spaces in the store', async () => {
const { manager, tool } = makeHarness();
const result = await runTool(tool, {
cron: '*/5\n* * * *',
prompt: 'x',
recurring: true,
});
expect(result.isError ?? false).toBe(false);
const tasks = manager.store.list();
expect(tasks).toHaveLength(1);
expect(tasks[0]!.cron).toBe('*/5 * * * *');
});
it('normalizes tab-separated cron fields', async () => {
const { manager, tool } = makeHarness();
const result = await runTool(tool, {
cron: '*/5\t*\t*\t*\t*',
prompt: 'x',
recurring: true,
});
expect(result.isError ?? false).toBe(false);
const tasks = manager.store.list();
expect(tasks).toHaveLength(1);
expect(tasks[0]!.cron).toBe('*/5 * * * *');
});
it('normalizes leading/trailing whitespace', async () => {
const { manager, tool } = makeHarness();
const result = await runTool(tool, {
cron: ' */5 * * * * ',
prompt: 'x',
recurring: true,
});
expect(result.isError ?? false).toBe(false);
const tasks = manager.store.list();
expect(tasks).toHaveLength(1);
expect(tasks[0]!.cron).toBe('*/5 * * * *');
});
it('normalized cron is what shows up in the rendered CronCreate output', async () => {
const { tool } = makeHarness();
const result = await runTool(tool, {
cron: '*/5\n* * * *',
prompt: 'x',
recurring: true,
});
const out = assertSuccess(result);
expect(out).toContain('cron: */5 * * * *');
// No literal newline between the cron field tokens — the only
// newlines should be the record separators between keys.
expect(out).not.toMatch(/cron: \*\/5\n\* \* \* \*/);
});
it('normalizes whitespace before parse so humanSchedule does not leak newlines', async () => {
// `cronToHuman` recognizes "*/5 * * * *" → "every 5 minutes",
// so the rendered humanSchedule should be the template output —
// a single line, no leaked newline from the raw input.
const { tool } = makeHarness();
const result = await runTool(tool, {
cron: '*/5\n* * * *',
prompt: 'x',
recurring: true,
});
const out = assertSuccess(result);
const match = /^humanSchedule: (.+)$/m.exec(out);
expect(match).not.toBeNull();
const value = match![1]!;
expect(value).toBe('every 5 minutes');
expect(value).not.toContain('\n');
});
it('humanSchedule fallback is single-line for a non-template cron with tabs in input', async () => {
// Five specific integers don't match any cronToHuman template,
// so the function falls back to `parsed.raw`. With normalization
// moved before parsing, `parsed.raw` is the single-line form;
// the rendered humanSchedule must not contain the original tabs
// or any newlines.
const { tool } = makeHarness();
const result = await runTool(tool, {
cron: '1\t2\t3\t4\t5',
prompt: 'x',
recurring: true,
});
const out = assertSuccess(result);
const match = /^humanSchedule: (.+)$/m.exec(out);
expect(match).not.toBeNull();
const value = match![1]!;
expect(value).toBe('1 2 3 4 5');
expect(value).not.toContain('\t');
expect(value.split('\n').length - 1).toBe(0);
});
});
describe('clock anchored at execute() time', () => {
it('uses the clock value at execute(), not resolveExecution()', async () => {
// Mirrors the manual-approval scenario: prepare returns a
// runnable, the user takes a while to approve, and only then
// does execute() run. If the schedule were anchored at prepare
// time, the one-shot would be inserted with a stale
// `createdAt` and could fire immediately on the next tick.
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
const tool = new CronCreateTool(manager);
const execution = tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'after-delay',
recurring: false,
});
if (isErrorExecution(execution)) {
throw new Error('expected runnable execution');
}
// Advance the wall clock past prepare-time (manual-approval gap)
// before running execute(). The task should be created with
// `createdAt` equal to the new wall time, not the original one.
const beforeExecute = harness.now();
harness.advance(10 * 60_000);
const afterExecute = harness.now();
await execution.execute({
turnId: 't',
toolCallId: 'c',
signal: new AbortController().signal,
});
const tasks = manager.store.list();
expect(tasks).toHaveLength(1);
const task = tasks[0]!;
expect(task.createdAt).toBe(afterExecute);
expect(task.createdAt).not.toBe(beforeExecute);
});
});
describe('one-shot pinned-date guard', () => {
it('rejects a one-shot whose first fire is more than ~one year out', async () => {
// Simulate the "today, just missed" footgun: the tool docs
// recommend pinning <today_dom>/<today_month> for "remind me at X
// today". If submission lands seconds past the target minute,
// `computeNextCronRun` rolls the match to next year and the
// 5-year window happily accepts it. Without the dedicated guard
// the user gets a year-late reminder. Build the cron by pinning
// *yesterday's* local dom/month — guaranteed to have passed in
// every timezone, so the next match is next year.
const { stub, manager, tool } = makeHarness();
const yesterday = new Date(manager.clocks.wallNow() - 24 * 60 * 60 * 1000);
const dom = yesterday.getDate();
const month = yesterday.getMonth() + 1;
const result = await runTool(tool, {
cron: `0 12 ${String(dom)} ${String(month)} *`,
prompt: 'yesterday',
recurring: false,
});
const msg = assertError(result);
expect(msg).toContain('more than a year out');
expect(manager.store.list()).toHaveLength(0);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('accepts a recurring task whose next fire happens to be a year out', async () => {
// The guard is one-shot-only — recurring tasks may legitimately
// land their first match on next year's anniversary.
const { manager, tool } = makeHarness();
const yesterday = new Date(manager.clocks.wallNow() - 24 * 60 * 60 * 1000);
const dom = yesterday.getDate();
const month = yesterday.getMonth() + 1;
const result = await runTool(tool, {
cron: `0 12 ${String(dom)} ${String(month)} *`,
prompt: 'annual',
recurring: true,
});
expect(result.isError ?? false).toBe(false);
expect(manager.store.list()).toHaveLength(1);
});
it('accepts a one-shot scheduled for a near-future date this year', async () => {
// Sanity: the guard does not reject legitimate pinning whose
// next match is still well inside the window. Compute the
// target date dynamically from the harness clock so the test is
// independent of the host timezone (local-time evaluation of
// the cron means hard-coded dom/month would be wrong on non-UTC
// CI).
const { manager, tool } = makeHarness();
const sevenDaysAhead = new Date(manager.clocks.wallNow() + 7 * 24 * 60 * 60 * 1000);
const dom = sevenDaysAhead.getDate();
const month = sevenDaysAhead.getMonth() + 1;
const result = await runTool(tool, {
cron: `0 12 ${String(dom)} ${String(month)} *`,
prompt: 'in 7 days',
recurring: false,
});
expect(result.isError ?? false).toBe(false);
expect(manager.store.list()).toHaveLength(1);
});
});
describe('approvalRule includes the payload', () => {
it('produces a distinct rule for each (cron, prompt, recurring) tuple', () => {
// Without payload encoding, every CronCreate would share the
// same approvalRule `CronCreate` — one `scope: session` approval
// would auto-authorize any future scheduled prompt for the rest
// of the session. Mirror the Bash / Write / Edit convention: the
// rule must change when the payload changes.
const { tool } = makeHarness();
const ruleA = extractApprovalRule(
tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'A',
recurring: true,
}),
);
const ruleB = extractApprovalRule(
tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'B',
recurring: true,
}),
);
const ruleSameAsA = extractApprovalRule(
tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'A',
recurring: true,
}),
);
const ruleDifferentCron = extractApprovalRule(
tool.resolveExecution({
cron: '0 9 * * *',
prompt: 'A',
recurring: true,
}),
);
// Different prompts → different rules.
expect(ruleA).not.toBe(ruleB);
// Different crons → different rules.
expect(ruleA).not.toBe(ruleDifferentCron);
// Same payload → same rule (so a session approval keeps working).
expect(ruleA).toBe(ruleSameAsA);
// Rule must still start with the tool name so the permission
// layer routes it to the right matcher.
expect(ruleA.startsWith('CronCreate(')).toBe(true);
});
});
describe('cap rechecked inside execute()', () => {
it('refuses insert when the store fills between prepare and execute', async () => {
// Two concurrently prepared CronCreate calls must not be able
// to both pass the cap check at prepare time and then both
// succeed at execute() time, breaching the cap. The first to
// execute wins; the second must observe the live store size and
// refuse.
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
const tool = new CronCreateTool(manager);
// Seed up to one below the cap, then prepare two CronCreate
// calls. Both see length === MAX - 1 < MAX and pass the
// prepare-time gate; the second's execute() must trip the
// re-check after the first executes.
const seedNow = manager.clocks.wallNow();
for (let i = 0; i < MAX_CRON_JOBS_PER_SESSION - 1; i++) {
manager.store.add(
{ cron: '*/5 * * * *', prompt: `seed-${String(i)}`, recurring: true },
seedNow,
);
}
const first = tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'first',
recurring: true,
});
const second = tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'second',
recurring: true,
});
if (isErrorExecution(first) || isErrorExecution(second)) {
throw new Error('expected both to pass prepare-time cap');
}
const ctx = {
turnId: 't',
toolCallId: 'c',
signal: new AbortController().signal,
};
const firstResult = await first.execute(ctx);
const secondResult = await second.execute(ctx);
expect(firstResult.isError ?? false).toBe(false);
expect(secondResult.isError).toBe(true);
expect(secondResult.output).toMatchInlineSnapshot(
`"Cron job cap reached (max 50 per session)."`,
);
expect(manager.store.list()).toHaveLength(MAX_CRON_JOBS_PER_SESSION);
});
});
});

View file

@ -0,0 +1,177 @@
/**
* Tests for `tools/cron/cron-delete.ts`. Pins the report-and-correct
* contract: every code path that would otherwise be a silent no-op
* (missing id, malformed id) reports an error.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CronManager } from '../../../src/agent/cron/manager';
import {
CronDeleteTool,
type CronDeleteInput,
} from '../../../src/tools/cron/cron-delete';
import { CRON_DELETED } from '../../../src/tools/cron/telemetry-events';
import type {
ExecutableToolErrorResult,
ExecutableToolResult,
RunnableToolExecution,
ToolExecution,
} from '../../../src/loop/types';
import {
createAgentStub,
createClocks,
scrubCronOutput,
type AgentStub,
} from '../../agent/cron/harness/stub';
interface Harness {
readonly stub: AgentStub;
readonly manager: CronManager;
readonly tool: CronDeleteTool;
}
function makeHarness(): Harness {
const stub = createAgentStub();
const manager = new CronManager(stub.agent, {
clocks: createClocks().clocks,
pollIntervalMs: null,
});
const tool = new CronDeleteTool(manager);
return { stub, manager, tool };
}
async function runTool(
tool: CronDeleteTool,
input: CronDeleteInput,
): Promise<ExecutableToolResult> {
const execution = tool.resolveExecution(input);
if (isErrorExecution(execution)) {
return execution;
}
return execution.execute({
turnId: 'test-turn',
toolCallId: 'test-call',
signal: new AbortController().signal,
});
}
function isErrorExecution(
execution: ToolExecution,
): execution is ExecutableToolErrorResult {
return (execution as RunnableToolExecution).execute === undefined;
}
function assertSuccess(result: ExecutableToolResult): string {
expect(result.isError ?? false).toBe(false);
expect(typeof result.output).toBe('string');
return result.output as string;
}
function assertError(result: ExecutableToolResult): string {
expect(result.isError).toBe(true);
expect(typeof result.output).toBe('string');
return result.output as string;
}
describe('CronDeleteTool', () => {
beforeEach(() => {
// Disable jitter — irrelevant to delete behaviour but keeps the
// manager construction path consistent with the create / list
// tests, in case a later assertion grows to read nextFireAt.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('deletes an existing task, drains the store, and emits cron_deleted', async () => {
const { stub, manager, tool } = makeHarness();
// Seed via the store directly so the test is independent of
// CronCreate's validation surface.
const task = manager.store.add(
{ cron: '*/5 * * * *', prompt: 'hi', recurring: true },
manager.clocks.wallNow(),
);
expect(manager.store.list()).toHaveLength(1);
const out = assertSuccess(await runTool(tool, { id: task.id }));
expect(scrubCronOutput(out)).toMatchInlineSnapshot(
`"Deleted cron job <id>."`,
);
expect(manager.store.list()).toHaveLength(0);
// Exactly one telemetry event, keyed on the deleted id.
expect(stub.telemetryCalls).toHaveLength(1);
expect(stub.telemetryCalls[0]!.event).toBe(CRON_DELETED);
expect(stub.telemetryCalls[0]!.props).toEqual({ task_id: task.id });
// The delete tool never steers — guard against an accidental wiring
// mistake that would inject the prompt at delete time.
expect(stub.steerCalls).toHaveLength(0);
});
it('reports an error when the id is well-formed but absent, with no telemetry', async () => {
const { stub, manager, tool } = makeHarness();
// No tasks seeded — the lookup miss is the path under test.
const msg = assertError(await runTool(tool, { id: '0123abcd' }));
expect(msg).toMatchInlineSnapshot(`"No cron job with id 0123abcd."`);
expect(manager.store.list()).toHaveLength(0);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('rejects an uppercase id (format check, no store mutation)', async () => {
const { stub, manager, tool } = makeHarness();
// Seed a real task so we can confirm the malformed id never reaches
// the store. (The seeded id won't collide with the uppercase one,
// but this guards against a regression that bypasses the format
// check entirely and somehow clears the store.)
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'hi', recurring: true },
manager.clocks.wallNow(),
);
const msg = assertError(await runTool(tool, { id: 'ABCD1234' }));
expect(msg).toMatchInlineSnapshot(
`"Invalid cron job id "ABCD1234" — must be 8 lowercase hex characters."`,
);
expect(manager.store.list()).toHaveLength(1);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('rejects a too-short id', async () => {
const { stub, manager, tool } = makeHarness();
const msg = assertError(await runTool(tool, { id: 'abc' }));
expect(msg).toMatchInlineSnapshot(
`"Invalid cron job id "abc" — must be 8 lowercase hex characters."`,
);
expect(manager.store.list()).toHaveLength(0);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('rejects a non-hex id of the right length', async () => {
const { stub, manager, tool } = makeHarness();
const msg = assertError(await runTool(tool, { id: 'zzzzzzzz' }));
expect(msg).toMatchInlineSnapshot(
`"Invalid cron job id "zzzzzzzz" — must be 8 lowercase hex characters."`,
);
expect(manager.store.list()).toHaveLength(0);
expect(stub.telemetryCalls).toHaveLength(0);
});
it('rejects an empty id', async () => {
const { stub, manager, tool } = makeHarness();
const msg = assertError(await runTool(tool, { id: '' }));
expect(msg).toMatchInlineSnapshot(
`"Invalid cron job id "" — must be 8 lowercase hex characters."`,
);
expect(manager.store.list()).toHaveLength(0);
expect(stub.telemetryCalls).toHaveLength(0);
});
});

View file

@ -0,0 +1,398 @@
/**
* Tests for `tools/cron/cron-expr.ts`. Dates are constructed via
* `new Date(year, monthIndex, day, h, m, s)` so the suite is stable
* regardless of the host TZ (cron expressions evaluate in local time).
*/
import { describe, expect, it } from 'vitest';
import {
computeNextCronRun,
cronToHuman,
hasFireWithinYears,
parseCronExpression,
} from '../../../src/tools/cron/cron-expr';
function localDate(
y: number,
monthIndex: number,
d: number,
h = 0,
m = 0,
s = 0,
): number {
return new Date(y, monthIndex, d, h, m, s, 0).getTime();
}
function nextLocalParts(ts: number) {
const d = new Date(ts);
return {
year: d.getFullYear(),
month: d.getMonth() + 1,
day: d.getDate(),
hour: d.getHours(),
minute: d.getMinutes(),
second: d.getSeconds(),
dow: d.getDay(),
};
}
describe('parseCronExpression', () => {
it('parses wildcard', () => {
const p = parseCronExpression('* * * * *');
expect(p.minutes.size).toBe(60);
expect(p.hours.size).toBe(24);
expect(p.daysOfMonth.size).toBe(31);
expect(p.months.size).toBe(12);
expect(p.daysOfWeek.size).toBe(7);
expect(p.daysOfMonthWildcard).toBe(true);
expect(p.daysOfWeekWildcard).toBe(true);
});
it('parses single integers', () => {
const p = parseCronExpression('5 9 1 6 3');
expect([...p.minutes]).toEqual([5]);
expect([...p.hours]).toEqual([9]);
expect([...p.daysOfMonth]).toEqual([1]);
expect([...p.months]).toEqual([6]);
expect([...p.daysOfWeek]).toEqual([3]);
expect(p.daysOfMonthWildcard).toBe(false);
expect(p.daysOfWeekWildcard).toBe(false);
});
it('parses ranges', () => {
const p = parseCronExpression('0 9-17 * * 1-5');
expect([...p.hours].toSorted((a, b) => a - b)).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
expect([...p.daysOfWeek].toSorted((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
});
it('parses lists', () => {
const p = parseCronExpression('0 9,12,17 * * *');
expect([...p.hours].toSorted((a, b) => a - b)).toEqual([9, 12, 17]);
});
it('parses step with wildcard', () => {
const p = parseCronExpression('*/5 * * * *');
expect([...p.minutes].toSorted((a, b) => a - b)).toEqual([
0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55,
]);
});
it('parses step with range', () => {
const p = parseCronExpression('0-30/10 * * * *');
expect([...p.minutes].toSorted((a, b) => a - b)).toEqual([0, 10, 20, 30]);
});
it('folds day-of-week 7 to 0 (Sunday)', () => {
const p = parseCronExpression('0 0 * * 7');
expect([...p.daysOfWeek]).toEqual([0]);
});
it('treats bare * as wildcard but */n as restriction', () => {
const a = parseCronExpression('* * * * *');
const b = parseCronExpression('*/5 * * * *');
expect(a.daysOfMonthWildcard).toBe(true);
expect(b.daysOfMonthWildcard).toBe(true);
});
it('throws on too few fields', () => {
expect(() => parseCronExpression('* * * *')).toThrow(/5 fields/);
});
it('throws on too many fields', () => {
expect(() => parseCronExpression('* * * * * *')).toThrow(/5 fields/);
});
it('throws on empty input', () => {
expect(() => parseCronExpression('')).toThrow(/empty/);
expect(() => parseCronExpression(' ')).toThrow(/empty/);
});
it('throws on out-of-range minute', () => {
expect(() => parseCronExpression('60 * * * *')).toThrow(/minute/);
});
it('throws on out-of-range hour', () => {
expect(() => parseCronExpression('0 24 * * *')).toThrow(/hour/);
});
it('throws on out-of-range day-of-month', () => {
expect(() => parseCronExpression('0 0 32 * *')).toThrow(/day-of-month/);
});
it('throws on out-of-range month', () => {
expect(() => parseCronExpression('0 0 * 13 *')).toThrow(/month/);
});
it('throws on out-of-range day-of-week', () => {
expect(() => parseCronExpression('0 0 * * 8')).toThrow(/day-of-week/);
});
it('throws on non-integer step', () => {
expect(() => parseCronExpression('*/x * * * *')).toThrow(/step/);
});
it('throws on zero step', () => {
expect(() => parseCronExpression('*/0 * * * *')).toThrow(/step/);
});
it('throws on descending range', () => {
expect(() => parseCronExpression('5-1 * * * *')).toThrow(/range/);
});
it('throws on empty list term', () => {
expect(() => parseCronExpression('1,,3 * * * *')).toThrow(/empty term/);
});
});
describe('rejects malformed numeric tokens', () => {
it('rejects negative range lower bound (-5 * * * *)', () => {
expect(() => parseCronExpression('-5 * * * *')).toThrow(/digits only|non-negative integer/);
});
it('rejects scientific notation (1e1 * * * *)', () => {
expect(() => parseCronExpression('1e1 * * * *')).toThrow(/digits only|non-negative integer/);
});
it('rejects hex notation (0x10 * * * *)', () => {
expect(() => parseCronExpression('0x10 * * * *')).toThrow(/digits only|non-negative integer/);
});
it('rejects leading-plus (+5 * * * *)', () => {
expect(() => parseCronExpression('+5 * * * *')).toThrow(/digits only|non-negative integer/);
});
it('rejects scientific notation in step (*/1e1 * * * *)', () => {
expect(() => parseCronExpression('*/1e1 * * * *')).toThrow(/digits only|non-negative integer/);
});
it('rejects hex notation in step (*/0x10 * * * *)', () => {
expect(() => parseCronExpression('*/0x10 * * * *')).toThrow(/digits only|non-negative integer/);
});
it('rejects scientific notation in range (1-1e1 * * * *)', () => {
expect(() => parseCronExpression('1-1e1 * * * *')).toThrow(/digits only|non-negative integer/);
});
it('rejects scientific notation in range lower bound (1e1-5 * * * *)', () => {
// Symmetric coverage of the lo path — hi was covered by the
// 1-1e1 case above. Both lo and hi go through parseCronInt.
expect(() => parseCronExpression('1e1-5 * * * *')).toThrow(/digits only|non-negative integer/);
});
it('still accepts plain integers, ranges, lists, and steps', () => {
expect(() => parseCronExpression('5 * * * *')).not.toThrow();
expect(() => parseCronExpression('1-5 * * * *')).not.toThrow();
expect(() => parseCronExpression('1,5,10 * * * *')).not.toThrow();
expect(() => parseCronExpression('*/5 * * * *')).not.toThrow();
expect(() => parseCronExpression('1-30/5 * * * *')).not.toThrow();
});
});
describe('computeNextCronRun', () => {
it('*/5 — from xx:00:30 advances to xx:05:00', () => {
const expr = parseCronExpression('*/5 * * * *');
const from = localDate(2024, 5, 1, 12, 0, 30);
const next = computeNextCronRun(expr, from);
expect(next).not.toBeNull();
const p = nextLocalParts(next!);
expect(p.year).toBe(2024);
expect(p.month).toBe(6);
expect(p.day).toBe(1);
expect(p.hour).toBe(12);
expect(p.minute).toBe(5);
expect(p.second).toBe(0);
});
it('*/5 — from xx:00:00 advances strictly to xx:05:00, never xx:00:00', () => {
const expr = parseCronExpression('*/5 * * * *');
const from = localDate(2024, 5, 1, 12, 0, 0);
const next = computeNextCronRun(expr, from);
expect(next).not.toBeNull();
expect(next!).toBeGreaterThan(from);
const p = nextLocalParts(next!);
expect(p.minute).toBe(5);
});
it('0 9 * * * — from 08:00 advances to 09:00 same day', () => {
const expr = parseCronExpression('0 9 * * *');
const from = localDate(2024, 5, 1, 8, 0, 0);
const next = computeNextCronRun(expr, from);
const p = nextLocalParts(next!);
expect(p.day).toBe(1);
expect(p.hour).toBe(9);
expect(p.minute).toBe(0);
});
it('0 9 * * 1-5 — from Saturday 09:00 → next Monday 09:00', () => {
const expr = parseCronExpression('0 9 * * 1-5');
// 2024-06-01 is a Saturday.
const sat = new Date(2024, 5, 1, 9, 0, 0, 0);
expect(sat.getDay()).toBe(6);
const next = computeNextCronRun(expr, sat.getTime());
const p = nextLocalParts(next!);
expect(p.dow).toBe(1); // Monday
expect(p.day).toBe(3); // 2024-06-03
expect(p.hour).toBe(9);
expect(p.minute).toBe(0);
});
it('0 12 1 1 * — from mid-year advances to next Jan 1 12:00', () => {
const expr = parseCronExpression('0 12 1 1 *');
const from = localDate(2024, 5, 1, 0, 0, 0);
const next = computeNextCronRun(expr, from);
const p = nextLocalParts(next!);
expect(p.year).toBe(2025);
expect(p.month).toBe(1);
expect(p.day).toBe(1);
expect(p.hour).toBe(12);
});
it('0 0 31 2 * — Feb has no day 31 → null', () => {
const expr = parseCronExpression('0 0 31 2 *');
const from = localDate(2024, 0, 1, 0, 0, 0);
expect(computeNextCronRun(expr, from)).toBeNull();
});
it('29 2 — Feb 29 fires on leap years', () => {
// `0 0 29 2 *` — every Feb 29 midnight.
const expr = parseCronExpression('0 0 29 2 *');
const from = localDate(2023, 0, 1, 0, 0, 0);
const next = computeNextCronRun(expr, from);
const p = nextLocalParts(next!);
expect(p.year).toBe(2024);
expect(p.month).toBe(2);
expect(p.day).toBe(29);
});
it('cron-style OR: 0 0 1 * 1 fires on every 1st AND every Monday', () => {
const expr = parseCronExpression('0 0 1 * 1');
// Sample a few fires across a couple months. Starting Jun 1 2024 (Sat).
let cur = localDate(2024, 5, 1, 0, 0, 0) - 1;
const fires: { date: Date; dow: number; dom: number }[] = [];
for (let i = 0; i < 12; i++) {
const n = computeNextCronRun(expr, cur);
expect(n).not.toBeNull();
const d = new Date(n!);
fires.push({ date: d, dow: d.getDay(), dom: d.getDate() });
cur = n!;
}
// Every fire must be either a Monday OR the 1st of a month.
for (const f of fires) {
const isMonday = f.dow === 1;
const isFirst = f.dom === 1;
expect(isMonday || isFirst).toBe(true);
}
// We must see at least one of each (Monday and non-Monday 1st).
expect(fires.some((f) => f.dow === 1 && f.dom !== 1)).toBe(true);
expect(fires.some((f) => f.dom === 1)).toBe(true);
});
it('returns strictly greater than fromMs', () => {
const expr = parseCronExpression('* * * * *');
const from = localDate(2024, 5, 1, 12, 30, 45);
const next = computeNextCronRun(expr, from);
expect(next).not.toBeNull();
expect(next!).toBeGreaterThan(from);
});
it('DST transition still advances and never returns < fromMs', () => {
// Pick a date around the US DST transition (2024-03-10 spring
// forward in America/New_York). The test process TZ may not be
// NY, but the invariant "monotonic forward" must hold in any TZ.
const expr = parseCronExpression('0 * * * *');
const from = localDate(2024, 2, 10, 0, 0, 0);
let cur = from;
let prev = from;
for (let i = 0; i < 48; i++) {
const n = computeNextCronRun(expr, cur);
expect(n).not.toBeNull();
expect(n!).toBeGreaterThan(prev);
prev = cur;
cur = n!;
}
});
});
describe('hasFireWithinYears', () => {
it('0 0 31 2 * → false within 5 years', () => {
const expr = parseCronExpression('0 0 31 2 *');
expect(hasFireWithinYears(expr, 5, localDate(2024, 0, 1))).toBe(false);
});
it('0 12 1 1 * → true within 5 years', () => {
const expr = parseCronExpression('0 12 1 1 *');
expect(hasFireWithinYears(expr, 5, localDate(2024, 0, 1))).toBe(true);
});
it('* * * * * → true within any nonzero window', () => {
const expr = parseCronExpression('* * * * *');
expect(hasFireWithinYears(expr, 1, localDate(2024, 0, 1))).toBe(true);
});
it('computeNextCronRun returns null fast for never-firing 0 0 30 2 *', () => {
// Feb 30 never exists. Without a wall-time deadline this can scan
// tens of thousands of years before bailing. The fix bounds the
// search by candidate-date wall time, so this completes in < 500ms
// on any sane host.
const expr = parseCronExpression('0 0 30 2 *');
const start = performance.now();
const result = computeNextCronRun(expr, localDate(2024, 0, 1));
const elapsedMs = performance.now() - start;
expect(result).toBeNull();
expect(elapsedMs).toBeLessThan(500);
});
it('hasFireWithinYears returns false fast for never-firing 0 0 30 2 *', () => {
const expr = parseCronExpression('0 0 30 2 *');
const start = performance.now();
const result = hasFireWithinYears(expr, 5, localDate(2024, 0, 1));
const elapsedMs = performance.now() - start;
expect(result).toBe(false);
expect(elapsedMs).toBeLessThan(500);
});
it('hasFireWithinYears respects custom year windows around fire boundary', () => {
// Anchor: Jan 1 2024 at midnight. The expression `0 0 1 1 *` fires
// at Jan 1 of each year. Within 5 years we will see 5 fires
// (2025..2029). With a 0.5-year window starting after Jan 1 we see
// nothing until Jan 1 of the next year.
const expr = parseCronExpression('0 0 1 1 *');
const fromInsideYear = localDate(2024, 5, 1); // mid-2024
expect(hasFireWithinYears(expr, 5, fromInsideYear)).toBe(true);
// A window that ends before the next Jan 1 must return false.
// Jun 1 → ~7 months to Jan 1 2025; 0.5 years ≈ 6 months → false.
expect(hasFireWithinYears(expr, 0.5, fromInsideYear)).toBe(false);
});
});
describe('cronToHuman', () => {
it('every minute', () => {
expect(cronToHuman(parseCronExpression('* * * * *'))).toBe('every minute');
});
it('every 5 minutes', () => {
expect(cronToHuman(parseCronExpression('*/5 * * * *'))).toBe('every 5 minutes');
});
it('at HH:MM every day', () => {
expect(cronToHuman(parseCronExpression('0 9 * * *'))).toBe('at 09:00 every day');
expect(cronToHuman(parseCronExpression('30 14 * * *'))).toBe('at 14:30 every day');
});
it('weekdays / weekends shortcut', () => {
expect(cronToHuman(parseCronExpression('0 9 * * 1-5'))).toBe('at 09:00 on weekdays');
expect(cronToHuman(parseCronExpression('0 10 * * 0,6'))).toBe('at 10:00 on weekends');
});
it('at HH:MM on day N of <month>', () => {
expect(cronToHuman(parseCronExpression('0 12 1 1 *'))).toBe('at 12:00 on day 1 of January');
});
it('every N hours', () => {
expect(cronToHuman(parseCronExpression('0 */6 * * *'))).toBe('every 6 hours at minute 00');
});
it('falls back to raw expression for weird patterns', () => {
expect(cronToHuman(parseCronExpression('1,7,23 5,17 * * *'))).toBe('1,7,23 5,17 * * *');
});
});

View file

@ -0,0 +1,92 @@
/**
* Tests for `tools/cron/cron-fire-xml.ts`. Cover the attribute envelope,
* recurring vs one-shot rendering, attribute escaping, and verbatim
* prompt-body handling (including multi-line and quote content).
*/
import { describe, expect, it } from 'vitest';
import type { CronJobOrigin } from '../../../src/agent/context/types';
import { renderCronFireXml } from '../../../src/tools/cron/cron-fire-xml';
describe('renderCronFireXml', () => {
it('recurring origin renders all 5 attributes in order with prompt wrapped', () => {
const origin: CronJobOrigin = {
kind: 'cron_job',
jobId: 'deadbeef',
cron: '*/5 * * * *',
recurring: true,
coalescedCount: 3,
stale: false,
};
const out = renderCronFireXml(origin, 'check the deploy');
expect(out).toMatchInlineSnapshot(`
"<cron-fire jobId="deadbeef" cron="*/5 * * * *" recurring="true" coalescedCount="3" stale="false">
<prompt>
check the deploy
</prompt>
</cron-fire>"
`);
});
it('one-shot origin renders recurring="false", coalescedCount="1", stale="false"', () => {
const origin: CronJobOrigin = {
kind: 'cron_job',
jobId: 'cafebabe',
cron: '30 14 28 2 *',
recurring: false,
coalescedCount: 1,
stale: false,
};
const out = renderCronFireXml(origin, 'one-shot ping');
expect(out).toContain(
'<cron-fire jobId="cafebabe" cron="30 14 28 2 *" recurring="false" coalescedCount="1" stale="false">',
);
expect(out).toContain('<prompt>\none-shot ping\n</prompt>');
});
it('escapes `&` and `"` in attribute values; leaves prompt body verbatim', () => {
const origin: CronJobOrigin = {
kind: 'cron_job',
jobId: 'job&"id',
cron: '0 9 * * *',
recurring: true,
coalescedCount: 1,
stale: false,
};
const out = renderCronFireXml(origin, 'has a " quote and an & ampersand');
// Attribute value escapes `&` and `"`.
expect(out).toContain('jobId="job&amp;&quot;id"');
// Body is verbatim — no escaping of `"` or `&`.
expect(out).toContain('has a " quote and an & ampersand');
expect(out).not.toContain('&quot; quote');
});
it('preserves newlines in multi-line prompt body', () => {
const origin: CronJobOrigin = {
kind: 'cron_job',
jobId: 'aaaabbbb',
cron: '0 * * * *',
recurring: true,
coalescedCount: 1,
stale: false,
};
const prompt = 'line one\nline two\n\nline four';
const out = renderCronFireXml(origin, prompt);
expect(out).toContain(`<prompt>\n${prompt}\n</prompt>`);
});
it('renders stale="true" when origin.stale is true', () => {
const origin: CronJobOrigin = {
kind: 'cron_job',
jobId: 'staleone',
cron: '0 9 * * *',
recurring: true,
coalescedCount: 5,
stale: true,
};
const out = renderCronFireXml(origin, 'morning report');
expect(out).toContain('stale="true"');
expect(out).toContain('coalescedCount="5"');
});
});

View file

@ -0,0 +1,381 @@
/**
* Tests for `tools/cron/cron-list.ts`.
*
* Tasks are seeded via `manager.store.add(...)` so we can exercise
* corners CronCreate's validator would never let through (notably the
* malformed-cron path).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CronManager } from '../../../src/agent/cron/manager';
import {
CronListTool,
type CronListInput,
} from '../../../src/tools/cron/cron-list';
import type {
ExecutableToolErrorResult,
ExecutableToolResult,
RunnableToolExecution,
ToolExecution,
} from '../../../src/loop/types';
import {
createAgentStub,
createClocks,
scrubCronOutput,
WALL_ANCHOR,
type AgentStub,
} from '../../agent/cron/harness/stub';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
interface Harness {
readonly stub: AgentStub;
readonly manager: CronManager;
readonly tool: CronListTool;
}
function makeHarness(wall = WALL_ANCHOR): Harness {
const stub = createAgentStub();
const manager = new CronManager(stub.agent, {
clocks: createClocks(wall).clocks,
pollIntervalMs: null,
});
const tool = new CronListTool(manager);
return { stub, manager, tool };
}
async function runTool(
tool: CronListTool,
input: CronListInput,
): Promise<ExecutableToolResult> {
const execution = tool.resolveExecution(input);
if (isErrorExecution(execution)) {
return execution;
}
return execution.execute({
turnId: 'test-turn',
toolCallId: 'test-call',
signal: new AbortController().signal,
});
}
function isErrorExecution(
execution: ToolExecution,
): execution is ExecutableToolErrorResult {
return (execution as RunnableToolExecution).execute === undefined;
}
function assertSuccess(result: ExecutableToolResult): string {
expect(result.isError ?? false).toBe(false);
expect(typeof result.output).toBe('string');
return result.output as string;
}
describe('CronListTool', () => {
beforeEach(() => {
// Disable jitter so `nextFireAt` is the unmodified ideal — keeps
// the one-shot-on-`:00` assertion bisectable without needing to
// reason about a deterministic-but-task-id-dependent offset.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('renders the empty case with a zero header and no separator', async () => {
const { tool } = makeHarness();
const out = assertSuccess(await runTool(tool, {}));
expect(out).toMatchInlineSnapshot(`
"cron_jobs: 0
No cron jobs scheduled."
`);
});
it('renders a single recurring task with all expected columns', async () => {
const { manager, tool } = makeHarness();
const nowMs = manager.clocks.wallNow();
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'hi', recurring: true },
nowMs,
);
const out = assertSuccess(await runTool(tool, {}));
// ageDays is exactly 0.00 (we set createdAt = wallNow); stale is
// false (recurring, only 0 days old). id + nextFireAt are scrubbed.
expect(scrubCronOutput(out)).toMatchInlineSnapshot(`
"cron_jobs: 1
id: <id>
cron: */5 * * * *
humanSchedule: every 5 minutes
prompt: "hi"
nextFireAt: <iso>
recurring: true
ageDays: 0.00
stale: false"
`);
});
it('separates multiple records with \\n---\\n in insertion order', async () => {
const { manager, tool } = makeHarness();
const nowMs = manager.clocks.wallNow();
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'first', recurring: true },
nowMs,
);
manager.store.add(
{ cron: '0 12 * * *', prompt: 'second', recurring: false },
nowMs,
);
const out = assertSuccess(await runTool(tool, {}));
expect(scrubCronOutput(out)).toMatchInlineSnapshot(`
"cron_jobs: 2
id: <id>
cron: */5 * * * *
humanSchedule: every 5 minutes
prompt: "first"
nextFireAt: <iso>
recurring: true
ageDays: 0.00
stale: false
---
id: <id>
cron: 0 12 * * *
humanSchedule: at 12:00 every day
prompt: "second"
nextFireAt: <iso>
recurring: false
ageDays: 0.00
stale: false"
`);
});
it('flags a recurring task older than 7 days as stale', async () => {
// Anchor "now" at WALL_ANCHOR; seed the task with createdAt 8
// days earlier so age > 7 d crosses the stale threshold.
const { manager, tool } = makeHarness();
const eightDaysAgo = WALL_ANCHOR - 8 * MS_PER_DAY;
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'old', recurring: true },
eightDaysAgo,
);
const out = assertSuccess(await runTool(tool, {}));
// ageDays formatted via toFixed(2) is deterministic at 8.00.
expect(scrubCronOutput(out)).toMatchInlineSnapshot(`
"cron_jobs: 1
id: <id>
cron: */5 * * * *
humanSchedule: every 5 minutes
prompt: "old"
nextFireAt: <iso>
recurring: true
ageDays: 8.00
stale: true"
`);
});
it('reports recurring=false for one-shots; jittered nextFireAt is at-or-before the ideal', async () => {
// KIMI_CRON_NO_JITTER is set in beforeEach, so the jittered value
// equals the ideal: that satisfies "at-or-before" without making
// the test sensitive to the per-task deterministic offset.
const { manager, tool } = makeHarness();
const nowMs = manager.clocks.wallNow();
manager.store.add(
{ cron: '0 12 * * *', prompt: 'noon', recurring: false },
nowMs,
);
const out = assertSuccess(await runTool(tool, {}));
// Parse the rendered nextFireAt and confirm it is at-or-before
// the next ideal noon following `nowMs`. The snapshot scrubs the
// exact timestamp; this assertion guards the at-or-before bound.
const match = /^nextFireAt: (.+)$/m.exec(out);
expect(match).not.toBeNull();
const renderedMs = Date.parse(match![1]!);
expect(Number.isFinite(renderedMs)).toBe(true);
const expected = new Date(nowMs);
expected.setSeconds(0, 0);
expected.setMinutes(0);
expected.setHours(12);
if (expected.getTime() <= nowMs) {
expected.setDate(expected.getDate() + 1);
}
expect(renderedMs).toBeLessThanOrEqual(expected.getTime());
expect(scrubCronOutput(out)).toMatchInlineSnapshot(`
"cron_jobs: 1
id: <id>
cron: 0 12 * * *
humanSchedule: at 12:00 every day
prompt: "noon"
nextFireAt: <iso>
recurring: false
ageDays: 0.00
stale: false"
`);
});
it('renders malformed cron as raw / fallback humanSchedule / null nextFireAt without throwing', async () => {
const { manager, tool } = makeHarness();
// `store.add` does NOT validate — that's the seam we're using to
// simulate "this slipped past CronCreate".
const nowMs = manager.clocks.wallNow();
manager.store.add(
{ cron: 'garbage', prompt: 'x', recurring: true },
nowMs,
);
const out = assertSuccess(await runTool(tool, {}));
expect(scrubCronOutput(out)).toMatchInlineSnapshot(`
"cron_jobs: 1
id: <id>
cron: garbage
humanSchedule: garbage
prompt: "x"
nextFireAt: null
recurring: true
ageDays: 0.00
stale: false"
`);
});
it('one-shot nextFireAt is anchored at createdAt, not nowMs (pending todays slot)', async () => {
// Scenario from the Codex review: a daily one-shot scheduled for
// 12:00 that the agent could not yet deliver (busy turn, manual
// tick mode) and is listed 5 minutes after the ideal slot. The
// scheduler will still fire today's 12:00 slot from createdAt, so
// CronList must report today's 12:00 — not tomorrow's — to stay
// consistent with the pending work. Listing tomorrow would teach
// the LLM that today's reminder has shipped, even though the
// scheduler is still planning to deliver it.
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
const tool = new CronListTool(manager);
// Anchor "createdAt" at 11:55 local, then advance "now" to 12:05
// local without ticking. The scheduler hasn't fired yet (this
// test never calls tick); the list must report 12:00 today.
const today1155 = new Date();
today1155.setHours(11, 55, 0, 0);
harness.setNow(today1155.getTime());
const createdAt = harness.now();
manager.store.add(
{ cron: '0 12 * * *', prompt: 'noon-pending', recurring: false },
createdAt,
);
harness.advance(10 * 60_000); // now = 12:05
// Build the expected today-12:00 ISO from the same local TZ the
// tool will render in.
const expectedToday12 = new Date(today1155);
expectedToday12.setHours(12, 0, 0, 0);
const out = assertSuccess(await runTool(tool, {}));
const match = /^nextFireAt: (.+)$/m.exec(out);
expect(match).not.toBeNull();
const renderedMs = Date.parse(match![1]!);
expect(renderedMs).toBe(expectedToday12.getTime());
});
it('nextFireAt for a recurring task in its pending jitter window is the current period, not next period', async () => {
// C1 regression: when the ideal slot is past but the jittered
// delivery is still pending, CronList must report the pending
// slot — otherwise the model is told the fire is one period away
// when the scheduler will actually deliver inside the jitter cap.
vi.unstubAllEnvs();
vi.stubEnv('KIMI_CRON_NO_STALE', '1');
const stub = createAgentStub();
const harness = createClocks();
const manager = new CronManager(stub.agent, {
clocks: harness.clocks,
pollIntervalMs: null,
});
const tool = new CronListTool(manager);
// Anchor at a sharp 5-minute boundary so the next ideal is exactly
// 5 min later — keeps the assertion bound simple regardless of
// any internal task-id offset (jitter cap is 30 s on a 5-min job).
const anchor = new Date();
anchor.setSeconds(0, 0);
anchor.setMinutes(Math.floor(anchor.getMinutes() / 5) * 5);
harness.setNow(anchor.getTime());
manager.store.add(
{ cron: '*/5 * * * *', prompt: 'pending-jitter', recurring: true },
harness.now(),
);
// Advance 5 min + 1 s — past the next ideal but inside the 30 s
// jitter cap.
harness.advance(5 * 60_000 + 1_000);
const out = assertSuccess(await runTool(tool, {}));
const match = /^nextFireAt: (.+)$/m.exec(out);
expect(match).not.toBeNull();
const renderedMs = Date.parse(match![1]!);
const now = harness.now();
// Rendered fire must be in the *current* jittered window — within
// ~30 s of now — not the next-period skip (≥ 4 min ahead).
expect(renderedMs - now).toBeGreaterThanOrEqual(0);
expect(renderedMs - now).toBeLessThanOrEqual(60_000);
});
it('truncates prompts longer than 200 UTF-8 bytes with a …(truncated) marker', async () => {
// Explicit assertions instead of a snapshot: keeps the truncation
// boundary visible in the test source rather than buried in a
// mostly-x string.
const { manager, tool } = makeHarness();
const nowMs = manager.clocks.wallNow();
const longPrompt = 'x'.repeat(300);
manager.store.add(
{ cron: '*/5 * * * *', prompt: longPrompt, recurring: true },
nowMs,
);
const out = assertSuccess(await runTool(tool, {}));
const promptMatch = /^prompt: (.+)$/m.exec(out);
expect(promptMatch).not.toBeNull();
const renderedPromptField = promptMatch![1]!;
// JSON-encoded, so closing quote terminates the line.
expect(renderedPromptField.endsWith('…(truncated)"')).toBe(true);
expect(renderedPromptField.length).toBeLessThan(longPrompt.length);
expect(out).toContain('prompt: ');
});
it('walks back to a UTF-8 char boundary when truncating multi-byte prompts', async () => {
// Regression: a naïve `subarray(0, 200)` could split a 3-byte CJK
// sequence and produce a `<60>` replacement char on decode. The
// `0b1100_0000` continuation-byte walk-back must back off to a
// legal char boundary so the rendered preview is valid UTF-8.
const { manager, tool } = makeHarness();
const nowMs = manager.clocks.wallNow();
// `你` is 3 bytes in UTF-8; 100 × 3 = 300 bytes, well past the cap.
const cjkPrompt = '你'.repeat(100);
manager.store.add(
{ cron: '*/5 * * * *', prompt: cjkPrompt, recurring: true },
nowMs,
);
const out = assertSuccess(await runTool(tool, {}));
const promptMatch = /^prompt: (.+)$/m.exec(out);
expect(promptMatch).not.toBeNull();
const rendered = promptMatch![1]!;
expect(rendered.endsWith('…(truncated)"')).toBe(true);
// No replacement char — the walk-back stopped at a legal boundary.
expect(rendered).not.toContain('<27>');
// Inner content is JSON-stringified; strip the outer quotes and
// the trailing `…(truncated)` marker, then verify the remainder
// parses back to a run of `你` chars with no fractional sequence.
const stripped = rendered.replace(/^"|…\(truncated\)"$/g, '');
expect(stripped.length).toBeGreaterThan(0);
for (const ch of stripped) expect(ch).toBe('你');
});
});

View file

@ -0,0 +1,277 @@
/**
* Tests for `tools/cron/jitter.ts`. Fire times are constructed via
* `new Date(y, m, d, h, mn, s)` so minute-of-hour assertions are
* TZ-stable.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { parseCronExpression } from '../../../src/tools/cron/cron-expr';
import {
DEFAULT_CRON_JITTER_CONFIG,
jitteredNextCronRunMs,
oneShotJitteredNextCronRunMs,
} from '../../../src/tools/cron/jitter';
function localDate(
y: number,
monthIndex: number,
d: number,
h = 0,
m = 0,
s = 0,
): number {
return new Date(y, monthIndex, d, h, m, s, 0).getTime();
}
/**
* Two distinct 8-hex ids that produce visibly different jitter
* fractions. `aaaaaaaa` 0.667, `11111111` 0.067, so any cap > a
* few ms yields a separable offset for the "two distinct ids" test.
*/
const ID_A = 'aaaaaaaa';
const ID_B = '11111111';
describe('jitteredNextCronRunMs — recurring', () => {
it('offset for */5 * * * * is within [0, 30s] (10% of 5min period)', () => {
const parsed = parseCronExpression('*/5 * * * *');
const ideal = localDate(2024, 5, 1, 12, 5, 0); // 12:05 local
const jittered = jitteredNextCronRunMs(
{ id: ID_A, cron: '*/5 * * * *', recurring: true },
parsed,
ideal,
);
expect(jittered).toBeGreaterThanOrEqual(ideal);
expect(jittered - ideal).toBeLessThanOrEqual(30_000);
});
it('offset for daily 0 9 * * * is capped at 15min (NOT 10% of 1 day)', () => {
const parsed = parseCronExpression('0 9 * * *');
const ideal = localDate(2024, 5, 1, 9, 0, 0);
const jittered = jitteredNextCronRunMs(
{ id: ID_A, cron: '0 9 * * *', recurring: true },
parsed,
ideal,
);
expect(jittered).toBeGreaterThanOrEqual(ideal);
// 10% of 1 day = 2.4h = 8 640 000 ms, but cap is 15 min.
expect(jittered - ideal).toBeLessThanOrEqual(15 * 60_000);
// And visibly bigger than the */5 case — long-period jobs spread
// further (good for anti-herd at :00).
expect(jittered - ideal).toBeGreaterThan(60_000);
});
it('different ids produce different offsets', () => {
const parsed = parseCronExpression('*/5 * * * *');
const ideal = localDate(2024, 5, 1, 12, 5, 0);
const a = jitteredNextCronRunMs(
{ id: ID_A, cron: '*/5 * * * *', recurring: true },
parsed,
ideal,
);
const b = jitteredNextCronRunMs(
{ id: ID_B, cron: '*/5 * * * *', recurring: true },
parsed,
ideal,
);
expect(a).not.toBe(b);
});
it('deterministic: same inputs → same output across calls', () => {
const parsed = parseCronExpression('0 9 * * *');
const ideal = localDate(2024, 5, 1, 9, 0, 0);
const calls = Array.from({ length: 5 }, () =>
jitteredNextCronRunMs(
{ id: ID_A, cron: '0 9 * * *', recurring: true },
parsed,
ideal,
),
);
for (const v of calls) {
expect(v).toBe(calls[0]);
}
});
it('respects KIMI_CRON_NO_JITTER=1 — no offset', () => {
const prev = process.env['KIMI_CRON_NO_JITTER'];
process.env['KIMI_CRON_NO_JITTER'] = '1';
try {
const parsed = parseCronExpression('*/5 * * * *');
const ideal = localDate(2024, 5, 1, 12, 5, 0);
const jittered = jitteredNextCronRunMs(
{ id: ID_A, cron: '*/5 * * * *', recurring: true },
parsed,
ideal,
);
expect(jittered).toBe(ideal);
} finally {
if (prev === undefined) delete process.env['KIMI_CRON_NO_JITTER'];
else process.env['KIMI_CRON_NO_JITTER'] = prev;
}
});
});
describe('oneShotJitteredNextCronRunMs', () => {
it(':00 → offset within [-90s, 0)', () => {
const ideal = localDate(2024, 5, 1, 14, 0, 0);
const jittered = oneShotJitteredNextCronRunMs({ id: ID_A }, ideal);
expect(jittered - ideal).toBeLessThanOrEqual(0);
expect(jittered - ideal).toBeGreaterThanOrEqual(-90_000);
// ID_A's fraction ≈ 0.667 → expect ~ -60s, not 0.
expect(jittered).toBeLessThan(ideal);
});
it(':30 → offset within [-90s, 0)', () => {
const ideal = localDate(2024, 5, 1, 14, 30, 0);
const jittered = oneShotJitteredNextCronRunMs({ id: ID_A }, ideal);
expect(jittered - ideal).toBeLessThanOrEqual(0);
expect(jittered - ideal).toBeGreaterThanOrEqual(-90_000);
expect(jittered).toBeLessThan(ideal);
});
it(':07 → passthrough, offset = 0', () => {
const ideal = localDate(2024, 5, 1, 14, 7, 0);
const jittered = oneShotJitteredNextCronRunMs({ id: ID_A }, ideal);
expect(jittered).toBe(ideal);
});
it(':15 → passthrough, offset = 0', () => {
const ideal = localDate(2024, 5, 1, 14, 15, 0);
const jittered = oneShotJitteredNextCronRunMs({ id: ID_A }, ideal);
expect(jittered).toBe(ideal);
});
it('mid-minute (non-zero seconds component baked into idealMs) → passthrough', () => {
// Sub-minute granularity means the model didn't pick a round
// wall-clock minute — leave it alone.
const ideal = localDate(2024, 5, 1, 14, 0, 12);
const jittered = oneShotJitteredNextCronRunMs({ id: ID_A }, ideal);
expect(jittered).toBe(ideal);
});
it('deterministic: same id + same ideal → same output', () => {
const ideal = localDate(2024, 5, 1, 14, 0, 0);
const calls = Array.from({ length: 5 }, () =>
oneShotJitteredNextCronRunMs({ id: ID_A }, ideal),
);
for (const v of calls) {
expect(v).toBe(calls[0]);
}
});
it('respects KIMI_CRON_NO_JITTER=1', () => {
const prev = process.env['KIMI_CRON_NO_JITTER'];
process.env['KIMI_CRON_NO_JITTER'] = '1';
try {
const ideal = localDate(2024, 5, 1, 14, 0, 0);
const jittered = oneShotJitteredNextCronRunMs({ id: ID_A }, ideal);
expect(jittered).toBe(ideal);
} finally {
if (prev === undefined) delete process.env['KIMI_CRON_NO_JITTER'];
else process.env['KIMI_CRON_NO_JITTER'] = prev;
}
});
it('skips jitter when budget insufficient — returns idealMs, never earlier', () => {
// Schedule a one-shot at 08:59:30 for `0 9 * * *` with a high-hash
// id. The unclamped pull-forward lands at 08:58:30. A previous
// version clamped to `createdAt` (08:59:30), but the scheduler
// condition `now >= nextFireAt` then fires on the next tick —
// ~29 s before ideal. We skip jitter instead and return idealMs.
const ideal = localDate(2024, 5, 1, 9, 0, 0);
const createdAt = ideal - 30_000; // 08:59:30
const jittered = oneShotJitteredNextCronRunMs(
{ id: 'ffffffff', createdAt },
ideal,
);
expect(jittered).toBeGreaterThanOrEqual(createdAt);
expect(jittered).toBeLessThanOrEqual(ideal);
expect(jittered).toBe(ideal);
});
it('still pulls forward when createdAt leaves enough room', () => {
// 5-minute gap between createdAt and ideal — well past the 90 s
// oneShotMaxMs cap, so the budget is sufficient and the jitter
// applies normally. Regression: the budget-insufficient branch
// must not poison the happy path.
const ideal = localDate(2024, 5, 1, 9, 0, 0);
const createdAt = ideal - 5 * 60_000;
const jittered = oneShotJitteredNextCronRunMs(
{ id: 'ffffffff', createdAt },
ideal,
);
expect(jittered).toBeGreaterThanOrEqual(createdAt);
expect(jittered).toBeLessThan(ideal);
expect(ideal - jittered).toBeLessThanOrEqual(90_000);
});
it('passes through unchanged when createdAt is not provided (legacy callers)', () => {
// Backward-compat: existing test fixtures and external callers
// that don't carry a `createdAt` must keep getting the original
// unclamped behaviour.
const ideal = localDate(2024, 5, 1, 14, 0, 0);
const jittered = oneShotJitteredNextCronRunMs({ id: 'ffffffff' }, ideal);
expect(jittered).toBeLessThanOrEqual(ideal);
expect(ideal - jittered).toBeLessThanOrEqual(90_000);
});
});
describe('config knobs', () => {
it('default config is the documented constants', () => {
expect(DEFAULT_CRON_JITTER_CONFIG.recurringMaxFractionOfPeriod).toBe(0.1);
expect(DEFAULT_CRON_JITTER_CONFIG.recurringMaxMs).toBe(15 * 60_000);
expect(DEFAULT_CRON_JITTER_CONFIG.oneShotMaxMs).toBe(90_000);
});
it('custom oneShotMaxMs caps the pull-forward', () => {
const ideal = localDate(2024, 5, 1, 14, 0, 0);
const jittered = oneShotJitteredNextCronRunMs(
{ id: ID_A },
ideal,
{ ...DEFAULT_CRON_JITTER_CONFIG, oneShotMaxMs: 10_000 },
);
expect(jittered - ideal).toBeGreaterThanOrEqual(-10_000);
expect(jittered - ideal).toBeLessThanOrEqual(0);
});
it('custom recurringMaxMs caps the forward shift', () => {
const parsed = parseCronExpression('0 9 * * *');
const ideal = localDate(2024, 5, 1, 9, 0, 0);
const jittered = jitteredNextCronRunMs(
{ id: ID_A, cron: '0 9 * * *', recurring: true },
parsed,
ideal,
{ ...DEFAULT_CRON_JITTER_CONFIG, recurringMaxMs: 5_000 },
);
expect(jittered - ideal).toBeGreaterThanOrEqual(0);
expect(jittered - ideal).toBeLessThanOrEqual(5_000);
});
});
describe('id hashing fallback', () => {
it('non-hex id still produces a stable fraction', () => {
const parsed = parseCronExpression('*/5 * * * *');
const ideal = localDate(2024, 5, 1, 12, 5, 0);
const a1 = jitteredNextCronRunMs(
{ id: 'non-hex-id!', cron: '*/5 * * * *', recurring: true },
parsed,
ideal,
);
const a2 = jitteredNextCronRunMs(
{ id: 'non-hex-id!', cron: '*/5 * * * *', recurring: true },
parsed,
ideal,
);
expect(a1).toBe(a2);
expect(a1).toBeGreaterThanOrEqual(ideal);
expect(a1 - ideal).toBeLessThanOrEqual(30_000);
});
});
// Belt-and-suspenders: ensure env state we touched is clean after the
// suite (in case a future test in the same vitest worker reads it).
beforeEach(() => {
delete process.env['KIMI_CRON_NO_JITTER'];
});
afterEach(() => {
delete process.env['KIMI_CRON_NO_JITTER'];
});

View file

@ -0,0 +1,53 @@
/**
* Guard: forbid `Date.now()` in cron scheduler-adjacent files.
*
* The natural home for this rule is ESLint `no-restricted-syntax`, but
* oxlint 1.59 does not implement it so we scan source files here
* instead. `clock.ts` is excluded because it is where the wall-clock
* abstraction is *defined*. Non-existent files (P2 additions) are
* skipped so the guard activates automatically when they land.
*/
import { existsSync, readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { dirname, join } from 'pathe';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
// `test/tools/cron/` → package root → `src/tools/cron/`.
const cronSrcDir = join(here, '..', '..', '..', 'src', 'tools', 'cron');
const GUARDED_FILES = [
'scheduler.ts',
'persist.ts',
'lock.ts',
'jitter.ts',
] as const;
// Matches a `Date.now(` call. Word boundary on the `D` side so it
// won't trip on `myDate.now(` or `notDate.now(`; arbitrary whitespace
// between `now` and `(` so `Date.now ()` and `Date . now (` both
// catch. The intent is "the CallExpression `Date.now(...)`" — this
// regex is the cheap proxy for the AST selector we'd use in ESLint.
const DATE_NOW_REGEX = /\bDate\s*\.\s*now\s*\(/;
describe('cron scheduler files do not call Date.now()', () => {
for (const file of GUARDED_FILES) {
it(`${file} contains no Date.now() call`, () => {
const path = join(cronSrcDir, file);
if (!existsSync(path)) {
// File hasn't been added yet (P1/P2 commits introduce
// scheduler.ts, persist.ts, lock.ts). The guard activates
// automatically once they exist.
return;
}
const source = readFileSync(path, 'utf8');
const match = DATE_NOW_REGEX.exec(source);
expect(
match,
`Found \`Date.now()\` in ${file} at offset ${match?.index ?? -1}. ` +
`Use ClockSources.wallNow() instead — direct Date.now() bypasses ` +
`test/bench clock injection. clock.ts is the single legal exception.`,
).toBeNull();
});
}
});

View file

@ -0,0 +1,74 @@
/**
* Tests for `tools/cron/persist`.
*
* The store itself is a thin wrapper around `createPerIdJsonStore`,
* which has its own tests covering the FS contract (atomic writes,
* corrupt-skipping, path-traversal). This file only covers the
* cron-specific shape guard and a single round-trip to confirm the
* wiring.
*/
import { describe, expect, it } from 'vitest';
import { CRON_ID_REGEX, isValidCronTask } from '../../../src/tools/cron/persist';
import type { CronTask } from '../../../src/tools/cron/types';
const validTask: CronTask = {
id: '0123abcd',
cron: '*/5 * * * *',
prompt: 'ping',
createdAt: 1_700_000_000_000,
recurring: true,
};
describe('CRON_ID_REGEX', () => {
it('accepts an 8-char lowercase hex id', () => {
expect(CRON_ID_REGEX.test('00000000')).toBe(true);
expect(CRON_ID_REGEX.test('0123abcd')).toBe(true);
expect(CRON_ID_REGEX.test('ffffffff')).toBe(true);
});
it('rejects non-hex / wrong-length / uppercase ids', () => {
expect(CRON_ID_REGEX.test('0123abc')).toBe(false); // 7 chars
expect(CRON_ID_REGEX.test('0123abcde')).toBe(false); // 9 chars
expect(CRON_ID_REGEX.test('0123ABCD')).toBe(false); // uppercase
expect(CRON_ID_REGEX.test('zzzzzzzz')).toBe(false); // non-hex
expect(CRON_ID_REGEX.test('../etcok')).toBe(false); // path traversal
});
});
describe('isValidCronTask', () => {
it('accepts a fully-specified recurring task', () => {
expect(isValidCronTask(validTask)).toBe(true);
});
it('accepts a task with omitted `recurring` (treated as recurring)', () => {
const { recurring: _omit, ...withoutRecurring } = validTask;
expect(isValidCronTask(withoutRecurring)).toBe(true);
});
it('accepts `recurring: false` (one-shot)', () => {
expect(isValidCronTask({ ...validTask, recurring: false })).toBe(true);
});
it('rejects non-objects', () => {
expect(isValidCronTask(null)).toBe(false);
expect(isValidCronTask(undefined)).toBe(false);
expect(isValidCronTask('hello')).toBe(false);
expect(isValidCronTask(42)).toBe(false);
});
it('rejects ids that fail CRON_ID_REGEX', () => {
expect(isValidCronTask({ ...validTask, id: 'NOT-AN-ID' })).toBe(false);
expect(isValidCronTask({ ...validTask, id: '0123abcde' })).toBe(false);
});
it('rejects missing / wrong-type fields', () => {
const { cron: _c, ...withoutCron } = validTask;
expect(isValidCronTask(withoutCron)).toBe(false);
const { prompt: _p, ...withoutPrompt } = validTask;
expect(isValidCronTask(withoutPrompt)).toBe(false);
expect(isValidCronTask({ ...validTask, createdAt: 'recent' })).toBe(false);
expect(isValidCronTask({ ...validTask, recurring: 'yes' })).toBe(false);
});
});

View file

@ -0,0 +1,618 @@
/**
* Tests for `tools/cron/scheduler.ts`. Time is injected via
* `ClockSources`; `KIMI_CRON_NO_JITTER=1` pins fire counts on the
* recurring tests.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ClockSources } from '../../../src/tools/cron/clock';
import {
createCronScheduler,
type CronScheduler,
} from '../../../src/tools/cron/scheduler';
import type { CronTask } from '../../../src/tools/cron/types';
interface HarnessOptions {
readonly isIdle?: boolean;
readonly isKilled?: boolean;
readonly pollIntervalMs?: number | null;
readonly onFireThrows?: boolean;
}
interface Harness {
readonly scheduler: CronScheduler;
readonly tasks: CronTask[];
readonly fired: Array<{ task: CronTask; coalescedCount: number }>;
readonly removed: string[];
advance(ms: number): void;
setIdle(v: boolean): void;
setKilled(v: boolean): void;
setOnFireThrows(v: boolean): void;
now(): number;
}
// Fixed wall-clock anchor (Nov 14 2023, 22:13:20 UTC). Picked so it
// doesn't sit on any "round" minute mark — the next "every 5 minutes"
// fire lands 4-5 minutes ahead, not exactly 5.
const WALL_ANCHOR = 1_700_000_000_000;
function createHarness(opts: HarnessOptions = {}): Harness {
let now = WALL_ANCHOR;
let mono = 1_000_000;
const clocks: ClockSources = {
wallNow: () => now,
monoNowMs: () => mono,
};
const tasks: CronTask[] = [];
const fired: Array<{ task: CronTask; coalescedCount: number }> = [];
const removed: string[] = [];
let idle = opts.isIdle ?? true;
let killed = opts.isKilled ?? false;
let onFireThrows = opts.onFireThrows ?? false;
const scheduler = createCronScheduler({
clocks,
source: () => tasks,
onFire: (task, ctx) => {
if (onFireThrows) {
throw new Error('onFire boom');
}
fired.push({ task, coalescedCount: ctx.coalescedCount });
},
isIdle: () => idle,
isKilled: () => killed,
removeOneShot: (id) => {
removed.push(id);
const i = tasks.findIndex((t) => t.id === id);
if (i >= 0) tasks.splice(i, 1);
},
// Tests use manual tick() unless they explicitly opt into the timer.
pollIntervalMs: opts.pollIntervalMs ?? null,
});
return {
scheduler,
tasks,
fired,
removed,
advance: (ms: number) => {
now += ms;
mono += ms;
},
setIdle: (v: boolean) => {
idle = v;
},
setKilled: (v: boolean) => {
killed = v;
},
setOnFireThrows: (v: boolean) => {
onFireThrows = v;
},
now: () => now,
};
}
/**
* Tiny helper: 8-hex id deterministic per-call so each test gets a
* stable id without colliding across tasks within a test.
*/
let idCounter = 0;
function nextId(): string {
idCounter++;
return idCounter.toString(16).padStart(8, '0');
}
function makeTask(overrides: Partial<CronTask> & { cron: string; createdAt: number }): CronTask {
return {
id: overrides.id ?? nextId(),
prompt: overrides.prompt ?? 'do the thing',
cron: overrides.cron,
createdAt: overrides.createdAt,
recurring: overrides.recurring,
};
}
const ORIGINAL_ENV_NO_JITTER = process.env['KIMI_CRON_NO_JITTER'];
describe('createCronScheduler — tick behaviour', () => {
beforeEach(() => {
// Pin exact fire times in every test by default. The single test
// that exercises jitter restores the env explicitly.
process.env['KIMI_CRON_NO_JITTER'] = '1';
idCounter = 0;
});
afterEach(() => {
if (ORIGINAL_ENV_NO_JITTER === undefined) {
delete process.env['KIMI_CRON_NO_JITTER'];
} else {
process.env['KIMI_CRON_NO_JITTER'] = ORIGINAL_ENV_NO_JITTER;
}
});
it('recurring task fires once when due', () => {
const h = createHarness();
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
// Not yet due — first call to tick should not fire.
h.scheduler.tick();
expect(h.fired).toHaveLength(0);
// Advance ~6 minutes — past the next */5 boundary regardless of
// where WALL_ANCHOR landed in the minute cycle.
h.advance(6 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.fired[0]!.coalescedCount).toBe(1);
});
it('recurring task coalesces missed fires when scheduler sleeps', () => {
const h = createHarness();
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
// Advance 15 minutes — we should see 3 collapsed ideal fires.
h.advance(15 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.fired[0]!.coalescedCount).toBeGreaterThanOrEqual(3);
expect(h.fired[0]!.coalescedCount).toBeLessThanOrEqual(4);
});
it('one-shot task fires once then is removed', () => {
const h = createHarness();
// createdAt is 1 minute earlier so we can advance past the next
// 12:00 without anchoring needing a precise local-tz computation.
h.tasks.push(
makeTask({
cron: '0 12 * * *',
createdAt: h.now() - 60_000,
recurring: false,
}),
);
const taskId = h.tasks[0]!.id;
// Advance one full day — guaranteed to pass at least one 12:00.
h.advance(25 * 60 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.fired[0]!.coalescedCount).toBe(1);
expect(h.removed).toEqual([taskId]);
expect(h.tasks).toHaveLength(0);
});
it('isIdle=false suppresses fire but does not lose the task', () => {
const h = createHarness({ isIdle: false });
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
// Five minutes pass mid-turn — tick should not fire.
h.advance(6 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(0);
// Turn ends — the next tick picks the missed fire up.
h.setIdle(true);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.fired[0]!.coalescedCount).toBe(1);
});
it('isKilled=true short-circuits even when due and idle', () => {
const h = createHarness({ isKilled: true });
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
h.advance(6 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(0);
// Lifting the killswitch lets the missed fire through.
h.setKilled(false);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
});
it('bad cron expression does not stop other tasks in the same tick', () => {
const h = createHarness();
// Insert a task whose cron string will throw at parse time. We
// bypass any normal "constructor" by pushing a raw object — the
// scheduler must swallow the parse failure for this one task and
// continue processing the rest.
const bad: CronTask = {
id: nextId(),
cron: 'not a cron at all',
prompt: 'bad',
createdAt: h.now(),
recurring: true,
};
const good: CronTask = {
id: nextId(),
cron: '*/5 * * * *',
prompt: 'good',
createdAt: h.now(),
recurring: true,
};
h.tasks.push(bad, good);
h.advance(6 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.fired[0]!.task.id).toBe(good.id);
});
it('only the due task fires when two tasks are scheduled', () => {
const h = createHarness();
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
makeTask({ cron: '0 0 1 1 *', createdAt: h.now(), recurring: true }),
);
const dueId = h.tasks[0]!.id;
h.advance(6 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.fired[0]!.task.id).toBe(dueId);
});
it('recurring fires exactly once per turn even if multiple ideal fires elapse mid-turn', () => {
// Regression for US-15: a long busy turn over the 1-hour boundary
// must collapse into a single fire when idle returns.
const h = createHarness({ isIdle: false });
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
// 17 minutes elapse mid-turn — three ideal fires missed.
h.advance(17 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(0);
h.setIdle(true);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.fired[0]!.coalescedCount).toBeGreaterThanOrEqual(3);
});
it('recurring task whose onFire throws is retried on the next tick', () => {
// C3 regression: a throwing onFire must NOT advance lastSeenAt or
// consume the ideal fire. Otherwise a transient persistence error
// silently drops the reminder.
const h = createHarness({ onFireThrows: true });
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
// Past the next */5 boundary — task is due.
h.advance(6 * 60_000);
h.scheduler.tick();
// Throw swallowed; no delivery recorded.
expect(h.fired).toHaveLength(0);
// Recover and tick again — the same ideal must still be reachable.
h.setOnFireThrows(false);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
// Coalesced from the same just-past slot, not a phantom past
// delivery — the count is the natural 1 (or up to the gap), never
// skipped to a later period.
expect(h.fired[0]!.coalescedCount).toBe(1);
});
it('one-shot whose onFire throws is NOT removed and retries on the next tick', () => {
// C3 regression for one-shots: removeOneShot must be gated on a
// successful delivery so a transient throw doesn't lose the
// reminder entirely.
const h = createHarness({ onFireThrows: true });
h.tasks.push(
makeTask({
cron: '*/5 * * * *',
createdAt: h.now(),
recurring: false,
}),
);
const taskId = h.tasks[0]!.id;
h.advance(6 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(0);
// Critical: still present in the store, not removed.
expect(h.tasks).toHaveLength(1);
expect(h.tasks[0]!.id).toBe(taskId);
expect(h.removed).toEqual([]);
// Recover — delivery succeeds and removeOneShot runs.
h.setOnFireThrows(false);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.removed).toEqual([taskId]);
expect(h.tasks).toHaveLength(0);
});
});
describe('createCronScheduler — getNextFireTime', () => {
beforeEach(() => {
process.env['KIMI_CRON_NO_JITTER'] = '1';
idCounter = 0;
});
afterEach(() => {
if (ORIGINAL_ENV_NO_JITTER === undefined) {
delete process.env['KIMI_CRON_NO_JITTER'];
} else {
process.env['KIMI_CRON_NO_JITTER'] = ORIGINAL_ENV_NO_JITTER;
}
});
it('returns null when there are no tasks', () => {
const h = createHarness();
expect(h.scheduler.getNextFireTime()).toBeNull();
});
it('returns the minimum next-fire across tasks', () => {
const h = createHarness();
// Soonest: every minute. Latest: yearly Jan 1.
h.tasks.push(
makeTask({ cron: '* * * * *', createdAt: h.now(), recurring: true }),
makeTask({ cron: '0 0 1 1 *', createdAt: h.now(), recurring: true }),
);
const next = h.scheduler.getNextFireTime();
expect(next).not.toBeNull();
// The minute-cron's next fire is within 1 minute of now; the
// yearly cron is at least days away. So `next` must be within
// 65s of now.
expect(next! - h.now()).toBeLessThanOrEqual(65_000);
});
});
describe('createCronScheduler — getNextFireForTask', () => {
beforeEach(() => {
process.env['KIMI_CRON_NO_JITTER'] = '1';
idCounter = 0;
});
afterEach(() => {
if (ORIGINAL_ENV_NO_JITTER === undefined) {
delete process.env['KIMI_CRON_NO_JITTER'];
} else {
process.env['KIMI_CRON_NO_JITTER'] = ORIGINAL_ENV_NO_JITTER;
}
});
it('returns null for unknown id', () => {
const h = createHarness();
expect(h.scheduler.getNextFireForTask('00000000')).toBeNull();
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
// Unknown id even when there are other tasks.
expect(h.scheduler.getNextFireForTask('ffffffff')).toBeNull();
});
it('returns the same value getNextFireTime would for a single-task store', () => {
const h = createHarness();
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
const taskId = h.tasks[0]!.id;
const aggregate = h.scheduler.getNextFireTime();
const perTask = h.scheduler.getNextFireForTask(taskId);
expect(perTask).not.toBeNull();
expect(perTask).toBe(aggregate);
});
it('preserves pending jittered slot when ideal is just past', () => {
// C1 load-bearing assertion: an already-past ideal whose jittered
// delivery is still in the future must be returned as the next
// fire, not skipped to the following period.
delete process.env['KIMI_CRON_NO_JITTER'];
const h = createHarness();
// id `ffffffff` → fraction ≈ 1.0 → recurring offset ≈ 30s (10% of
// 5-min period).
h.tasks.push(
makeTask({
id: 'ffffffff',
cron: '*/5 * * * *',
createdAt: h.now(),
recurring: true,
}),
);
const taskId = h.tasks[0]!.id;
// Burn one fire so lastSeenAt advances onto a known 5-min slot;
// from this point the period structure is regular.
h.advance(6 * 60_000 + 30_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
const firedAtIdeal = h.fired[0]!.task; // proves we delivered
// The next ideal sits 5 min after the previous ideal. Capture
// `getNextFireForTask` as the source of truth, then advance just
// past that ideal but BEFORE the jittered +30s delivery.
const idealPlusJitter = h.scheduler.getNextFireForTask(taskId);
expect(idealPlusJitter).not.toBeNull();
expect(firedAtIdeal.id).toBe(taskId);
// Step to 20s past the next ideal: ideal is at (idealPlusJitter -
// 30s); we are now 10s short of the jittered delivery point.
const stepTo = idealPlusJitter! - 10_000;
h.advance(stepTo - h.now());
// Pending current-period slot — should still report the same
// jittered timestamp, NOT skip to the next period.
const next = h.scheduler.getNextFireForTask(taskId);
expect(next).toBe(idealPlusJitter);
// Sanity: that timestamp is in the very near future, not 5 min
// later (which would be the next-period skip we are preventing).
expect(next! - h.now()).toBeLessThanOrEqual(15_000);
expect(next! - h.now()).toBeGreaterThan(0);
});
});
describe('createCronScheduler — start/stop lifecycle', () => {
beforeEach(() => {
process.env['KIMI_CRON_NO_JITTER'] = '1';
idCounter = 0;
});
afterEach(() => {
if (ORIGINAL_ENV_NO_JITTER === undefined) {
delete process.env['KIMI_CRON_NO_JITTER'];
} else {
process.env['KIMI_CRON_NO_JITTER'] = ORIGINAL_ENV_NO_JITTER;
}
});
it('start() with pollIntervalMs=null does not auto-tick', async () => {
const h = createHarness({ pollIntervalMs: null });
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
h.scheduler.start();
h.advance(60 * 60_000);
// Yield so any (unintended) timer would have fired.
await new Promise((resolve) => setTimeout(resolve, 30));
expect(h.fired).toHaveLength(0);
await h.scheduler.stop();
});
it('start() with a small pollIntervalMs wires up the auto-tick', async () => {
const h = createHarness({ pollIntervalMs: 20 });
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
h.advance(6 * 60_000); // task is now due
h.scheduler.start();
// Wait long enough for at least one interval tick to elapse.
await new Promise((resolve) => setTimeout(resolve, 80));
await h.scheduler.stop();
expect(h.fired.length).toBeGreaterThanOrEqual(1);
// Coalesced to a single fire — auto-ticks subsequent to the first
// see lastSeenAt = now and have nothing new to fire.
expect(h.fired[0]!.coalescedCount).toBe(1);
});
it('stop() is idempotent and clears state', async () => {
const h = createHarness({ pollIntervalMs: null });
h.scheduler.start();
await h.scheduler.stop();
await h.scheduler.stop();
// Calling tick() after stop is still safe.
h.scheduler.tick();
expect(h.fired).toHaveLength(0);
});
it('start() is idempotent', () => {
const h = createHarness({ pollIntervalMs: null });
expect(() => {
h.scheduler.start();
h.scheduler.start();
}).not.toThrow();
// No auto-tick (interval is null) → no fires sneak in.
expect(h.fired).toHaveLength(0);
});
});
describe('createCronScheduler — jitter integration', () => {
beforeEach(() => {
delete process.env['KIMI_CRON_NO_JITTER'];
idCounter = 0;
});
afterEach(() => {
if (ORIGINAL_ENV_NO_JITTER === undefined) {
delete process.env['KIMI_CRON_NO_JITTER'];
} else {
process.env['KIMI_CRON_NO_JITTER'] = ORIGINAL_ENV_NO_JITTER;
}
});
it('recurring task with jitter fires after advancing past the cap', () => {
const h = createHarness();
// 30s past 6 minutes guarantees we crossed the ideal + max(10% of
// 5min = 30s) jitter cap.
h.tasks.push(
makeTask({ cron: '*/5 * * * *', createdAt: h.now(), recurring: true }),
);
h.advance(6 * 60_000 + 30_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.fired[0]!.coalescedCount).toBe(1);
});
it('one-shot task always reports coalescedCount=1 even after a long backlog', () => {
// A daily one-shot left un-delivered for a week should still
// report `coalescedCount: 1` — one-shots are removed after a
// single delivery, so multi-occurrence counts are meaningless and
// would mislead the LLM into thinking it missed multiple
// scheduled events.
process.env['KIMI_CRON_NO_JITTER'] = '1';
try {
const h = createHarness();
const task = makeTask({
cron: '0 9 * * *',
createdAt: h.now(),
recurring: false,
});
h.tasks.push(task);
h.advance(7 * 24 * 60 * 60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
expect(h.fired[0]!.coalescedCount).toBe(1);
expect(h.removed).toEqual([task.id]);
} finally {
delete process.env['KIMI_CRON_NO_JITTER'];
}
});
it('does not advance baseline past a not-yet-jittered ideal fire', () => {
// The bot-flagged scenario: when the next ideal fire's jittered
// delivery is still in the future, `countCoalesced` must not
// include it and `lastSeenAt` must not advance past it — otherwise
// the jittered delivery is lost on the next tick. (jitter ON).
//
// Setup: id `ffffffff` → fraction ≈ 1.0 → recurring offset = 10%
// of 5-min period = 30s. After firing the first slot, the next
// ideal is +5 min and its jittered delivery is +5 min 30s.
delete process.env['KIMI_CRON_NO_JITTER'];
const h = createHarness();
h.tasks.push(
makeTask({
id: 'ffffffff',
cron: '*/5 * * * *',
createdAt: h.now(),
recurring: true,
}),
);
// Cross the first jittered fire (6m past anchor + 30s buffer).
h.advance(6 * 60_000 + 30_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
// The next ideal is the very next `*/5` after the first ideal —
// 5 minutes later — with the same 30s jitter offset. Advance just
// past the ideal but short of the jittered delivery; the
// scheduler must NOT fire yet, and must keep the slot reachable.
h.advance(20_000); // now is 20s past the next ideal, still 10s short of jittered
h.scheduler.tick();
expect(h.fired).toHaveLength(1);
// Cross the jittered delivery point — the slot fires now.
h.advance(60_000);
h.scheduler.tick();
expect(h.fired).toHaveLength(2);
expect(h.fired[1]!.coalescedCount).toBe(1);
});
});

View file

@ -0,0 +1,180 @@
/**
* Tests for `tools/cron/session-store.ts`.
*/
import { describe, expect, it } from 'vitest';
import {
SessionCronStore,
type SessionCronTaskInit,
} from '../../../src/tools/cron/session-store';
const ID_REGEX = /^[0-9a-f]{8}$/;
/** Convenience: minimal valid init that varies by suffix so tests can
* tell two tasks apart in `list()` snapshots. */
function makeInit(suffix: string, overrides: Partial<SessionCronTaskInit> = {}): SessionCronTaskInit {
return {
cron: '*/5 * * * *',
prompt: `prompt-${suffix}`,
recurring: true,
...overrides,
};
}
describe('SessionCronStore', () => {
describe('add', () => {
it('returns a task with id matching /^[0-9a-f]{8}$/', () => {
const store = new SessionCronStore();
const task = store.add(makeInit('a'), 1000);
expect(task.id).toMatch(ID_REGEX);
});
it('preserves cron / prompt / recurring from the init', () => {
const store = new SessionCronStore();
const init: SessionCronTaskInit = {
cron: '0 9 * * 1-5',
prompt: 'sync PRs',
recurring: true,
};
const task = store.add(init, 1000);
expect(task.cron).toBe('0 9 * * 1-5');
expect(task.prompt).toBe('sync PRs');
expect(task.recurring).toBe(true);
});
it('sets createdAt to the supplied nowMs (no internal clock read)', () => {
const store = new SessionCronStore();
const task = store.add(makeInit('a'), 1_700_000_000_000);
expect(task.createdAt).toBe(1_700_000_000_000);
});
it('does not consult Date.now()', () => {
// Sentinel: a nowMs deliberately not close to "now" makes
// accidental Date.now() use obvious in createdAt.
const store = new SessionCronStore();
const task = store.add(makeInit('a'), 0);
expect(task.createdAt).toBe(0);
});
it('rapid sequential adds produce distinct ids', () => {
const store = new SessionCronStore();
const ids = new Set<string>();
for (let i = 0; i < 32; i++) {
ids.add(store.add(makeInit(`x${i}`), 1000 + i).id);
}
expect(ids.size).toBe(32);
});
});
describe('get', () => {
it('returns a previously-added task', () => {
const store = new SessionCronStore();
const task = store.add(makeInit('a'), 1000);
expect(store.get(task.id)).toEqual(task);
});
it('returns undefined for an unknown id', () => {
const store = new SessionCronStore();
expect(store.get('deadbeef')).toBeUndefined();
});
});
describe('list', () => {
it('returns tasks in insertion order', () => {
const store = new SessionCronStore();
const t1 = store.add(makeInit('1'), 1000);
const t2 = store.add(makeInit('2'), 1001);
const t3 = store.add(makeInit('3'), 1002);
expect(store.list().map((t) => t.id)).toEqual([t1.id, t2.id, t3.id]);
});
it('returns a fresh array on each call (no aliasing)', () => {
const store = new SessionCronStore();
store.add(makeInit('a'), 1000);
const a = store.list();
const b = store.list();
expect(a).not.toBe(b);
expect(a).toEqual(b);
});
it('mutating the returned array does not affect the store', () => {
const store = new SessionCronStore();
store.add(makeInit('a'), 1000);
const snap = store.list() as unknown as CronTaskLike[];
snap.length = 0;
expect(store.list()).toHaveLength(1);
});
it('returns empty array on a fresh store', () => {
const store = new SessionCronStore();
expect(store.list()).toEqual([]);
});
});
describe('remove', () => {
it('returns only the ids that were actually present', () => {
const store = new SessionCronStore();
const t1 = store.add(makeInit('1'), 1000);
const t2 = store.add(makeInit('2'), 1001);
const removed = store.remove([t1.id, 'missing0', t2.id]);
expect(removed).toEqual([t1.id, t2.id]);
});
it('actually removes the tasks from list / get', () => {
const store = new SessionCronStore();
const t1 = store.add(makeInit('1'), 1000);
store.remove([t1.id]);
expect(store.get(t1.id)).toBeUndefined();
expect(store.list()).toHaveLength(0);
});
it('returns empty array when nothing matches', () => {
const store = new SessionCronStore();
store.add(makeInit('a'), 1000);
expect(store.remove(['ffffffff', 'eeeeeeee'])).toEqual([]);
});
it('preserves insertion order of remaining tasks', () => {
const store = new SessionCronStore();
const t1 = store.add(makeInit('1'), 1000);
const t2 = store.add(makeInit('2'), 1001);
const t3 = store.add(makeInit('3'), 1002);
store.remove([t2.id]);
expect(store.list().map((t) => t.id)).toEqual([t1.id, t3.id]);
});
});
describe('clear', () => {
it('empties the store', () => {
const store = new SessionCronStore();
store.add(makeInit('a'), 1000);
store.add(makeInit('b'), 1001);
store.clear();
expect(store.list()).toEqual([]);
});
it('is a no-op on an already-empty store', () => {
const store = new SessionCronStore();
expect(() => store.clear()).not.toThrow();
expect(store.list()).toEqual([]);
});
});
describe('id uniqueness at scale', () => {
it('256 adds produce 256 unique 8-hex ids', () => {
const store = new SessionCronStore();
const ids = new Set<string>();
for (let i = 0; i < 256; i++) {
const task = store.add(makeInit(`x${i}`), 1000 + i);
expect(task.id).toMatch(ID_REGEX);
ids.add(task.id);
}
expect(ids.size).toBe(256);
expect(store.list()).toHaveLength(256);
});
});
});
/** Local alias for the mutate-snapshot test that needs a writable view
* of `readonly CronTask[]` without dragging in the real CronTask type. */
type CronTaskLike = { id: string };

View file

@ -237,6 +237,35 @@ describe('Plan mode permission policy', () => {
},
);
it('denies CronCreate when plan mode is active', async () => {
const { agent } = await activePlanAgent();
const result = evaluatePlanPolicy(agent, 'CronCreate', {
cron: '*/5 * * * *',
prompt: 'ping',
});
const deny = expectDeny(result);
expect(deny.message ?? '').toContain('CronCreate');
expect(deny.message ?? '').toContain('plan mode');
});
it('denies CronDelete when plan mode is active', async () => {
const { agent } = await activePlanAgent();
const result = evaluatePlanPolicy(agent, 'CronDelete', { id: 'job_1' });
const deny = expectDeny(result);
expect(deny.message ?? '').toContain('CronDelete');
expect(deny.message ?? '').toContain('plan mode');
});
it('allows CronList when plan mode is active', async () => {
const { agent } = await activePlanAgent();
expect(evaluatePlanPolicy(agent, 'CronList', {})).toBeUndefined();
});
it('does not block anything once plan mode has exited', async () => {
const { agent, planMode } = await activePlanAgent();
planMode.exit();

View file

@ -0,0 +1,176 @@
/**
* Tests for the generic per-id JSON record store.
*
* Background/cron tests cover end-to-end behavior with their own task
* shapes; these tests stay shape-agnostic so they exercise the store's
* own invariants (path-traversal, atomic write, corrupt-skipping).
*/
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createPerIdJsonStore } from '../../src/utils/per-id-json-store';
interface Sample {
readonly id: string;
readonly payload: string;
}
const ID_REGEX = /^[a-z0-9]{4}$/;
function isSample(obj: unknown): obj is Sample {
if (typeof obj !== 'object' || obj === null) return false;
const o = obj as Record<string, unknown>;
return (
typeof o['id'] === 'string' &&
ID_REGEX.test(o['id']) &&
typeof o['payload'] === 'string'
);
}
let rootDir: string;
beforeEach(async () => {
rootDir = join(
tmpdir(),
`kimi-per-id-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await mkdir(rootDir, { recursive: true });
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
function newStore() {
return createPerIdJsonStore<Sample>({
rootDir,
subdir: 'things',
idRegex: ID_REGEX,
isValid: isSample,
});
}
describe('createPerIdJsonStore', () => {
it('round-trips a value via write/read', async () => {
const store = newStore();
await store.write('aaaa', { id: 'aaaa', payload: 'hello' });
expect(await store.read('aaaa')).toEqual({ id: 'aaaa', payload: 'hello' });
});
it('read returns undefined for missing files', async () => {
expect(await newStore().read('bbbb')).toBeUndefined();
});
it('write overwrites previous content', async () => {
const store = newStore();
await store.write('aaaa', { id: 'aaaa', payload: 'first' });
await store.write('aaaa', { id: 'aaaa', payload: 'second' });
expect((await store.read('aaaa'))?.payload).toBe('second');
});
it('list enumerates every record by basename', async () => {
const store = newStore();
await store.write('aaaa', { id: 'aaaa', payload: 'a' });
await store.write('bbbb', { id: 'bbbb', payload: 'b' });
const all = await store.list();
expect(all.map((v) => v.id).toSorted()).toEqual(['aaaa', 'bbbb']);
});
it('list returns empty when subdir does not exist', async () => {
expect(await newStore().list()).toEqual([]);
});
it('list silently skips files with invalid basenames', async () => {
const store = newStore();
await store.write('aaaa', { id: 'aaaa', payload: 'good' });
// Stray file whose name fails ID_REGEX
await writeFile(
join(rootDir, 'things', 'NOT-A-VALID-ID.json'),
JSON.stringify({ id: 'aaaa', payload: 'whatever' }),
'utf-8',
);
const all = await store.list();
expect(all).toHaveLength(1);
expect(all[0]?.id).toBe('aaaa');
});
it('list silently skips corrupt JSON', async () => {
const store = newStore();
await store.write('aaaa', { id: 'aaaa', payload: 'good' });
await writeFile(join(rootDir, 'things', 'bbbb.json'), '{not json', 'utf-8');
const all = await store.list();
expect(all.map((v) => v.id)).toEqual(['aaaa']);
});
it('list silently skips records that fail isValid', async () => {
const store = newStore();
await store.write('aaaa', { id: 'aaaa', payload: 'good' });
// Valid JSON, valid basename, but missing `payload`.
await writeFile(
join(rootDir, 'things', 'cccc.json'),
JSON.stringify({ id: 'cccc' }),
'utf-8',
);
const all = await store.list();
expect(all.map((v) => v.id)).toEqual(['aaaa']);
});
it('read returns undefined for files that fail isValid', async () => {
const store = newStore();
await mkdir(join(rootDir, 'things'), { recursive: true });
await writeFile(
join(rootDir, 'things', 'cccc.json'),
JSON.stringify({ id: 'cccc' }),
'utf-8',
);
expect(await store.read('cccc')).toBeUndefined();
});
it('remove deletes the file and is idempotent', async () => {
const store = newStore();
await store.write('aaaa', { id: 'aaaa', payload: 'x' });
await store.remove('aaaa');
expect(await store.read('aaaa')).toBeUndefined();
await expect(store.remove('aaaa')).resolves.toBeUndefined();
});
it('write creates the subdir with mode 0700', async () => {
const store = newStore();
await store.write('aaaa', { id: 'aaaa', payload: 'x' });
const st = await stat(join(rootDir, 'things'));
// eslint-disable-next-line no-bitwise
expect(st.mode & 0o777).toBe(0o700);
});
it('rejects path-traversal ids on write/read/remove', async () => {
const store = newStore();
await expect(
store.write('../etc', { id: '../etc', payload: 'x' }),
).rejects.toThrow(/Invalid id/);
await expect(store.read('../etc')).rejects.toThrow(/Invalid id/);
await expect(store.remove('../etc')).rejects.toThrow(/Invalid id/);
});
it('uses entityName in path-traversal rejection errors', async () => {
const store = createPerIdJsonStore<Sample>({
rootDir,
subdir: 'things',
idRegex: ID_REGEX,
isValid: isSample,
entityName: 'thing id',
});
await expect(
store.write('../etc', { id: '../etc', payload: 'x' }),
).rejects.toThrow(/Invalid thing id: "\.\.\/etc"/);
});
it('read on an unknown id does not create the subdir', async () => {
expect(await newStore().read('dead')).toBeUndefined();
const top = await readdir(rootDir);
expect(top.includes('things')).toBe(false);
});
});