第五部:Goal与多Agent——谁在掌舵

Goal不是Session上的currentGoal字段,而是事件流里追加的goal/change快照。从单个Agent的Goal控制,到Subagent父子树,再到Worker线程里的Workflow脚本编排——多Agent协作的每一步都有严格的边界。

一艘蓝鲸船在夜空星光下航行,船长握着罗盘站在甲板上(Goal),多条小船(Subagent)从母船出发执行任务,Worker Thread 潜水艇在水下,Workflow 旗帜在桅杆上飘扬。
展开阅读路线与实验入口

你以为 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。

Goal 以 event-sourced 方式管理 Phase/Revision/Round,Plan/Todo 驱动调度,Subagent 继承 lineage 经 parent mailbox 通信,Workflow 跑在独立 Worker 线程,多 Agent 按 child-first 顺序 teardown

所有证据来自固定 commit 的源码。实验命令只读不写,用 $DSH_SOURCE_DIR 指向官方 checkout。

本节源码依据(1 处)

从最反直觉的设计开始:Goal 为什么存成事件而不是字段。

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

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

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,你打开一个跑过 goal 的会话日志,想找”当前 goal 存在哪”。你翻遍 header、翻遍事件,找不到任何一个叫 currentGoal 的字段——因为它不存在。账本里只有一种 goal 事件 goal/change,而且每条都写着改完之后的完整样子:objective、phase、revision、maxGoalRounds,外加三个计数器。你要知道”现在 goal 是什么样”,唯一的办法是从第一条事件开始 fold。你以为状态是存出来的;在这套设计里,状态是算出来的——而且同一套折叠代码,在写入那一刻就已经把违规事件挡在门外了。

这一章是第五部的开场。前面四部讲的是账本本身怎么 append、怎么恢复、怎么分叉、怎么压缩;从这里开始,账本上长出了第一种”信念”——一个跨轮次坚持的目标。

Goal 事件流:会话日志按 seq 排列 goal/change(create rev1 active rounds=0)、user/messagesource.kind=goal, round=1、goal/change(edit rev2 phase 不变)、user/messageround=2、goal/change(pause rev3 active→paused);foldGoal 从空累加器起步逐条解码严格校验,同一套代码既跑 replay 又当 write gate;三个累加槽 state.goal(全快照替换、revision 恰好+1)、state.roundsStarted(只被 goal source 消息推进)、state.seenGoalIds(id 永不复用);输出分叉为落盘的 GoalProjection 与进程本地永不落盘的 activation;底部列出为什么是事件流的五条理由。

全快照变更:每次都写”改完之后的样子”

先看变更事件长什么样。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字段(精确键集合)折叠动作
creategoal 整快照 + 三计数器要求 rev1/active/rounds=0/无当前 goal 或已 complete/id 未见过
edit同上phase 与 blockedReason 不变,只换 objective/maxGoalRounds
pause/resume/complete/block同上各走相位表;resume 额外查轮次预算
clearcleared ref + clearedAtstate.goal 置空、roundsStarted 归零、lastRef 记墓碑
user/message{source.kind=goal}带 roundroundsStarted 推到该 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 变更方法都走同一套三步:

  1. prepareMutation:先 assertLive(agent)——不是”有这个 id”就放行,而是 ctx.agents.get(agent.id) === agent 严格同一实例(否则 GOAL_AGENT_NOT_LIVE);再读 sessionProjections.stateOf(session, 'goal')(注意它读的不是 fold 实时算的,而是投影注册表维护的那份 state);state.failure 非空就直接抛错——投影折叠失败过,目标领域拒绝在坏账上继续写。
  2. expectCurrent(ref):拿调用方给的 ref 跟投影里的当前 goal 对牌——id 不同或 revision 落后,抛”stale goal ref …; current is … revision …”(GOAL_STALE_REVISION)。这就是并发写的 CAS:两个 fiber 同时基于旧 revision 改,只有第一个 append 成功,第二个在对牌时就被挡。
  3. 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并发写基于旧 revisionGOAL_STALE_REVISION(消息同时报调用方与当前 revision)ref 与投影里的 goal
commitactivation 翻账 offset 被挪位pendingActivation offset ≠ append 返回 seqsession.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 处)

小结

  1. Goal 没有对象:账本里只有 goal/change 事件;当前态是 fold 出来的,不是存出来的。
  2. 全快照变更:每条 change 写改完后的完整样子;单条自洽,恢复无需重放 delta。
  3. fold 即校验:回放解码器同时是 append 期 write gate;历史形状由精确键集合锁死。
  4. 轮次数出来:goal source 的 user/message 推进 roundsStarted,且必须恰好是下一个 admitted round。
  5. 历史归日志,意图归进程: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"