目标之下的三件套——plan、todo、schedule
goal 是跨轮次的信念,它身边还有三个更小的投影:plan-mode 是一个二进制开关 {active, pending},由 plan/mode 事件与 /plan 命令生命周期折叠而成;todo/write 是整条列表快照 last-wins,TodoItem 只有 content+status 三态,无 id 无优先级;schedule 是版本化变更事件 schedule/change,六种记录 after/at/every/daily/weekly/cron,更新用 expected 整条 CAS,三种 miss 码。它们都注册成会话投影,fold 纯函数,写闸门由各自 invariant 伴侣把关。
Written in Chinese
第 0 秒,模型跟你说”我先做个计划”。你以为它启动了一个复杂规划器——其实账本上只多了一条 plan/mode {active: true}。再写个待办?整条列表 todo/write {todos: [...]} 一把替换。再定个明天的提醒?一条 schedule/change 落账。这三件事共用 goal 那一章学到的全部哲学:当前态是 fold 出来的,不是存出来的;写是 append,不是字段赋值;schema 严格解码,写闸门在写入那一刻就关着。 只是它们各自选了一种最贴合自己形状的”快照粒度”。
plan-mode:一个开关,两个布尔
先看最小的那个。PlanProjection 就两个字段:
active:日志里最新一条plan/mode写的是什么(第一条之前是 inactive)。pending:一个/plan命令选了某个目标模式,但还没通过配对的command/done结算、也没有更新的plan/mode记录那个状态——这期间 pending=true。
PlanUnitState 是 fold 的内部累加器,除了 active/pending 还多了几个运行时槽:wanted(当前待生效的目标模式)、running(正在等结算的命令 id 与它的目标)、activeAtLastHeader(最近一次 request/header 里记录的 active)。
关键一句在 docstring 里:“Capability absence (plan-mode not composed) is the key’s absence, never a value.”——你没装 plan-mode 能力,plan 这个投影 key 干脆就不存在,不会给你一个 {active: false}。这是把”未组合”与”明确关闭”区分开的做法。
本节源码依据(4 处)
todo/write:整条列表,last-wins
TodoItem 被刻意做得极小:
{ content: string, status: 'pending' | 'in_progress' | 'completed' }
没有 id,没有优先级,没有 activeForm。docstring 解释得直白:“the list is replaced wholesale on every write (last-write-wins), so entries need no stable identity.”——既然每次写整条列表,条目根本不需要身份。
todo/write 事件就一个字段:{todos: TodoItem[]}。fold 就是取最新一条。第一条之前投影是 null。in_progress 可以标多条——并行工作合法。
为什么不加 id? 加 id 就意味着局部更新(“把第 3 项标完成”),那就需要 delta 语义、需要合并规则、需要并发冲突解决。todo 是模型在工作过程中反复改写的清单——一把替换最简单,模型一次写完整列表,UI 直接渲染。
本节源码依据(4 处)
schedule:六种记录,版本化变更,CAS 更新
schedule 是这三件里最厚的。它的事件叫 schedule/change,是版本化变更——docstring 写明”the owning package validates the complete session-local transition stream before accepting a candidate event”。
六种记录 kind:
| kind | 语义 | 关键字段 |
|---|---|---|
| after | 相对延迟一次性 | afterSeconds |
| at | 绝对时刻一次性 | scheduledAt |
| every | 固定间隔重复 | everySeconds(≥1 分钟) |
| daily | 每日本地时间 | time + timeZone |
| weekly | 每周指定工作日 | time + timeZone + weekdays |
| cron | 五字段 cron | expression + timeZone |
更新走 CAS:ScheduleUpdateRequest 要带 expected: ScheduleRecord——你编辑开始时读到的整条旧记录。对不上就返回 ScheduleUpdateMiss,三选一:schedule_not_found / schedule_ended / schedule_conflict。
投递历史不是无限的:DeliveryRetentionBounds {days, records} 双约束,新投递入列时旧的会被剪;被剪过会显式标 earlierRecordsPruned: true,缺失记录绝不重建。
变更落账后广播 schedule/changed cordis 事件,监听器失败被 contained。
本节源码依据(7 处)
推理段:为什么三件事粒度不一样?
把它们并排看,会发现”快照粒度”是按改写频率与并发度选的:
- plan-mode 改写极少(进入/退出规划模式),形状就两个布尔——够用。
- todo 改写极频繁(模型每几步就重写一遍),整条替换最省脑——不要 id,不要 delta。
- schedule 改写少但并发编辑可能存在(一个 UI 正在改,模型又在改),所以加 CAS——比 goal 的 revision 粗一些,但比 todo 的 last-wins 严。
同一份 append-only 账本,按需要选粒度。这不是三个独立设计,是 goal 那一章”全快照 vs delta vs CAS”选择题的不同答案。
把三件事的形状、fold 规则、闸门并排看,能看出它们是同一哲学的三个调参档:
| 维度 | plan-mode | todo/write | schedule/change |
|---|---|---|---|
| 事件形状 | {active: boolean} | {todos: TodoItem[]} | 版本化变更,带 operation + record |
| fold 结果 | {active, pending} 两布尔 | TodoItem[] | null(取最新) | active map + seenIds |
| 并发语义 | last-wins | last-wins | CAS(expected 整条对牌) |
| 写闸门 | active 是 boolean | content 非空 trim + 不重复 + 落 open turn | 整条转移流校验 |
| 能力缺位表现 | projection key 缺席(不是 false) | projection key 缺席 | projection key 缺席 |
| 广播事件 | 无 cordis 广播 | 无 cordis 广播 | schedule/changed(监听器失败 contained) |
注意”能力缺位 = key 缺席”是三件事共同的设计选择——没装这个包,projection 注册表就不挂这一项;客户端不会看到一个”默认值”。这把”未组合”与”显式关闭”区分开,跟 goal 的”无合法 descriptor → null”是同一条原则。
本节源码依据(3 处)
能力缺位:key 缺席,不是 false
三件事的投影注册都遵守同一条规矩——包没装上,projection key 根本不存在。这不是”返回默认值”,而是注册表层面的不挂项。
为什么不挂默认值?因为”未组合”与”显式关闭”是两种不同的世界状态。假设一个部署没装 plan-mode,客户端如果看到 {active: false},它会以为”plan 模式是关着的”——但事实是”这个部署根本没有 plan 模式这个能力”。两者在 UI 上的区别很大:前者允许切换,后者连入口都不该显示。
docstring 把它写得很直白:“Capability absence (plan-mode not composed) is the key’s absence, never a value.”——把缺席做成”键不在”,而不是”值为假”。这是从 schema 设计到客户端行为一以贯之的一条原则。
本节源码依据(2 处)
invariant 伴侣:各自的形状规矩
三个包都注册了自己的 invariant 伴侣,做写入闸门。规矩各自不同,但都是同一种模式:内部 dispatch 试算 → 事件公开才落账。
plan-mode 的闸门最薄——它的 docstring 写明 plan/mode 是 standalone whole-value,没有 turn 包裹关系(idle 选择在回合之间提交,回合中选择在步骤边界提交),所以只查一件事:active 必须是 boolean。
todo 的闸门多一层 turn 约束:todo/write 必须落在 open turn 里(turn/start 与 turn/end 之间)——会话外不许写待办。条目形状也查:content 非空且已 trim、status 必须三态之一、content 不许重复。但它故意不查 in_progress 的条数——docstring 解释得很清楚:那是部署配置 allowParallelInProgress 的策略,不是 durable 形状规矩;一个放宽策略时写下的日志,在收紧策略后必须仍能回放。把 invariant 绑到当前配置上,会把”当时合法”的历史判成非法。
这条”durable 形状 vs 部署策略”的分界线,是账本类包反复出现的设计原则。
本节源码依据(4 处)
schedule 的时间对齐:gaps skip,overlaps 取早
三种重复类记录(every/daily/weekly/cron)的 next 时刻不是简单加间隔,而是带对齐语义。类型注释里写了两条规矩:
- Gaps skip a date:如果一次触发错过了(进程没跑、机器关机),下一个触发日直接跳到下一个合法时间——不补触发。错过的就是错过,不补债。
- Overlaps use the earlier instant:如果一次编辑让两个合法时间撞在同一时刻,取较早的那个。
为什么”错过不补”?因为提醒是给”之后那个时刻”的——错过那一刻再追发,意义就反了。“取早不取晚”是并发编辑下的保守选择:宁可早到提醒用户,也不要因为编辑把提醒推迟。
every 类固定间隔还有一条硬下限:everySeconds 不低于一分钟。过短的间隔会让调度器被自己追着跑。
本节源码依据(2 处)
小结
- plan-mode 是开关:
{active, pending}两个布尔,能力缺位 = key 缺席。 - todo 是整条列表:TodoItem 无 id 无优先级,last-wins 一把替换。
- schedule 是版本化变更:六种 kind,CAS 更新,投递历史有上限。
- 粒度按频率选:写得少就小,写得频繁就粗,并发可能就加 CAS。
下一章离开单 agent 账本:第 32 章 子代理的血缘——subagent 怎么知道自己是谁的孩子、深度怎么限。
只读验证
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD # 应输出 477b4f420553e8a52c2fbccc464d7561b239c443
grep -n "Capability absence" "$repo/packages/plan/plan-mode/src/types.ts"
grep -n "replaced wholesale" "$repo/packages/todo/tool-todo/src/types.ts"
grep -n "ScheduleChange" "$repo/packages/schedule/schedule/src/types.ts"