第五部:Goal与多Agent——谁在掌舵
Goal不是Session上的currentGoal字段,而是事件流里追加的goal/change快照。从单个Agent的Goal控制,到Subagent父子树,再到Worker线程里的Workflow脚本编排——多Agent协作的每一步都有严格的边界。
展开阅读路线与实验入口
你以为 Goal 就是 Session 对象上一个叫 currentGoal 的字段,改它就直接赋值,清掉就设 null。你以为多 Agent 就是 new Agent() 然后 Promise.all 跑完拉倒。你以为 Workflow 就是模型输出一堆 tool call 然后按顺序执行。
错。
在 DeepSeek Harness 里,Goal 是追加在事件流里的完整快照。Subagent 有严格的深度限制、父子邮箱、cold resume 路径。Workflow 跑在独立的 Worker 线程沙箱里,脚本不是模型生成的,是预定义的。
这一部讲谁在掌舵——从单个 Goal 的事件溯源,到多 Agent 树的生命周期,再到 Worker 线程里的脚本编排。
这一部解决什么
读完这七章,你能回答七个关键问题:
如果你想最快抓住第五部的骨架,可以先按这个顺序走:先看 Goal 为什么是事件流,再看 Phase / Revision / Round 三道闸门,然后看 Plan / Todo / Schedule 这三个邻居,最后再进入 Subagent、Parent Mailbox、Workflow 和清理顺序。第五部真正难的不是名词多,而是持久化状态、进程内状态、父子关系和执行流程这几层很容易互相串线。
- Goal 为什么不是字段而是事件,7种操作为什么都追加 goal/change
- whole-value 快照和 tombstone 怎么配合,foldGoal 纯函数怎么 replay 验证
- Phase、Revision、Round 三道闸门怎么防止状态漂移和时钟回拨
- Plan Mode、Todo、Schedule 这三个邻居和 Goal 到底有什么区别
- Subagent 怎么出生,父子关系和深度限制怎么执行,policy 怎么快照
- Parent Mailbox 怎么接住子 Agent 结果,notifySettlement 顺序为什么关键
- Workflow 在 Worker 线程里怎么跑,fatal/non-fatal 错误怎么区分
- Cancel 和 Dispose 的方向为什么相反,多 Agent 树怎么安全清理
你可能在别的框架里见过 agent.goal = "fix bug" 这种直接赋值。Harness 不这么做。每次改 Goal 都追加一个完整的 GoalSnapshot,clear 写 tombstone,id 永远不能重用——即使 clear 之后也不行。Activation 是进程本地的 armed/disarmed,绝不持久化,session-start 强制 disarmed。
所有证据来自固定 commit 的源码。实验命令只读不写,用 $DSH_SOURCE_DIR 指向官方 checkout。
本节源码依据(1 处)
从最反直觉的设计开始:Goal 为什么存成事件而不是字段。
Goal 不是状态对象,是一串可折叠的事件
会话里没有"当前 goal"这个字段,只有 goal/change 事件——每个变更事件都携带改完之后的完整 GoalSnapshot,外加 roundsStarted/createdAt/updatedAt 三个计数器;clear 是一条墓碑。恢复时 foldGoal 从空累加器逐条解码:精确键集合、revision 恰好 +1、轮次 user/message 严格是下一个 admitted round。这套解码器同时是 append 期的 write gate。activation(armed/disarmed)是进程本地态,故意不落盘。
Written in Chinese
第 0 秒,你打开一个跑过 goal 的会话日志,想找”当前 goal 存在哪”。你翻遍 header、翻遍事件,找不到任何一个叫 currentGoal 的字段——因为它不存在。账本里只有一种 goal 事件 goal/change,而且每条都写着改完之后的完整样子:objective、phase、revision、maxGoalRounds,外加三个计数器。你要知道”现在 goal 是什么样”,唯一的办法是从第一条事件开始 fold。你以为状态是存出来的;在这套设计里,状态是算出来的——而且同一套折叠代码,在写入那一刻就已经把违规事件挡在门外了。
这一章是第五部的开场。前面四部讲的是账本本身怎么 append、怎么恢复、怎么分叉、怎么压缩;从这里开始,账本上长出了第一种”信念”——一个跨轮次坚持的目标。
全快照变更:每次都写”改完之后的样子”
先看变更事件长什么样。goal/change 有两种 shape:
- 快照变更(create/edit/pause/resume/complete/block):字段是
{kind:'goal/change', version:1, operation, goal: GoalSnapshot, roundsStarted, createdAt, updatedAt}。goal是完整快照——不是”把 phase 从 active 改成 paused”这种 delta,而是改完之后 goal 的全部字段。 - 墓碑(clear):
{kind:'goal/change', version:1, operation:'clear', cleared: GoalRef, clearedAt}。不写 goal,只写被清掉的引用。
解码器对两种 shape 都执行精确键集合检查:把字段名排序后拼字符串,跟允许的清单逐字比对——多一个字段、少一个字段,都抛错。快照的 goal 同样如此:普通相位是六个字段(id/maxGoalRounds/objective/phase/revision),blocked 相位多一个 blockedReason,且 blockedReason 自己必须恰好是 code+message 两个字段,code 必须是 lower-kebab-case,message 必须是非空且已 trim。
为什么用全快照而不是 delta? delta 序列折叠到一半崩溃,你就得面对”不知道哪个操作没做完”;全快照让单条变更事件自洽——任何一条 goal/change 单独拿出来,都能回答”这一刻 goal 长什么样”。代价是冗余(objective 每次都重写一遍),换来的是恢复时不需要重放 delta 链、不需要事务中间态。对一个长寿命、要被反复 resume 的账本来说,这个交换是值的。
本节源码依据(5 处)
fold 即校验:同一段代码管回放和写入
foldGoal(events) 是个纯函数:从 emptyGoalFoldState() 出发,逐条 applyGoalEvent。它对日志里两类事件敏感:
goal/change:解码后按操作类型做相位转移校验,然后整个替换 state.goal。user/message:如果它的 source 是{kind:'goal', goalId, revision, round},就尝试推进 roundsStarted。
关键在折叠规则之严。非 create 的快照变更必须过 requireNextRevision:id 相同、revision 恰好 +1;过 requireSameDefinition:objective 与 maxGoalRounds 没变;过计数器守恒:createdAt 不变、updatedAt 不倒退、roundsStarted 不变。相位转移逐操作列表:pause 只许 active→paused,block 只许 active→blocked,complete 只许从非 complete 到 complete,resume 只许从 active/paused/blocked 回 active 且轮次预算没耗尽。
而这套解码器不是事后审计——它就是写入闸门。goal-invariant 伴侣在 internal/dispatch 阶段克隆一份累加器试算这条候选事件,通过了才在 session/event 公开时把账落上;试算失败,直接 fail('session event N violates the durable goal stream: ...')。回放时读出来的规则,和写入时挡下来的规则,是同一段代码——不存在”恢复时才发现日志写错了”的缝隙。
| change shape | 字段(精确键集合) | 折叠动作 |
|---|---|---|
| create | goal 整快照 + 三计数器 | 要求 rev1/active/rounds=0/无当前 goal 或已 complete/id 未见过 |
| edit | 同上 | phase 与 blockedReason 不变,只换 objective/maxGoalRounds |
| pause/resume/complete/block | 同上 | 各走相位表;resume 额外查轮次预算 |
| clear | cleared ref + clearedAt | state.goal 置空、roundsStarted 归零、lastRef 记墓碑 |
user/message{source.kind=goal} | 带 round | roundsStarted 推到该 round(必须恰好是下一个) |
本节源码依据(5 处)
轮次是数出来的,不是写出来的
注意一个反直觉的设计:roundsStarted 不是 goal 快照里的字段,而是一个独立计数器,靠 user/message 事件推进。折叠规则卡得很死——一条 goal source 的 user/message 要被接受,必须同时满足:当前 goal 存在且 active、source.goalId 就是当前 goal、source.revision 就是当前 revision、source.round 恰好等于 roundsStarted+1、且不超过 maxGoalRounds。任何一条不满足,抛”goal round at session event N is not the next admitted round”。
这意味着第 N 轮这个数,是从日志里一条一条数出来的——不是某个服务端自增计数器。好处是轮次本身变成了可审计的:你随时能指着某条 user/message 说”这就是第 3 轮”。轮次预算(maxGoalRounds)的耗尽判断也因此是纯函数:fold 到最后,roundsStarted 与 maxGoalRounds 一比就知道。
为什么不让 goal/change 自己带 roundsStarted 自增? 因为”开了一轮”这个动作的语义,是模型真的发了一条消息——它属于对话流,不属于目标元数据。把它拆成消息归属事件,目标的变更事件保持纯元数据,轮次的归属保持在对话里,两条流各自自洽。
本节源码依据(3 处)
服务边界:对牌、写入、广播三步走
fold 是纯的,服务层是薄的。每个 goal 变更方法都走同一套三步:
- prepareMutation:先
assertLive(agent)——不是”有这个 id”就放行,而是ctx.agents.get(agent.id) === agent严格同一实例(否则 GOAL_AGENT_NOT_LIVE);再读sessionProjections.stateOf(session, 'goal')(注意它读的不是 fold 实时算的,而是投影注册表维护的那份 state);state.failure 非空就直接抛错——投影折叠失败过,目标领域拒绝在坏账上继续写。 - expectCurrent(ref):拿调用方给的 ref 跟投影里的当前 goal 对牌——id 不同或 revision 落后,抛”stale goal ref …; current is … revision …”(GOAL_STALE_REVISION)。这就是并发写的 CAS:两个 fiber 同时基于旧 revision 改,只有第一个 append 成功,第二个在对牌时就被挡。
- commit:构造 change meta,先在 runtime 上挂一个
pendingActivation {offset: session.seq, activation},再session.append('goal/change', change)——append 返回的事件 seq 恰好等于挂账时的 offset,才把进程 activation 翻成新值;finally 里清掉挂账。这个 offset 对比防的是:append 被表面 replace 等机制挪了位置时,activation 不跟着错翻。
广播是最后一步:append 落账后重新读投影,组一条 GoalChanged {operation, ref, goal?} 发 goal/changed——监听器失败是 contained 的,不会反过来影响已经落盘的事件。
三步职责切得很干净:
| 步骤 | 拦什么 | 失败错误码 | 读什么 |
|---|---|---|---|
| prepareMutation | 实例已死 / 投影折叠失败过 | GOAL_AGENT_NOT_LIVE / state.failure 抛出 | sessionProjections 的 goal state |
| expectCurrent | 并发写基于旧 revision | GOAL_STALE_REVISION(消息同时报调用方与当前 revision) | ref 与投影里的 goal |
| commit | activation 翻账 offset 被挪位 | pendingActivation offset ≠ append 返回 seq | session.append 返回的事件 seq |
本节源码依据(6 处)
落盘与不落盘的分界线
GoalProjection 是 fold 出来的 durable 部分:goal 快照、roundsStarted、createdAt、updatedAt。它被注册成会话投影 goal——resume 后任何进程 fold 一遍日志,得到的都是同一个它。
但 activation(armed/disarmed:本进程是否自动续轮)不在投影里。它是 GoalActivationChanged 事件里单独广播的进程本地态。docstring 写明:“Activation is process-local (never persisted) and deliberately absent — the projection reflects durable phase only.”。
这条分界线是有意的:phase(active/paused/blocked/complete)是目标的历史事实,必须跨进程一致;activation 是”这个进程现在想不想自动跑”,属于调度决策,换个进程可以做不同决定——比如恢复一个 paused 目标,旧进程是 disarmed 的,新进程可以重新武装。历史归日志,意图归进程。
本节源码依据(4 处)
小结
- Goal 没有对象:账本里只有 goal/change 事件;当前态是 fold 出来的,不是存出来的。
- 全快照变更:每条 change 写改完后的完整样子;单条自洽,恢复无需重放 delta。
- fold 即校验:回放解码器同时是 append 期 write gate;历史形状由精确键集合锁死。
- 轮次数出来:goal source 的 user/message 推进 roundsStarted,且必须恰好是下一个 admitted round。
- 历史归日志,意图归进程:phase 落盘,activation 进程本地。
下一章拆开看 fold 里那张相位表:第 30 章 Goal 的 phase、revision、round 三个数各自怎么被推进、又怎么被卡。
只读验证
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD # 应输出 477b4f420553e8a52c2fbccc464d7561b239c443
# 精确键集合解码
grep -n "must have exactly" "$repo/packages/goal/goal/src/fold.ts"
# revision CAS
grep -n "must advance the current goal by one revision" "$repo/packages/goal/goal/src/fold.ts"
# 轮次严格性
grep -n "not the next admitted round" "$repo/packages/goal/goal/src/fold.ts"