Skip to content

feat(agent-core): add cron tools and scheduler for session-level recurring tasks - #136

Closed
sailist wants to merge 26 commits into
MoonshotAI:mainfrom
sailist:feat/cron-tools
Closed

feat(agent-core): add cron tools and scheduler for session-level recurring tasks#136
sailist wants to merge 26 commits into
MoonshotAI:mainfrom
sailist:feat/cron-tools

Conversation

@sailist

@sailist sailist commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR introduces a complete cron subsystem in agent-core that enables agents to schedule, list, and delete recurring tasks within a session. It includes a 5-field cron expression parser, deterministic jitter, pluggable clock sources, an in-memory scheduler, three new builtin tools (cron_create, cron_list, cron_delete), and full Agent integration via CronManager. The cron tools are now enabled in the default agent profile.


1. Cron expression parsing and scheduling engine

Problem: There was no way for agents to manage recurring tasks within a session.

What was done:

  • Added a 5-field cron expression parser (cron-expr.ts) supporting standard cron syntax.
  • Implemented pluggable ClockSources (clock.ts) for testability and time abstraction.
  • Added deterministic jitter (jitter.ts) to prevent thundering-herd patterns.
  • Built an in-memory CronScheduler (scheduler.ts) with session-scoped task execution.
  • Added SessionCronStore (session-store.ts) for persisting cron tasks in memory per session.

2. Cron tools for agent interaction

Problem: Agents needed a programmatic interface to manage cron tasks.

What was done:

  • Created CronCreate tool (cron-create.ts) with markdown documentation for scheduling new tasks.
  • Created CronList tool (cron-list.ts) for querying active and historical cron jobs.
  • Created CronDelete tool (cron-delete.ts) for canceling scheduled tasks.
  • Added telemetry event name constants (telemetry-events.ts) for cron operations.

3. Agent integration and lifecycle management

Problem: The cron subsystem needed to be wired into the Agent lifecycle and profile system.

What was done:

  • Added CronManager (manager.ts) as the integration layer between the Agent and the cron scheduler.
  • Wired CronManager and cron tools into the Agent constructor and tool registry.
  • Enabled cron tools in the default agent profile (default/agent.yaml).
  • Added manual-tick environment support and a SIGUSR1 bench hook for testing.
  • Removed the durable flag and environment-based clock source from the final refactor for simplicity.

4. Comprehensive test coverage

Problem: The new cron subsystem required validation across all layers.

What was done:

  • Added unit tests for clock sources, expression parser, jitter, scheduler, and session store.
  • Added integration tests for CronManager and Agent-level cron behavior.
  • Added end-to-end session cron smoke tests.
  • Added a shared cron test harness (harness/stub.ts) for reusable test fixtures.
  • Enforced Date.now() guards in cron scheduler files to ensure clock abstraction compliance.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked the related issue, if any.
  • I have added tests that prove my fix is effective or that my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

@changeset-bot

changeset-bot Bot commented May 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9dfd5ed

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@moonshot-ai/agent-core Minor
@moonshot-ai/kimi-code Minor
@moonshot-ai/migration-legacy Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ad2cbb63e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +204 to +207
const next = computeNextCronRun(parsed, cursor);
if (next === null) break;
if (next > nowMs) break;
count++;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid counting fires before their jittered delivery

With jitter enabled, this coalesces by comparing each ideal cron time to nowMs, but recurring jobs are actually delivered at jitteredNextCronRunMs(...). If the scheduler wakes at e.g. 09:10:01 for a minutely job whose 09:10 occurrence is jittered to 09:10:06, this loop counts the 09:10 ideal fire and the caller then advances lastSeenAt to 09:10:01, so the 09:10 occurrence is never delivered. Count only occurrences whose jittered delivery time is due, or advance the baseline to the last due occurrence instead of wall-clock now.

Useful? React with 👍 / 👎.

Comment on lines +145 to +146
const offset = -config.oneShotMaxMs * fractionFromId(task.id);
return idealMs + offset;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clamp one-shot jitter to avoid immediate early fires

For one-shot reminders on round minutes, this can move nextFireAt before the task's createdAt because the offset can be as large as 90 seconds. If a user schedules a one-shot at 08:59:30 for 0 9 * * *, a high-hash id can jitter the 09:00 ideal time back to 08:58:30; the next scheduler tick then treats the brand-new reminder as overdue and fires it immediately instead of near 09:00. The pull-forward needs a floor at the scheduling/current time (or enough context to avoid returning a past timestamp).

Useful? React with 👍 / 👎.

Comment on lines +180 to +187
const task = this.manager.store.add(
{
cron: args.cron,
prompt: args.prompt,
recurring,
},
nowMs,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-read the clock when the schedule is actually created

This stores the nowMs captured during resolveExecution, before permission prompts and hooks run, and reuses it inside execute. In manual permission mode a user can approve a CronCreate minutes later; a one-shot for the next minute (or a frequent recurring job) is then inserted with an old createdAt/nextFireAt and can fire immediately with coalesced missed occurrences. Read wallNow() inside execute so the schedule is anchored to the time it is approved and committed.

Useful? React with 👍 / 👎.

Comment on lines +274 to +277
} else {
// Recurring: advance baseline so the next tick computes
// the next-after-now ideal fire.
lastSeenAt.set(task.id, now);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the documented recurring-job expiry

Recurring jobs are described to the model as firing until deleted or auto-expired after 7 days, but the recurring branch only advances lastSeenAt and leaves stale tasks in the store forever. Once a session stays up past the stale threshold, an old cron prompt continues to run indefinitely instead of expiring, which can trigger unexpected future agent turns. Delete or skip stale recurring tasks here (or remove the auto-expiry contract from the tool schema).

Useful? React with 👍 / 👎.

try {
const parsed = parseCronExpression(task.cron);
humanSchedule = cronToHuman(parsed);
const ideal = computeNextCronRun(parsed, nowMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Show pending one-shot fires from their scheduled baseline

For one-shot jobs that are already due but could not fire yet because the agent was busy or manual ticking is disabled, CronList recomputes from nowMs, so a daily one-shot scheduled for 09:00 and listed at 09:05 is reported as tomorrow even though the scheduler will still fire today's missed occurrence from task.createdAt. This makes the list output disagree with the actual pending work; compute one-shot nextFireAt from the same baseline the scheduler uses until the job is removed.

Useful? React with 👍 / 👎.

}

// 4. Session-level cap.
if (this.manager.store.list().length >= MAX_CRON_JOBS_PER_SESSION) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recheck the session cap at execution time

This cap is checked while preparing the tool call, but the actual insert happens later in execute. If the session already has 49 jobs and the model issues two CronCreate calls in the same step, both preparations see 49 and pass; the scheduler then runs the executions sequentially and the store ends up with 51 jobs. Recheck the live store length inside execute immediately before store.add(...) so concurrent prepared calls cannot bypass the cap.

Useful? React with 👍 / 👎.

Comment on lines +248 to +249
const coalescedCount =
ideal === null ? 1 : Math.max(1, countCoalesced(parsed, ideal, now));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep one-shot coalesced counts at one

This coalescing path also runs for recurring: false tasks, so a one-shot daily reminder whose first fire was missed while the laptop was closed for several days can be delivered with coalescedCount greater than 1. One-shots are removed after a single delivery and should not imply that multiple scheduled occurrences existed; force the coalesced count to 1 for one-shot tasks or skip this counting branch when task.recurring === false.

Useful? React with 👍 / 👎.

sailist added a commit to sailist/kimi-code that referenced this pull request May 28, 2026
… 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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sailist

sailist commented May 28, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

Address typecheck failures and the previous Codex P2 review:

  • TS4111 / approvalRule / undefined / readonly / missing imports — all CI errors fixed
  • Extended kimi-code's isReplayUserTurnRecord for the new cron_job / cron_missed origins
  • CronCreate re-reads clock + rechecks session cap inside execute()
  • One-shot jitter floor at createdAt
  • Scheduler coalesce now uses jittered delivery times; lastSeenAt advances to last delivered ideal; one-shot coalescedCount pinned to 1
  • 7-day auto-expire delivers one final fire then removes the task (+ cron_deleted telemetry)
  • CronList anchors one-shot nextFireAt at createdAt instead of nowMs

Verified: agent-core (2046 tests pass), full repo tsgo typecheck clean, lint clean, builds OK. Changeset added.

@pkg-pr-new

pkg-pr-new Bot commented May 28, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@9dfd5ed
npx https://pkg.pr.new/@moonshot-ai/kimi-code@9dfd5ed

commit: 9dfd5ed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6df93ab9be

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +143 to +144
const baseFromMs =
task.recurring === false ? task.createdAt : nowMs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compute recurring nextFireAt from scheduler state

For recurring jobs, using nowMs skips any already-ideal but not-yet-delivered jittered occurrence. For example, a daily 0 9 * * * job with a 15-minute forward jitter listed at 09:05 has not fired yet, but computeNextCronRun(parsed, nowMs) returns tomorrow's 09:00, so CronList reports tomorrow even though the scheduler will still deliver today's jittered fire. This contradicts the tool contract that nextFireAt is the value the scheduler compares against; expose/use the scheduler's per-task baseline instead of recomputing from nowMs.

Useful? React with 👍 / 👎.

Comment on lines +27 to +28
- `stale` — `true` when a recurring task is older than 7 days. The
task is **not** auto-deleted; recurring jobs keep firing. When you

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update stale-task guidance to match auto-expiry

This tool prompt now tells the model that stale recurring jobs are not auto-deleted and keep firing, but CronManager.handleFire removes stale recurring tasks immediately after one final delivery. When CronList shows stale: true, the model can therefore promise the user a keep/delete/refresh flow for a task that will be deleted on its next fire, which is user-visible misinformation from the tool description.

Useful? React with 👍 / 👎.

coalescedCount: ctx.coalescedCount,
stale,
};
const content: ContentPart[] = [{ type: 'text', text: task.prompt }];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include cron metadata in delivered prompt

In stale or coalesced fires, the model only receives task.prompt here; CronJobOrigin is kept on the internal context record but ContextMemory.messages projects records into provider Messages and drops origin. That means the model cannot observe coalescedCount or stale even though the tool docs tell it to change behavior based on those fields, so a week-old final fire or a collapsed backlog is handled as an ordinary user prompt.

Useful? React with 👍 / 👎.

Comment thread packages/agent-core/src/agent/index.ts Outdated
// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop cron timers when the session closes

Starting every agent's cron loop here leaves it running for the lifetime of the process, but Session.close() only stops background tasks/MCP/log handles and never calls agent.cron.stop(). In long-lived hosts or tests that close a session while other handles keep the process alive, unref() does not cancel the interval, so scheduled tasks can continue polling and fire into a closed session instead of dying with the session-only store.

Useful? React with 👍 / 👎.

Comment on lines +253 to +254
case 'cron_job':
case 'cron_missed':

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep cron replay limiting consistent with rendering

These cron-origin messages are excluded from limitReplayRecordsByTurn, but renderReplayUserMessage has no cron branch and falls through to advanceReplayTurn() for the same records. In a session with many cron-fired turns and few real user turns, resume can therefore include far more than REPLAY_TURN_LIMIT rendered turns while also showing cron prompts as plain user messages; either count these as turn starts or render/skip them consistently as internal cron events.

Useful? React with 👍 / 👎.

sailist added a commit to sailist/kimi-code that referenced this pull request May 28, 2026
… 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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sailist
sailist force-pushed the feat/cron-tools branch from 6df93ab to 9d0de99 Compare May 28, 2026 06:23
sailist added 20 commits May 28, 2026 14:45
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
…hots

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.
…n 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.
… 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.
@sailist
sailist force-pushed the feat/cron-tools branch from 9d0de99 to a387914 Compare May 28, 2026 06:46
@sailist

sailist commented May 28, 2026

Copy link
Copy Markdown
Collaborator Author

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a38791411f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/agent-core/src/agent/index.ts Outdated
// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop cron when sessions close

Starting the scheduler from the Agent constructor leaves it running after CoreImpl.closeSession() calls Session.close() and removes the session; I checked packages/agent-core/src/session/index.ts and it only stops background tasks/MCP/logging, not agent.cron.stop(). In the TUI, switching sessions calls previous.close(), so any scheduled task in the old session can still tick later through this retained interval closure and call turn.steer for a session the SDK has already closed/cleared handlers for. Please wire the cron manager into the session/agent close lifecycle before starting it unconditionally here.

Useful? React with 👍 / 👎.

Comment on lines +160 to +161
if (task.createdAt !== undefined && shifted < task.createdAt) {
return task.createdAt;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not fire one-shots immediately before their match

When a one-shot is created shortly before a round :00/:30 match and the deterministic pull-forward would land before createdAt, returning createdAt makes the scheduler consider the job due on the very next tick (now >= nextFireAt), before the requested cron match. For example, creating 0 9 * * * at 08:59:30 with a high-hash id returns 08:59:30, so it can fire at 08:59:31 instead of the next 09:00 match; in this case the jitter should be skipped or clamped to a future time rather than to createdAt.

Useful? React with 👍 / 👎.

Comment on lines +164 to +168
return [
`id: ${task.id}`,
`cron: ${task.cron}`,
`humanSchedule: ${humanSchedule}`,
`nextFireAt: ${nextFireAtIso}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include prompts in cron listings

In sessions where the original CronCreate output has scrolled away or been compacted, CronList is the only way for the model to inspect scheduled jobs before explaining, deleting, or refreshing them. The record rendered here omits task.prompt, so a stale job cannot be recreated with the same prompt as the tool description instructs, and the user cannot be told what an existing reminder/job will actually do from the list alone. Please include the stored prompt (possibly truncated) in each record.

Useful? React with 👍 / 👎.

Comment on lines +378 to +380
new b.CronCreateTool(this.agent.cron),
new b.CronListTool(this.agent.cron),
new b.CronDeleteTool(this.agent.cron),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Block cron mutations in plan mode

Registering CronCreate and CronDelete exposes session-mutating tools that the existing plan-mode guard never denies; I checked PlanModeGuardDenyPermissionPolicy, and it only hard-blocks Write, Edit, and TaskStop. In auto permission mode that means a model can schedule or delete recurring jobs while plan mode is active, even though the analogous background mutation is explicitly blocked until ExitPlanMode. Please add the mutating cron tools to the plan-mode deny path while leaving CronList read-only.

Useful? React with 👍 / 👎.

new b.TaskOutputTool(background),
new b.TaskStopTool(background),
new b.CronCreateTool(this.agent.cron),
new b.CronListTool(this.agent.cron),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Default-approve CronList

CronList is read-only, and its tool description says it is always safe to call, including in plan mode, but this new tool is not covered by DefaultToolApprovePermissionPolicy (I checked that allowlist). In manual/default permission mode it therefore falls through to a user approval prompt just to inspect scheduled jobs, unlike TaskList/TaskOutput; add CronList to the default-approved read-only tools or give it an equivalent approval policy.

Useful? React with 👍 / 👎.

@sailist

sailist commented May 28, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aea6eceb07

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +158 to +160
const baseFromMs =
task.recurring === false ? task.createdAt : nowMs;
const ideal = computeNextCronRun(parsed, baseFromMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep CronList from skipping the pending jitter window

当循环任务的理想触发点已经过去但前向 jitter 还没到达时(例如 0 9 * * * 在 09:05 查询,而该任务实际会在 09:10 触发),这里用 nowMs 作为 recurring 的基准会让 computeNextCronRun 直接跳到下一次理想触发点,所以 CronList 会把 nextFireAt 报成明天/下一周期,尽管当前周期仍在等待触发。这样会误导用户或模型认为本次提醒已经没有待触发;需要让列表逻辑复用调度器的 per-task next-fire 状态,或至少不要在当前 jitter 窗口内从 nowMs 跳过本次 occurrence。

Useful? React with 👍 / 👎.

Comment on lines +227 to +228
let iterations = 0;
const maxIterations = capMinutes + 10_000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound cron search by elapsed wall time

For legal-but-never-firing inputs such as 0 0 31 2 *, the loop does not actually stop after the documented five-year window: month/day jumps each consume only one iteration, so capMinutes + 10000 lets the search walk tens of thousands of years before returning null (I checked this path with that expression). This makes CronCreate validation and any defensive CronList render of a malformed stored task spend hundreds of milliseconds per expression instead of failing within the intended window; compare the candidate date against a real deadline rather than treating loop count as elapsed minutes.

Useful? React with 👍 / 👎.

Comment on lines +286 to +288
try {
onFire(task, { coalescedCount });
} catch (error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not mark failed cron fires as delivered

If onFire throws before the prompt is actually enqueued (for example agent.turn.steer can throw when record persistence has latched a write error), this catch swallows the failure and the code below still removes a one-shot or advances lastSeenAt for a recurring task. In that failure mode the reminder is lost and will not be retried; the scheduler should only remove/advance after a successful delivery, or have onFire report success explicitly.

Useful? React with 👍 / 👎.

Comment on lines +207 to +212
const task = this.manager.store.add(
{
cron: args.cron,
prompt: args.prompt,
recurring,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize accepted cron whitespace before storing

parseCronExpression accepts any whitespace between the five fields, so a tool call like "*/5\n* * * *" is valid and reaches this insert path, but storing the original string means CronList and CronCreate render a multi-line cron: value and break their own one-record-per-line format. Store a canonical args.cron.trim().split(/\s+/).join(' ') (or equivalent) after validation so accepted tabs/newlines cannot corrupt later tool output or the cron-fire envelope.

Useful? React with 👍 / 👎.

sailist added 3 commits May 28, 2026 16:46
- session: stop each agent's CronManager during Session.close() so the
  1-second tick interval (and via closure, the Agent → Session graph)
  doesn't leak across session lifecycles. Mirrors the parallel-stop
  pattern used by stopBackgroundTasksOnExit, with no keepAlive gate.
- jitter: oneShotJitteredNextCronRunMs now skips jitter (returns
  idealMs) when shifted < createdAt, instead of clamping to createdAt
  which made `now >= nextFireAt` fire the task ~29s before ideal on
  the very next tick.
- cron-list: render a JSON-escaped `prompt:` row truncated to ~200
  UTF-8 bytes with a `…(truncated)` suffix; UTF-8 boundary walk-back
  avoids splitting multi-byte chars. Lets the model recall a task's
  intent after context compaction.
- plan-mode: PlanModeGuardDenyPermissionPolicy now denies CronCreate
  and CronDelete (state-mutating); CronList stays allowed.
- default-approve: add CronList to DEFAULT_APPROVE_TOOLS for parity
  with TaskList — pure read-only listing shouldn't trigger manual
  approval prompts.

Tests:
- session: spy-based and SIGUSR1-listener-count assertions verify cron
  stop actually runs its teardown.
- jitter: both budget-insufficient (returns idealMs) and
  budget-sufficient (still pulls forward within cap) paths covered.
- cron-list: 200-byte truncation marker and a separate CJK regression
  case confirming the UTF-8 walk-back lands on a legal char boundary.
- plan-mode: three new cases (CronCreate denied, CronDelete denied,
  CronList allowed).
- default-approve: dedicated policy unit tests covering CronList
  approval and CronCreate/CronDelete fall-through.
A — cron-fire envelope:
  Cron fires now wrap `task.prompt` in a `<cron-fire ...>` XML envelope
  carrying `jobId`, `cron`, `recurring`, `coalescedCount`, `stale` as
  attributes and the original text inside `<prompt>...</prompt>`. The
  envelope is the only path by which the LLM observes those fields — the
  previous content was `{type:'text', text: task.prompt}` and the
  CronJobOrigin metadata was server-side only, making the coalesce /
  stale guidance in cron-create.md un-actionable. Renderer style mirrors
  `agent/context/notification-xml.ts`. Adds projector merge-guard so
  `<cron-fire ` envelopes aren't smeared into adjacent user messages
  (same load-bearing detector that protects <notification>, etc.).

B / C / D — doc/implementation drift on stale + nextFireAt:
  cron-list.md previously said recurring stale tasks "are not
  auto-deleted; keep firing"; the implementation does the opposite
  (final fire, then auto-delete). cron-create.md / cron-delete.md
  prescribed a "CronDelete the stale id, then CronCreate" refresh
  ritual that always failed step 1 because the id was already gone.
  Rewrote the three docs to match the implementation: one final fire,
  auto-delete, refresh = just CronCreate again with the prompt from
  CronList. Also fixed cron-create.md "nextFireAt (epoch ms or null)"
  to ISO timestamp.

E — subagents skip cron init:
  Every Agent constructor unconditionally ran cron.start() (1Hz
  setInterval + optional SIGUSR1 listener). Subagents don't expose
  Cron tools in their default profile, so the work was pure waste,
  and N parallel subagents under KIMI_CRON_MANUAL_TICK=1 would
  eventually trip MaxListenersExceededWarning. Now gates start() and
  the three Cron tool instantiations on `agent.type !== 'sub'`.
  `agent.cron` itself stays constructed so `agent.cron.store` /
  `.stop()` consumers (Session.close) keep working.

F — jitter UTC modulo comment:
  Clarified that `idealMs % MS_PER_MINUTE === 0` is a UTC minute-
  boundary check that incidentally coincides with local minute
  boundaries because no current timezone has a sub-minute offset.

G — SIGUSR1 tick errors visible under debug:
  The SIGUSR1 handler previously swallowed `tick()` exceptions
  silently. Now logs to stderr when `KIMI_CRON_DEBUG=1`, matching the
  scheduler's existing debug pattern. Production stays silent.

H — KIMI_CRON_CLOCK=file: bounded read:
  `wallNow()` previously `readFileSync`'d the entire file on every
  call. A hostile or stray-large file could OOM. Replaced with a
  64-byte `openSync` + `readSync` read; epoch-ms is < 20 chars in
  practice. fd is closed in all error paths.

Tests:
- New `cron-fire-xml.test.ts`: 5 cases (recurring, one-shot, attribute
  escaping vs body verbatim, multi-line body, stale=true).
- New `subagent-skip.test.ts`: 3 cases verifying main/independent
  init cron and subagent skips both `start()` and the three tools.
- New context-projector test: cron-fire envelope is not merged into
  an adjacent real user message.
- manager.test.ts: content assertions updated to expect the envelope,
  with an `=== 1` envelope-count check to detect accidental double-wrap.
- manual-tick.test.ts: two SIGUSR1-debug-log cases (emits with
  KIMI_CRON_DEBUG=1, silent without).
- clock.test.ts: 64-byte cap + garbage fallback.
- cron.e2e.test.ts: content assertion updated for the envelope.
C3 — Don't mark failed cron fires as delivered:
  scheduler.tick() previously swallowed any throw from onFire and
  STILL ran the removeOneShot / lastSeenAt.set block, silently
  losing reminders (one-shot deleted, recurring lastSeenAt advanced
  past an undelivered ideal). Now gates the advance/remove block on
  a `delivered` flag: failures leave state untouched so the next
  tick re-detects the task as due and retries. inFlight is still
  cleared at end-of-tick.

C1 — CronList nextFireAt matches the scheduler:
  Cron list used `nowMs` as the recurring computeNextCronRun base.
  When the current period's ideal had passed but its jittered
  delivery was still pending (e.g. ideal 09:00 + 30s jitter = 09:00:30,
  query at 09:00:15), CronList rendered `nextFireAt = tomorrow 09:00`
  while the scheduler still planned 09:00:30 today — model and user
  saw conflicting state. Scheduler now exposes `getNextFireForTask(id)`
  using the same internal `lastSeenAt` baseline; manager delegates;
  CronList calls through. The previous compute/jitter dance in
  cron-list.ts and its unused imports are removed.

C2 — cron-expr search budget is wall-time, not iteration-count:
  nextRunWithinMinutes capped iteration count, but advanceMonth jumps
  ~43 200 minutes per iteration. A never-firing expression like
  `0 0 30 2 *` could spin ~200 000 years before bailing. The deadline
  is now `fromMs + capMinutes * MS_PER_MINUTE` checked against the
  advancing candidate date. A generous HARD_ITERATION_CAP (10M)
  remains as forward-compat insurance against a refactor that
  accidentally fails to advance the date.

C4 — Normalize CronCreate input whitespace:
  parseCronExpression accepts any \\s+ between fields (including
  tabs / newlines), but downstream sinks (CronList one-key-per-line
  records, <cron-fire cron="..." ...> envelope attribute) assume a
  single-line cron value. Now `args.cron.trim().split(/\\s+/).join(' ')`
  before store.add, and the normalized form flows through the
  rendered output and the approval description as well.

Tests:
- scheduler.test.ts: +5 tests — recurring/one-shot retry semantics
  for C3, and getNextFireForTask for C1 (including the pending
  jittered slot regression with id=`ffffffff`).
- cron-list.test.ts: +1 regression test pinning the current-period
  jittered slot for a recurring task.
- manager.test.ts: +1 delegation test for getNextFireForTask.
- cron-expr.test.ts: +3 tests — fast-null for the pathological
  `0 0 30 2 *` (under a 500ms budget) and a window-edge case for
  hasFireWithinYears.
- cron-create.test.ts: +4 tests covering newline / tab / leading-
  and-trailing whitespace normalization, plus a rendered-output
  assertion that the LLM sees the normalized form.
@sailist
sailist force-pushed the feat/cron-tools branch from aea6ece to 3f2204e Compare May 28, 2026 08:46
@sailist

sailist commented May 28, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f2204e51a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +249 to +250
case 'cron_job':
case 'cron_missed':

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle cron replay origins consistently

When a resumed session contains cron fires, this filter stops counting cron_job/cron_missed records as user turn starts, but SessionReplayController.renderUserMessage has no matching branch for these origins and still falls through to advanceTurn() and renders the raw <cron-fire> envelope as a user message. That can both display internal cron XML as if the user typed it and let cron-generated turns bypass the replay turn limit because they were excluded here; please either render/skip cron origins explicitly in replay or count them consistently.

Useful? React with 👍 / 👎.

// offending field.
let parsed: ParsedCronExpression;
try {
parsed = parseCronExpression(args.cron);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize before preserving the cron raw text

When the cron argument contains tabs or newlines and the expression is not one of cronToHuman's special cases (for example 1\n2\n3\n4\n5), parsing the raw input preserves those separators in parsed.raw; the later cronToHuman(parsed) fallback then emits a multi-line humanSchedule: in the CronCreate output even though the stored cron: field was normalized. This still corrupts the one-key-per-line tool output for valid inputs with whitespace-separated fields, so parse the normalized expression (or normalize parsed.raw) before formatting the response.

Useful? React with 👍 / 👎.

Comment on lines +162 to +163
lo = Number(loStr);
hi = Number(hiStr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject malformed numeric cron tokens

For minute/hour/day-of-week fields, an input such as -5 * * * * reaches this range branch with an empty lower bound, and Number('') becomes 0, so the parser silently schedules minutes 0 through 5 instead of rejecting the malformed cron. The same Number(...) conversions also accept non-cron forms like 1e1 or 0x10; please validate digit-only bounds/values before converting.

Useful? React with 👍 / 👎.

…nd 4

D1 — TUI replay handles cron origins consistently:
  `isReplayUserTurnRecord` already excluded `cron_job` / `cron_missed`
  from user-turn counting, but `SessionReplayController.renderUserMessage`
  had no matching branch and rendered the raw `<cron-fire ...>` XML
  envelope as if the user had typed it. Added an early-return branch
  for both origins so cron records are now consistently skipped:
  not counted toward the replay turn limit, not visually rendered.

D2 — Normalize cron whitespace BEFORE parsing:
  The previous round normalized `args.cron` only AFTER
  `parseCronExpression`, but `parsed.raw` retained the original tabs
  and newlines. `cronToHuman`'s raw-fallback branch then emitted a
  multi-line `humanSchedule:` row for any non-template expression,
  re-corrupting the one-key-per-line tool output for inputs like
  `1\\t2\\t3\\t4\\t5`. Moved the normalize step above the parse so
  `parsed.raw` is single-line and every downstream sink (description,
  store.add, output, error messages) reads the same canonical form.

D3 — Reject malformed numeric cron tokens:
  The range / step parsers used `Number(str)` which silently accepted
  `''` (→ 0, so `-5 * * * *` became `0-5 * * * *`), `'1e1'` (→ 10),
  `'0x10'` (→ 16), and `'+5'`. Added a `DIGIT_ONLY = /^\\d+$/` gate via
  a `parseCronInt(raw, name, role)` helper at all four call sites
  (step, single value, range lower bound, range upper bound). Plain
  digit forms still parse; only typo-shaped non-cron inputs surface
  a parse error instead of being silently rescheduled.

Tests:
- message-replay.test.ts: +2 cases pinning cron_job/cron_missed
  skipping in replay (no XML in transcript, no user-turn entry).
- cron-create.test.ts: +2 cases pinning humanSchedule single-line
  behavior for both the template-recognized path (`*/5\\n* * * *`
  → `every 5 minutes`) and the raw-fallback path (`1\\t2\\t3\\t4\\t5`
  → `1 2 3 4 5`).
- cron-expr.test.ts: +9 cases covering negative bound, scientific
  notation, hex, leading-plus across single/step/range-lo/range-hi
  positions, plus a regression case asserting plain digit forms
  still parse.
@sailist

sailist commented May 28, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

Fourth batch on this PR (a6777c44) addresses the three P2 findings from your previous review:

  • D1 (apps/kimi-code/src/tui/controllers/session-replay.ts) — renderUserMessage now early-returns on cron_job / cron_missed origins, matching isReplayUserTurnRecord's exclusion. Cron records are consistently skipped: no <cron-fire> XML in the replayed transcript, no contribution to the replay turn counter. Mirrors the existing injection branch.
  • D2 (cron-create.ts) — moved args.cron.trim().split(/\s+/).join(' ') to BEFORE parseCronExpression, so parsed.raw is already single-line. cronToHuman's raw-fallback branch can no longer leak tabs/newlines into the humanSchedule: row. All downstream args.cron references migrated to normalizedCron (description, store, output, error messages).
  • D3 (cron-expr.ts) — added DIGIT_ONLY = /^\d+$/ gate via a parseCronInt(raw, name, role) helper, applied at all four numeric call sites (step, single value, range lo, range hi). Inputs like -5 * * * *, 1e1 * * * *, 0x10 * * * *, +5 * * * *, */1e1 * * * *, 1-1e1 * * * *, 1e1-5 * * * * now surface a parse error instead of being silently rescheduled. Plain digit forms still parse.

Tests: message-replay +2 (cron_job/cron_missed skip), cron-create +2 (template + raw-fallback humanSchedule), cron-expr +9 (malformed in all numeric positions plus a regression covering plain forms). agent-core suite at 2098 passing, kimi-code app tests green, typecheck clean.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

sailist added 2 commits May 28, 2026 17:41
CI's `tsgo -p packages/agent-core/tsconfig.json` (TypeScript native
preview 7.0.0-dev.20260421) intermittently fails to resolve the
`localKaos` const export from `@moonshot-ai/kaos` in this file,
emitting `TS2724 has no exported member named 'localKaos'`. The
three other session tests that hit the same import all also import
either a `type` from kaos or a symbol from `@moonshot-ai/kosong`,
which seems to anchor the workspace resolution; this file was the
lone value-only import and was the only one that flaked.

Switch to instantiating `LocalKaos` directly. Functionally
identical at runtime (the `localKaos` const is itself just a
`new LocalKaos()` singleton), and the class export sidesteps
whatever resolution path the value export trips on.

Local `tsc` and local `tsgo` both passed before and after; the
fix only changes the import shape, not the test behavior.
The previous round revealed that CI's `tsgo` (TypeScript native preview)
intermittently fails to resolve `localKaos` from `@moonshot-ai/kaos`
in this file, emitting TS2724. Swapping to `new LocalKaos()` traded
that for TS2673 (constructor is private) — `LocalKaos` is exported
but its constructor is package-internal; only the `localKaos`
singleton is meant to be consumed.

The three other session tests that hit the same `localKaos` value
import all also pull a `type` from kaos (`KaosProcess`, etc.) — and
they pass tsgo cleanly. Naming `type Kaos` here adds the same
workspace-type anchor and binds `localKaos` through a typed const,
which keeps tsgo's resolver stable while preserving the runtime
behaviour (kaos field is still the same singleton).

Local `tsc` and `tsgo` both pass before and after the change.
@sailist sailist closed this May 28, 2026
sailist added a commit to sailist/kimi-code that referenced this pull request May 28, 2026
… 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.
sailist added a commit to sailist/kimi-code that referenced this pull request May 28, 2026
…tAI#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.
sailist added a commit that referenced this pull request May 28, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant