让 Agent 学会看表:Cron 调度器的设计与实现
🎯 学习目标
- 触发权从「人→Agent」变成什么?由此要回答哪四个问题?
- schedule_cron 的五个必填字段?哪个不能靠隐式默认?
- 四个边界(schedule_cron/JsonCronStore/CronRuntime/AgentRunner.runEvents)各负责什么?
- Scheduler 直接绕过 Agent Loop 执行业务工具吗?
- durable outbox 解决什么?EventInbox 解决什么?
- 定时任务真正调用工具时,是否仍经过当前权限策略?
🧠 核心概念
1 · 问题的本质:触发权变了 ▸
手动触发:人→Agent,人不在链路断。定时触发:Scheduler→Agent,人退出链路,调度器成新触发者。触发权交给调度器要回答四问:
- 谁负责解释 cron 表达式和时区?
- 时间到了如何持久记录「这次工作应该执行」?
- Agent 正忙时,事件如何保留而不是丢失?
- 定时任务真正调用工具时,是否仍经过当前权限策略?
P14 严格等于 P13 已有能力 + CRON capability,scheduler 纳入已有异步资源生命周期,不另起一套 Loop。
2 · schedule_cron 五个必填字段 ▸
| 字段 | 含义 |
|---|---|
| cron | 严格五段 cron 表达式 |
| prompt | 到期后交给 Agent 的工作描述 |
| timezone | IANA 时区名,如 UTC、Asia/Shanghai |
| recurring | true 周期任务,false 一次性 |
| durable | 是否持久化(崩溃恢复) |
3 · 四个边界 ▸
schedule_cron 工具
↓
JsonCronStore
保存 job、next slot 和 durable outbox
↓
CronRuntime
受管 scheduler 定期 tick,把 pending event 发布到 EventInbox
↓
AgentRunner.runEvents()
空闲时执行 event-only turn,工具仍经过 Hook 与权限
Scheduler 只判断时间并产生事件,不直接绕过 Agent Loop 执行业务工具。durable outbox 负责「工作意图不能凭空消失」,EventInbox 负责进程内 typed FIFO 交接,AgentRunner 才是模型/Hook/权限/工具执行的唯一入口。
4 · durable outbox vs EventInbox ▸
durable outbox(在 JsonCronStore):时间到了,「这次工作应该执行」的意图持久化到磁盘。即使进程崩溃,重启后仍能恢复未执行的意图,不凭空消失。
EventInbox(进程内):当前进程内的 typed FIFO 交接。CronRuntime 把 pending event 发布到 EventInbox,AgentRunner.runEvents() 空闲时取出执行 event-only turn。
两层分离:持久化保证不丢意图,进程内交接保证当前执行有序。
5 · 权限不变 + 资源生命周期 ▸
定时任务真正调用工具时仍经过当前权限策略。Scheduler 不因为是自己触发的就绕过 Hook/权限/审批。event-only turn 和普通 turn 走同一套工具执行路径。身份来自过去:工具看到的 ToolContext.identity 是创建 job 时的人,idempotencyKey 填本次 event_id;权限来自现在:按触发时的当前策略判决。cron prompt 是待重新提交的文本,不是权限票据。
scheduler 纳入已有异步资源生命周期(第13章的 JobSupervisor 模式):受管 scheduler 定期 tick,进程关闭时优雅停止,不留下孤儿定时器或后台 worker。
6 · slot、misfire 与批量退场 ▸
slot 是应该执行的时刻,不是发现到期的时刻:9:00 的任务忙了 8 秒才被发现,事件 slot 仍是 09:00:00;防重复靠比对 last_slot。next_run 严格大于创建时刻,「创建即触发」不是本章语义。本地日历算、UTC instant 存:DST gap(春季不存在的时间)推进到有效时刻;fold(秋季重复)会执行两次——刻意不修,修它要重新引入本地时间语义,重复由幂等键兑底。
停机三天只补最早一个 misfire slot 后追平,不补齐所有(否则上下文被历史事件吃光)。outbox 容量满时被挤掉的 job 保持原 next_run 原地等待——先推进再丢工作是一类静默 bug。第 13 章的批量注入(batch:{index,total})在本章被整体退掉:一个 model turn 只有一个 ToolContext,装一个 identity,批量会让某个创建者用别人的身份执行。事件先入 canonical history 再 ack,ack 失败要回滚弹出——宁可重复不可丢失。状态存单一 state.json 快照,.cron.lock 保护读改写,leader.lock 保证同一 workspace 只有一个 durable scheduler。