Cron 调度器
把“人 -> Agent”变成“Scheduler -> Agent”,让任务在无人值守时也能触发。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
Cron 调度器
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 调度器改变的是触发权,不是 Agent 的安全边界。
- 计划需要时区、周期、状态和幂等标识。
- 调度本身可测试,不必等待真实时间。
顺着原文把边界看清
01导读:问题背景与本章目标你设好 7:00,去睡觉,去洗澡,去做饭,到点它自己响。这是一件理所当然的事情,理所当然到你从来不会去想:闹钟是怎么在你不在的时候知道时间到了的。⌄
闹钟不需要你盯着它才会响。
你设好 7:00,去睡觉,去洗澡,去做饭,到点它自己响。这是一件理所当然的事情,理所当然到你从来不会去想:闹钟是怎么在你不在的时候知道时间到了的。
但当你开始构建 Agent 系统的时候,这个问题就不理所当然了。
第 13 章的 Agent 支持后台执行:耗时几分钟的操作可以异步跑,主循环继续响应用户。但所有操作仍然是人触发的。你说一句,Agent 动一下。“每天早上 9 点跑一次测试”“每 30 分钟检查一次 CI 状态”,这类任务没有人去推,Agent 就永远不会动。
本文讲的就是:如何给 Agent 加一个会自己看表的调度器。
02问题的本质:触发权变了手动触发的链路是:人 -> Agent。人不在,链路就断了。⌄
手动触发的链路是:人 -> Agent。人不在,链路就断了。
定时触发的链路是:Scheduler -> Agent。人退出了这条链路,调度器成了新的触发者。
触发权一旦交给调度器,至少要回答四个问题:
- 谁负责解释 cron 表达式和时区?
- 时间到了以后,如何持久记录“这次工作应该执行”?
- Agent 正忙时,事件如何保留而不是丢失?
- 定时任务真正调用工具时,是否仍然经过当前权限策略?
第 14 章没有另起一套 Agent Loop。它严格等于第 13 章已有能力,再增加一个 CRON capability,并把 scheduler 纳入已有的异步资源生命周期。最终实现分成四个边界:
schedule_cron 工具
|
v
JsonCronStore
保存 job、next slot 和 durable outbox
|
v
CronRuntime
受管 scheduler 定期 tick,并把 pending event 发布到 EventInbox
|
v
AgentRunner.runEvents()
空闲时执行 event-only turn,工具仍经过 Hook 与权限相关实现集中在以下文件:
- [Cron 领域与运行时](code/chapters/ch14/src/features/cron.ts)
- [原子 JSON store](code/chapters/ch14/src/adapters/cron-json.ts)
- [typed EventInbox](code/chapters/ch14/src/core/events.ts)
- [公共 Agent Loop](code/chapters/ch14/src/core/loop.ts)
- [章节组合根](code/chapters/ch14/src/bootstrap.ts)
- [CLI 组合入口](code/chapters/ch14/src/cli.ts)
Scheduler 只判断时间并产生事件,不直接绕过 Agent Loop 执行业务工具。持久 outbox 负责“工作意图不能凭空消失”,EventInbox 负责当前进程内的 typed FIFO 交接,AgentRunner 才是模型、Hook、权限和工具执行的唯一入口。
03公开工具:只增加 schedulecron第 14 章相对第 13 章只追加一个工具:schedulecron。没有 schedulejob 别名,也没有提前实现 list 或 cancel。⌄
第 14 章相对第 13 章只追加一个工具:schedule_cron。没有 schedule_job 别名,也没有提前实现 list 或 cancel。
输入模型包含五个必填字段:
const scheduleCronSchema = z.object({
cron: z.string(),
prompt: z.string(),
timezone: z.string(),
recurring: z.boolean(),
durable: z.boolean(),
}).strict();
interface ScheduleCronInput {
readonly cron: string;
readonly prompt: string;
readonly timezone: string;
readonly recurring: boolean;
readonly durable: boolean;
}这五个字段都不能靠隐式默认值补齐。
| 字段 | 含义 |
|---|---|
cron | 严格五段 cron 表达式 |
prompt | 到期后交给 Agent 的工作描述 |
timezone | IANA 时区名,例如 UTC、Asia/Shanghai |
recurring | true 表示周期任务,false 表示 one-shot |
durable | true 表示定义和 pending event 跨进程重启恢复 |
创建者身份不由模型填写。运行时从当前 ToolContext.identity 取得 identity,并保存到 CronJob。同样,id、nextRunAtUtc 和 lastSlotAtUtc 都由运行时生成或推进,不属于工具输入。
CronJob 的实际状态比工具输入多:
interface CronJob {
readonly id: string;
readonly cron: string;
readonly prompt: string;
readonly timezone: string;
readonly recurring: boolean;
readonly durable: boolean;
readonly identity: string;
readonly nextRunAtUtc: Date;
readonly lastSlotAtUtc: Date | null;
}这组字段让“定义是什么”“谁创建的”“下一次何时触发”“上一个 slot 是什么”都有明确归属,不再依赖进程内的零散字典。
04cron 解析:不要重造五十年的边界本章严格限制为五段,并把语法和下一次发生时间交给锁定依赖 cron-parser。列表、范围、步进和星期都由成熟解析器处理:⌄
五段 cron 的顺序是:
分钟 小时 日 月 星期本章严格限制为五段,并把语法和下一次发生时间交给锁定依赖 cron-parser。列表、范围、步进和星期都由成熟解析器处理:
* * * * * 每分钟
0 9 * * * 每天本地时间 09:00
*/15 9-10 * * 1,3,5 周一、周三、周五的 09:00-10:59,每 15 分钟
0 9 * * 1-5 周一到周五的本地时间 09:00不是五段、字段越界、步进为零、反向范围或未知时区都会明确失败。坏输入不会先写状态再等待 scheduler 出错。
真正值得单独讲的是 DOM 和 DOW:
- DOM 是 day of month,也就是“几号”。
- DOW 是 day of week,也就是“星期几”。
标准 cron 在两者都受约束时使用 OR,不是 AND。cron-parser 默认按这条语义解释五段表达式:
function nextCronOccurrence(expression: string, timezone: string, afterUtc: Date): Date {
const normalized = validateCronExpression(expression);
const iterator = CronExpressionParser.parse(normalized, {
currentDate: afterUtc,
tz: validateCronTimezone(timezone),
});
return adjustDstOccurrence(normalized, timezone, afterUtc, iterator.next().toDate());
}例如:
0 9 1 * 1它表示“每月 1 号的 09:00,或者每个周一的 09:00”,不是“每月第一个周一”。
五段 cron 本身不能准确表达“每月第一个工作日”。DOM/DOW 的 OR 语义会扩大匹配集合,真实业务日历还涉及节假日和补班,应由独立的日历规则处理。
05时区:本地日历计算,UTC 持久化“每天 9 点”缺少时区就没有完整含义。上海的 9 点、伦敦的 9 点和纽约的 9 点不是同一个 instant。⌄
“每天 9 点”缺少时区就没有完整含义。上海的 9 点、伦敦的 9 点和纽约的 9 点不是同一个 instant。
工具要求调用方显式传入 IANA 时区名。cron-parser 接收 UTC 基准和 tz 选项,在本地日历中计算下一次发生时间,结果再转回 UTC:
const nextRun = nextCronOccurrence(
"0 9 * * *",
"Asia/Shanghai",
new Date("2026-06-01T00:30:00Z"),
);
expect(nextRun.toISOString()).toBe("2026-06-01T01:00:00.000Z");持久化字段 next_run_at_utc、last_slot_at_utc 和 slot_at_utc 都必须是 UTC ISO 字符串。运行时拒绝无效 Date,也不保存没有时区含义的本地分钟字符串。
这个边界对 DST 尤其重要。夏令时切换会出现不存在的本地时间或重复的本地时间;持久化实际 UTC instant,才能区分 fold 两次发生对应的两个时刻,也不会把 gap 当成一个含混字符串。实现对解析器的 DST 结果再做显式策略处理。纽约 2026 年春季跳时中不存在的 02:30 会推进到首个有效时刻 03:00,也就是 07:00 UTC;秋季回拨的两个 01:30 则分别保存为 05:30 UTC 和 06:30 UTC。测试固定这组结果,避免把宿主机本地时区误当成业务时区。
06Job 和 Event 必须分开CronJob 是未来的时间规则,CronEvent 是某一个已经到期的工作意图。两者不能共用一个状态。⌄
CronJob 是未来的时间规则,CronEvent 是某一个已经到期的工作意图。两者不能共用一个状态。
interface CronEvent extends RuntimeEvent {
readonly kind: "cron";
readonly eventId: string;
readonly jobId: string;
readonly identity: string;
readonly prompt: string;
readonly timezone: string;
readonly durable: boolean;
readonly slotAtUtc: Date;
}到期时,store 在同一次状态迁移中完成这些动作:
- 为最早到期 slot 创建一个带稳定 UUID 的
CronEvent。 - 把 event 放入 outbox。
- recurring job 更新
lastSlotAtUtc,并把nextRunAtUtc推进到当前nowUtc之后。 - one-shot job 删除定义,但 event 继续留在 outbox,直到被 Agent Loop 接收并确认。
这种分离解决了一个常见错误:如果 one-shot 到期时直接删除 job,又没有独立 outbox,那么进程恰好在删除后、执行前崩溃,这次工作就永久消失了。
重复 tick 也不依赖 "%H:%M" 之类的字符串标记。第一次迁移后,job 的下一 slot 已经推进,或者 one-shot 定义已经删除;同一时间再次 tick 不会创建第二个 event。
如果 Agent 停机三天后重启,recurring job 最多为最早遗漏 slot 产生一个 event,然后直接把下一次发生时间推进到“现在”之后。它不会瞬间补发几千个历史分钟。
07单一原子快照:state.json 加 outboxworkspace/.agenttutorial/cron/state.json⌄
durable 状态位于:
workspace/.agent_tutorial/cron/state.json它是 durable job 和 durable outbox 的单一权威快照:
{
"jobs": [
{
"cron": "0 9 * * *",
"durable": true,
"id": "00000000-0000-4000-8000-000000000401",
"identity": "user",
"last_slot_at_utc": null,
"next_run_at_utc": "2026-06-02T09:00:00Z",
"prompt": "check CI",
"recurring": true,
"timezone": "UTC"
}
],
"outbox": [],
"version": 1
}真实序列化由 JsonCronStore 把 Date 转成 ISO UTC 字符串,关键契约是值必须表示 UTC instant,而不是依赖示例中的文本格式。
JsonCronStore 使用 workspace 内的 proper-lockfile 目录锁包住读取、due 判断、event 创建、job 推进和持久化。写入不是直接覆盖目标文件,而是:
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
const handle = await open(temporary, "wx");
await handle.writeFile(content);
await handle.sync();
await handle.close();
await rename(temporary, path);在当前进程可观察的替换失败和普通进程重启边界内,一次写入要么提交完整的新快照,要么保留旧字节。测试会分别让 schedule、tick 和 ack 的原子替换失败,并验证 job、outbox 与旧文件字节都没有漂移。
这里的 durable 不承诺主机断电或内核崩溃级持久性:实现同步临时文件后执行同目录 rename,没有额外同步父目录。它保证的是 Agent 进程正常退出、异常退出或重新启动后,可以从已经提交的快照恢复。
损坏的 state.json 也不会被静默忽略。未知字段、错误版本、重复 ID、非法 job、非 UTF-8 或坏 JSON 都会抛出 CronStorageError。静默跳过坏状态看起来“更健壮”,实际会让调用者误以为任务已经恢复,这是更危险的失败方式。
outbox 有明确容量。容量满时,后续 due job 保持原来的 next_run_at_utc 和 last_slot_at_utc,等待 event 被确认后再重试,不能先推进时间再丢工作。
08durable 和 session-only 是两套生命周期durable=True 表示 job、下一 slot 和 pending event 写入 state.json,新建 JsonCronStore 后可以恢复。⌄
durable=True 表示 job、下一 slot 和 pending event 写入 state.json,新建 JsonCronStore 后可以恢复。
durable=False 表示 job 和 outbox 只存在当前 store 实例的内存中。进程或 session 结束后,它们消失,也不会混入 durable 快照。
durable 仍然不等于“应用关闭后继续运行”。scheduler 属于 Agent 进程;进程停止时,没有代码在看表。durable 的含义是下次启动第 14 章 Agent 时恢复规则和未确认事件。
同一个 workspace 可能同时打开多个 Agent session。状态更新使用 .agent_tutorial/.cron.lock 串行化,而 durable scheduler 另使用 .agent_tutorial/cron/leader.lock 选出一个 leader。只有 leader 为 durable job 产生 event,避免多个 session 对同一个 slot 重复投递;每个 session 仍可处理自己的 session-only job。
同一进程内,JsonCronStore 还会先用 withMutex 串行化操作。它解决的是多个 store 实例同时读写同一份 session Map 与快照的问题;跨进程竞争才由 .cron.lock 接管。
这两个锁解决的是不同问题:
| 锁 | 负责什么 |
|---|---|
.cron.lock | 保护 durable 快照的原子读改写 |
leader.lock | 限制同一 workspace 只有一个 durable scheduler |
09从 outbox 到 Agent:先入历史,再确认-> 发布当前可投递的 pending event 到共享 EventInbox⌄
CronRuntime.tick() 的职责很窄:
读取当前 UTC `Date`
-> store.tick()
-> 发布当前可投递的 pending event 到共享 EventInbox
-> 若绑定了 wakeup,则调用 AgentRunner.runEvents()EventInbox 是第 13 章已经引入的 session-local typed FIFO。P14 不再创建第二套 cron queue,而是让 background event 和 cron event 共享同一条窄通道,同时保持各自状态模型独立。
runEvents() 使用 Runner 自己的运行锁。如果 Agent 正在处理用户 turn,它立即返回 undefined,不会并发进入同一 history。event 此时仍在 inbox,durable event 也仍在 outbox,后续空闲 turn 可以继续接收。即使用户 turn 自己轮询到了带 identity 或 idempotency key 的 Cron event,也只会把它暂存在 Runner 内,不会追加到当前交互历史或提前 ack。只有后续 runEvents() 才能按保存的事件上下文执行它。
每次 drain 或 wait 最多取一个 event。这样不同 identity 的 Cron event 不会被同一个 model turn 批量吸收,也不会共用错误的 ToolContext。
Runner 空闲时,事件按下面的顺序进入循环:
从 EventInbox drain 一个 RuntimeEvent
-> 校验 event_id、identity 和 idempotency key
-> 序列化为普通 user message,追加到 canonical history
-> 通知 event pump acknowledge
-> 使用事件上下文执行 event-only model turnack 的含义要说准确:它只确认事件已从 event pump 的 durable outbox 移除,不证明后续所有外部副作用已经“恰好一次”完成。先把事件按 event_id 幂等追加到 history。这样即使进程恰好在两步之间退出,outbox 仍保留可重投的工作意图。若确认调用返回失败,Runner 会回滚这次尚未交给模型的 history 接收;后续 tick 可以重新发布同一个 event。确认成功后,同一个 ID 会传入工具上下文:
const context: ToolContext = Object.freeze({
workspace,
identity: event.contextIdentity ?? runnerIdentity,
...(event.idempotencyKey === undefined
? {}
: { idempotencyKey: event.idempotencyKey }),
});真正有外部副作用的工具仍应使用这个 idempotency key 设计自己的幂等边界。
10触发时重新授权创建 cron job 时允许保存一条未来工作意图,不等于未来所有操作都被永久授权。⌄
创建 cron job 时允许保存一条未来工作意图,不等于未来所有操作都被永久授权。
CronEvent 保存创建者 identity。event-only turn 执行时,Runner 用这个 identity 构造新的 ToolContext。模型随后提出的每个工具调用仍按公共顺序处理:
工具参数校验
-> PreToolUse Hook
-> 当前 PermissionPolicy
-> handler
-> PostToolUse Hook
-> 对应 tool result 回填如果当前策略拒绝,handler 调用次数就是零。权限规则在 job 创建后发生变化,也以触发时的当前规则为准。
这条边界非常重要。cron prompt 只是未来要重新交给 Agent 的输入,不是权限票据,也不能绕过文件、Shell 或其他副作用工具的授权。
11受管 scheduler 和关闭顺序CronRuntime.start() 通过第 13 章的 JobSupervisor.startManaged() 注册 scheduler,并把 supervisor 的取消信号转成 scheduler 自己的 AbortSignal:⌄
CronRuntime.start() 通过第 13 章的 JobSupervisor.startManaged() 注册 scheduler,并把 supervisor 的取消信号转成 scheduler 自己的 AbortSignal:
this.#worker = this.#supervisor.startManaged(
async (signal) => await this.#runScheduler(signal),
"cron-scheduler",
);这样 scheduler 和后台 job 共享唯一 task owner。测试可以注入 FakeClock 和 BlockingSleeper,不依赖真实墙钟或真实 sleep,就能确定验证 worker 已启动和关闭。
组合根把资源按下面的顺序交给 Runner:
const resources = Object.freeze([
backgroundSupervisor,
cronRuntime,
]);AgentRunner.close() 逆序关闭,所以先执行 cronRuntime.close()。它会取消并 await scheduler、等待已经进入的 tick() 离开同一把异步锁,再释放 leader lock;之后才关闭共享 JobSupervisor。等待锁的后续 tick 会看到 closed 状态并明确失败,不能在 close 返回后重新获取 leader。CLI 构建失败时也按 Cron -> supervisor -> OpenAI model 的顺序清理。关闭返回后不应再有活跃 worker。
12组合根与两个入口P14 的 BuildDependencies 要求显式提供 cronRuntime。P13 传入它会失败,P14 缺少它也会失败,避免“构造了资源却没有接入”或“声明有能力却没有运行时”。⌄
P14 的 BuildDependencies 要求显式提供 cronRuntime。P13 传入它会失败,P14 缺少它也会失败,避免“构造了资源却没有接入”或“声明有能力却没有运行时”。
在工具注册顺序上,P14 保留完整 P13 工具序列,只在最后追加 schedule_cron。P13 与 P14 的 shell schema 完全相同,Cron 不会反向改变上一章能力。
CLI 创建一个共享 EventInbox,再用它构造 JobSupervisor 和 CronRuntime。组合根还会验证 CronRuntime 持有的正是传入的 supervisor 和它的 EventInbox,拒绝两套外观相似但互不相通的运行时:
return new CronRuntime({
store: new JsonCronStore(workspace),
inbox,
supervisor,
clock: { now: () => new Date() },
});固定章节入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run ch14 -- --prompt "每天上海时间 9 点检查 CI,周期执行并持久化"通用入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run agent-tutorial -- run --chapter 14 --prompt "每天上海时间 9 点检查 CI,周期执行并持久化"模型调用 schedule_cron 后,durable 状态会写到当前 workspace 的 .agent_tutorial/cron/state.json。
13与 Claude Code 的差异参照 learn-claude-code/s14cronscheduler/README.md 中深入 CC 源码的分析,P14 的 Cron 调度器在教学简化上与 Claude Code 的调度器存在以下差异:⌄
参照 learn-claude-code/s14_cron_scheduler/README.md 中深入 CC 源码的分析,P14 的 Cron 调度器在教学简化上与 Claude Code 的调度器存在以下差异:
工具数量。 CC 暴露三个 cron 工具:CronCreate、CronDelete、CronList;P14 只实现一个 schedule_cron,没有 list 或 cancel。CC 的 CronCreate 支持 createdAt 字段,P14 的 CronJob 由运行时生成 id、nextRunAtUtc 和 lastSlotAtUtc。
特性开关。 CC 的 cron 工具受编译时 feature('AGENT_TRIGGERS')、运行时 GrowthBook 标志 tengu_kairos_cron 和本地环境变量 CLAUDE_CODE_DISABLE_CRON 三重控制;P14 无条件注册 schedule_cron,只由 Profile 和工具注册顺序决定是否可用。
存储与锁。 CC 将 durable 任务写入 .claude/scheduled_tasks.json,并用 .scheduled_tasks.lock 防止同项目多个 session 重复触发;P14 使用 .agent_tutorial/cron/state.json 保存 job 与 outbox 的单一原子快照,用 .cron.lock 保护读改写、leader.lock 选举唯一 durable scheduler。P14 的 outbox 是持久事件队列,CC 的 JSON 文件不保存已触发但未消费的事件。
时区语义。 CC 的 cron 表达式按本地时区解释,模型不能显式传 IANA 时区;P14 要求调用方传 timezone,用 cron-parser 在本地日历中计算,并把 next_run_at_utc、last_slot_at_utc、slot_at_utc 全部持久化为 UTC instant。P14 对 DST gap/fold 有显式测试,CC 参考实现没有等价说明。
防惊群与过期。 CC 为重复任务加入确定性抖动(延迟最多为周期的 10%,上限 15 分钟),一次性任务在 :00/:30 时最多提前 90 秒;重复任务 7 天自动过期(可配置,上限 30 天)。P14 没有抖动、自动过期或 QoS,靠固定 UTC slot 和 job 推进避免重复触发。
任务上限。 CC 的 CronCreateTool.ts:25 设置 MAX_JOBS = 50,超限返回 "Too many scheduled jobs (max 50). Cancel one first.";P14 没有 job 数量上限,只有 outbox 容量上限,满了之后不推进 job,等待事件确认。
交付方式。 CC 触发后通过 enqueuePendingNotification() 以 priority: 'later' 入队命令队列,并标记 workload: WORKLOAD_CRON。API 容量紧张时按更低 QoS 服务;useQueueProcessor.ts 在无 query、无阻塞 UI、队列非空时自动处理。P14 把触发事件放入共享 EventInbox,由 AgentRunner.runEvents() 在空闲时逐条执行,不区分优先级或 workload 标签。
调度器归属。 CC 的 cronScheduler.ts 每秒检查一次,并用 chokidar 监听 scheduled_tasks.json 变化。P14 的 scheduler 由 JobSupervisor.startManaged() 注册为受管 worker,与后台 job 共享生命周期,关闭顺序可等待。P14 没有文件系统观察者。
14从 ai-agent-book 学到什么:事件流与安全点ai-agent-book/chapter4/agent-with-event-trigger/README.md 对应实验 4-5,ai-agent-book/chapter4/async-agent/README.md 对应实验 4-6。前者用 register -> trigger -> wake -> handle 演示外部世界唤醒 Agent,后者在这个简单事件队列上继续做紧急度分类、批量处理和状态检查点。它们没有引入新的模型推理原语,而是把“什么时候让事件进入 Agent”变成框架层可控的工程边界。⌄
[ai-agent-book/chapter4/agent-with-event-trigger/README.md](ai-agent-book/chapter4/agent-with-event-trigger/README.md) 对应实验 4-5,[ai-agent-book/chapter4/async-agent/README.md](ai-agent-book/chapter4/async-agent/README.md) 对应实验 4-6。前者用 register -> trigger -> wake -> handle 演示外部世界唤醒 Agent,后者在这个简单事件队列上继续做紧急度分类、批量处理和状态检查点。它们没有引入新的模型推理原语,而是把“什么时候让事件进入 Agent”变成框架层可控的工程边界。
15ai-agent-book 的实验设计实验 4-5 把触发源分成一次性定时器、循环定时器和文件监听,统一成 {eventtype, content, metadata} 结构化事件,并由后台线程推入共享队列。离线 demo 不调 LLM,也能清楚看到定时器注册、触发、事件循环取事件、唤醒 Agent、模拟处理五个阶段。⌄
实验 4-5 把触发源分成一次性定时器、循环定时器和文件监听,统一成 {event_type, content, metadata} 结构化事件,并由后台线程推入共享队列。离线 demo 不调 LLM,也能清楚看到定时器注册、触发、事件循环取事件、唤醒 Agent、模拟处理五个阶段。
实验 4-6 / Flux 更进一步:classify_urgency() 把事件分为 INTERRUPT、IMMEDIATE、DEFERRED。紧急事件通过取消式处理制造安全点;普通事件进入 pending;异步工具结果到达时再批量追加轨迹并触发一次 LLM。它还有三个离线能力演示:并行与串行墙钟、打断恢复、检查点跨会话恢复。书里明确点出关键原则:事件只在每轮循环的边界被消费,不中途插入模型推理或工具执行。
16P14 的对照与取舍P14 没有照搬 Flux 的紧急度和批量机制,但把同一套事件边界原则落地到 Cron:⌄
P14 没有照搬 Flux 的紧急度和批量机制,但把同一套事件边界原则落地到 Cron:
- 结构化事件。 ai-agent-book 的事件至少带
event_type、content、metadata;P14 的CronEvent带eventId、jobId、identity、prompt、timezone、slotAtUtc,RuntimeEvent还带idempotencyKey。触发 turn 不需要靠裸字符串猜上下文。 - 安全点消费。 ai-agent-book 强调事件只在循环边界消费;P14 的
runEvents()与用户 turn 共用运行锁,Agent 忙时事件留在 inbox/outbox,空闲后才进入 event-only turn。 - 逐条处理。 Flux 为效率批量追加非紧急事件;P14 每轮最多取一个 Cron event。不同 identity、时区和幂等键不会混进同一个 model turn,也避免了批量事件只关注最后一条的问题。
- 持久 outbox。 Flux 用状态检查点保存 trajectory 与 task progress;P14 的 durable 检查点保存 job 与 pending outbox,事件确认前不删除工作意图。范围更窄,但正好覆盖“进程重启后 Cron 工作不丢”的核心。
- 重新授权。 ai-agent-book 的外部事件通常带有用户或渠道身份;P14 保存创建者 identity,触发时重建
ToolContext,工具仍经过 Hook 和PermissionPolicy。 - 离线验证。 ai-agent-book 提供无 API key 的离线 demo 和可测量输出;P14 用
FakeClock、BlockingSleeper、原子替换失败注入和固定 UTC 断言,不依赖真实 sleep 或系统时间。
P14 明确不做的事: 没有 INTERRUPT/IMMEDIATE/DEFERRED 紧急度分类,Cron event 统一排队到空闲;没有事件批量;没有文件监听、Webhook 或外部 Channel,只有时间驱动的 scheduler;没有占位符式异步工具;也没有完整 trajectory 检查点。ai-agent-book 的这些能力更适合接在事件源多样、需要打断和并行的框架上,第 14 章先守住“时间到了,事件不丢,到安全点执行”这条主线。
17如何验证本章测试不依赖真实 OpenAI、真实 sleep 或系统时间。五组聚焦测试分别覆盖表达式、store、runtime、组合根和入口:⌄
本章测试不依赖真实 OpenAI、真实 sleep 或系统时间。五组聚焦测试分别覆盖表达式、store、runtime、组合根和入口:
- [表达式与时区测试](code/chapters/ch14/tests/ch14-cron.test.ts)
- [原子 store 与恢复测试](code/chapters/ch14/tests/ch14-cron-store.test.ts)
- [event-only turn 与关闭测试](code/chapters/ch14/tests/ch14-runtime.test.ts)
- [P13/P14 增量测试](code/chapters/ch14/tests/ch14-bootstrap.test.ts)
- [双入口与持久化测试](code/chapters/ch14/tests/ch14-entry.test.ts)
运行聚焦验证:
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch14
npm run typecheck
npm run lint
npm run format:check
npm run build关键断言不是“对象存在”,而是可观察结果:
- 五段表达式、列表、范围、步进、星期和 DOM/DOW OR 得到确定 slot。
- 本地时区计算结果转换为预期 UTC instant,DST gap/fold 行为固定。
- recurring 同 slot 不重复,one-shot 删除定义但保留 outbox。
- 延迟多天只补一个最早 slot,下一次时间推进到 now 之后。
- durable 重建恢复,session-only 重建消失。
- outbox 满以及 schedule、tick、ack 持久化失败时,已提交状态保持不变。
- 两个 store 竞争同一 slot 只提交一个 event。
- Agent 忙时不 ack,空闲后 event-only turn 使用保存身份并重新授权。
- close 等待在途 tick,关闭后 scheduler 和 supervisor 都没有活跃 task,也不会重新获取 leader。
- CLI 通过两个入口运行,并把 durable job 写入正确路径。
18本章明确不做什么最小完整实现不等于把所有调度产品能力一次塞进来。第 14 章没有实现:⌄
最小完整实现不等于把所有调度产品能力一次塞进来。第 14 章没有实现:
- list/cancel Cron 工具
- 抖动、自动过期或 QoS
- 应用退出后仍运行的操作系统级调度
- P15 的 Teammate 和 Mailbox
- P16 的协作协议
这些能力需要新的公开契约和测试,不能藏在当前工具的隐式分支里。
19小结给 Agent 加 Cron,不是写一个无限循环每秒看表。真正的工程边界是:⌄
给 Agent 加 Cron,不是写一个无限循环每秒看表。真正的工程边界是:
- 用成熟解析器和显式时区得到正确的下一 UTC slot。
- 把 job 迁移和 pending event 放进同一个锁定原子快照。
- 用 durable outbox 保存工作意图,用
EventInbox连接当前运行时。 - 让事件重新进入唯一 Agent Loop,并在触发时重新经过权限。
- 让 scheduler 归
JobSupervisor管理,并用可等待的关闭顺序结束。
这样,Agent 忙时任务不会凭空消失,进程重启后 durable 状态可以恢复,多 session 不会重复触发同一个 durable slot,权限也不会因为“这是定时任务”而失效。
下一篇讲 Agent Teams:当任务大到一个 Agent 装不下,需要组队的时候,持久队友和异步收件箱怎么做。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始