手动触发链路是人→Agent;定时触发是 Scheduler→Agent。scheduler 只判断时间并产生事件,不直接绕过 Loop 执行业务工具。
schedule_cron 工具
→ JsonCronStore(job + next slot + outbox)
→ CronRuntime(tick 发布事件)
→ AgentRunner.runEvents()(event-only turn,工具过权限)
严格五段(分钟 小时 日 月 星期),交给 cron-parser。DOM 与 DOW 同时受约束时是 OR 语义,例如 0 9 1 * 1 表示『每月 1 号或每个周一 9 点』。
* * * * * 每分钟
0 9 * * * 每天本地 09:00
*/15 9-10 * * 1,3,5 周一/三/五 9-10 每15分
0 9 1 * 1 每月1号或每周一 09:00(OR)
工具要求显式 IANA 时区。cron-parser 在本地日历算下一次,再转回 UTC 存 next_run_at_utc / last_slot_at_utc / slot_at_utc。slot 是「应该执行的时刻」,不是发现到期的时刻;next_run 严格大于创建时刻,创建不即触发。gap(春季不存在的时间)推进到有效时刻;fold(秋季重复出现)会执行两次——刻意不修:修它要重新引入本地时间语义,换来一整类新边界;UTC instant 是唯一真相,重复由工具幂等键化解。
nextCronOccurrence("0 9 * * *", "Asia/Shanghai",
new Date("2026-06-01T00:30:00Z"))
→ "2026-06-01T01:00:00.000Z"
CronJob 是未来时间规则,CronEvent 是已到期的工作意图。到期时同一次状态迁移:创建 CronEvent → 放入 outbox → recurring 推进 nextRun / one-shot 删除定义但 event 留在 outbox。
到期原子动作:
1. 创建带稳定 UUID 的 CronEvent
2. 放入 outbox
3. recurring 更新 last_slot 并推进 next_run
4. one-shot 删除定义,event 留在 outbox
state.json 是 durable job 与 outbox 的唯一权威快照;损坏不静默忽略。.cron.lock 保护快照读改写,leader.lock 选出唯一 durable scheduler。容量满(outboxCapacity)时被挤掉的 job 必须保持原 next_run 原地等待——先推进再丢工作是一类看起来正常的静默 bug。
workspace/.agent_tutorial/cron/state.json {"jobs":[...],"outbox":[...]}
.cron.lock 保护 durable 快照原子读改写
leader.lock 限制同一 workspace 只有一个 durable scheduler
第 13 章的批量注入(batch: {index,total})在本章被整体退掉:一个 model turn 只有一个 ToolContext,装一个 identity——两个创建者的事件批量进同一轮,无论选谁另一个都在用别人的身份执行。身份隔离与批量效率冲突,P14 选隔离。misfire 只补最早一个 slot 后追平,不补齐所有(否则上下文被历史事件吃光)。
工具看到的上下文 = [{identity:"cron-owner", idempotencyKey: event_id}]
身份 = 创建 job 时的人(过去)
权限 = 触发时的当前 PermissionPolicy(现在)
cron prompt 是待重新提交的文本,不是权限票据
解析五段 cron + 时区;验证 durable/recurring;写入 store。
读当前 UTC → store.tick() → 发布 pending 事件到 EventInbox。
空闲时执行 event-only turn;忙时不 ack,事件保留;ack 失败回滚弹出刚追加的 history,模型请求次数为 0。
用保存的 identity 构造新 ToolContext;工具仍过 Hook 与权限。