第 14 章 让 Agent 学会看表:Cron 调度器 · Agent架构实操十四 开始测验

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

闹钟不需要你盯着才会响。但构建 Agent 系统时这就不理所当然了——所有操作仍是人触发的,「每天9点跑测试」没人推 Agent 就永远不动。触发权从「人→Agent」变成「Scheduler→Agent」。
⏱ 约 15 分钟 ⏰ schedule_cron 📤 durable outbox +CRON

🎯 学习目标

学完能答
  • 触发权从「人→Agent」变成什么?由此要回答哪四个问题?
  • schedule_cron 的五个必填字段?哪个不能靠隐式默认?
  • 四个边界(schedule_cron/JsonCronStore/CronRuntime/AgentRunner.runEvents)各负责什么?
  • Scheduler 直接绕过 Agent Loop 执行业务工具吗?
  • durable outbox 解决什么?EventInbox 解决什么?
  • 定时任务真正调用工具时,是否仍经过当前权限策略?

🧠 核心概念

点击展开。
1 · 问题的本质:触发权变了

手动触发:人→Agent,人不在链路断。定时触发:Scheduler→Agent,人退出链路,调度器成新触发者。触发权交给调度器要回答四问:

  1. 谁负责解释 cron 表达式和时区?
  2. 时间到了如何持久记录「这次工作应该执行」?
  3. Agent 正忙时,事件如何保留而不是丢失?
  4. 定时任务真正调用工具时,是否仍经过当前权限策略?

P14 严格等于 P13 已有能力 + CRON capability,scheduler 纳入已有异步资源生命周期,不另起一套 Loop。

2 · schedule_cron 五个必填字段
字段含义
cron严格五段 cron 表达式
prompt到期后交给 Agent 的工作描述
timezoneIANA 时区名,如 UTC、Asia/Shanghai
recurringtrue 周期任务,false 一次性
durable是否持久化(崩溃恢复)
注意 这五个字段都不能靠隐式默认值补齐。没有 schedule_job 别名,也没提前实现 list 或 cancel。
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。

▶️ 交互演示:Cron 调度流程

设置一个 cron(默认每天上海9点),点「快进到触发时间」看 Scheduler 如何产生 event、经 durable outbox 和 EventInbox,最终由 AgentRunner 执行 event-only turn。
08:59:50Asia/Shanghai · 每日 09:00

🔑 一句话总结

Scheduler 只判断时间产生事件,不绕过 Loop;durable outbox 保证意图不丢,EventInbox 保证执行有序,工具仍过权限

QA 测验