深入浅出 DeepSeek Harness 第五部:Goal与多Agent——谁在掌舵 第 31 章

目标之下的三件套——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 伴侣把关。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,模型跟你说”我先做个计划”。你以为它启动了一个复杂规划器——其实账本上只多了一条 plan/mode {active: true}。再写个待办?整条列表 todo/write {todos: [...]} 一把替换。再定个明天的提醒?一条 schedule/change 落账。这三件事共用 goal 那一章学到的全部哲学:当前态是 fold 出来的,不是存出来的;写是 append,不是字段赋值;schema 严格解码,写闸门在写入那一刻就关着。 只是它们各自选了一种最贴合自己形状的”快照粒度”。

三个投影并排:plan-mode 折叠成 active, pending 两个布尔;todo/write 整条列表 last-wins,TodoItem 只有 content 与三态 status;schedule 六种记录 kind + CAS 更新 + 三种 miss 码;底部共性——都注册会话投影、都走 append-only、都有 invariant 写闸门。

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五字段 cronexpression + 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-modetodo/writeschedule/change
事件形状{active: boolean}{todos: TodoItem[]}版本化变更,带 operation + record
fold 结果{active, pending} 两布尔TodoItem[] | null(取最新)active map + seenIds
并发语义last-winslast-winsCAS(expected 整条对牌)
写闸门active 是 booleancontent 非空 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 处)

小结

  1. plan-mode 是开关:{active, pending} 两个布尔,能力缺位 = key 缺席。
  2. todo 是整条列表:TodoItem 无 id 无优先级,last-wins 一把替换。
  3. schedule 是版本化变更:六种 kind,CAS 更新,投递历史有上限。
  4. 粒度按频率选:写得少就小,写得频繁就粗,并发可能就加 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"