第 十四 章 Agent 架构实操 深入学习 · 交互式 含 QA 测试

让 Agent 学会看表:Cron 调度器的设计与实现

💡 schedule_cron + JsonCronStore + CronRuntime + EventInbox 的持久 outbox。
触发权从『人』转移到『调度器』。要回答:谁解释 cron 和时区?时间到了如何持久记录工作意图?Agent 忙时事件如何不丢?触发时是否仍经过权限?
本章进度
0%
1 本章要掌握的目标
  • 工具只增加 schedule_cron(cron/prompt/timezone/recurring/durable 五必填,无默认值)。
  • cron 严格五段,本地日历计算,UTC 持久化(DST gap 推进 / fold 执行两次是刻意的)。
  • Job 和 Event 分开;durable outbox 保存 pending 工作意图,容量满不推进时间。
  • 事件先入 canonical history 再 ack;ack 失败要回滚弹出,宁可重复不可丢失(idempotencyKey 兑底)。
  • misfire 只补最早一个 slot;每轮最多一个事件(身份隔离);触发时用创建者身份重新授权。
2 核心知识点
问题的本质:触发权变了

手动触发链路是人→Agent;定时触发是 Scheduler→Agent。scheduler 只判断时间并产生事件,不直接绕过 Loop 执行业务工具。

schedule_cron 工具
  → JsonCronStore(job + next slot + outbox)
  → CronRuntime(tick 发布事件)
  → AgentRunner.runEvents()(event-only turn,工具过权限)
cron 解析用成熟库

严格五段(分钟 小时 日 月 星期),交给 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)
时区:本地日历计算,UTC 持久化

工具要求显式 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"
Job 和 Event 必须分开

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
durable 原子快照 + 两个锁

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 是待重新提交的文本,不是权限票据
3 机制流程
1
schedule_cron

解析五段 cron + 时区;验证 durable/recurring;写入 store。

2
CronRuntime.tick

读当前 UTC → store.tick() → 发布 pending 事件到 EventInbox。

3
runEvents()

空闲时执行 event-only turn;忙时不 ack,事件保留;ack 失败回滚弹出刚追加的 history,模型请求次数为 0。

4
重新授权

用保存的 identity 构造新 ToolContext;工具仍过 Hook 与权限。

4 术语表
slot(槽位)一次到期的 UTC instant;应执行时刻≠发现时刻,防重复靠比对 last_slot。
CronJob未来的时间规则(cron/prompt/timezone/recurring/durable/identity/next slot)。
CronEvent某一个已经到期的工作意图(eventId/jobId/identity/prompt/slotAtUtc)。
durable outbox未确认事件与 job 一起持久化;先入历史再 ack,事件确认前不删除。
misfire(漏触发)停机期间错过的 slot;只补最早一个,然后追平。
leader.lock避免多个 session 对同一 durable slot 重复投递。
idempotencyKey事件驱动的 turn 里填 event_id,供有副作用的工具做幂等边界。
5 QA 测试环节(自测题)
已完成 0 / 6 · 答对 0
Q1. cron 触发后,Agent 忙时事件如何?
Q2. one-shot 到期时?
Q3. durable 的边界是?
Q4. DOM/DOW 同时受约束时的语义是?
Q5. cron 触发时执行的工具?
Q6. Agent 停机几天后重启,recurring job 会?
6 验证与实验
  • npm run test:ch14:318 个测试 4 秒跑完全程离线(注入 CronClock/CronSleeper/BlockingSleeper,不依赖真实 OpenAI 或系统时间);P14 主 Agent 工具序列 16 个(原 15 + schedule_cron)。
  • 验证 UTC instant、DST、outbox 容量、leader 单一投递、ack 失败回滚、close 等待在途 tick。
  • npm run ch14 -- --prompt "每天上海时间 9 点检查 CI,周期执行并持久化"