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

Goal 的相位机、revision CAS 与轮次预算

phase 有四道门:active/paused/blocked/complete,每条转移边都写死在 fold 与 transition() 里——pause 只许 active→paused,resume 只许 active/paused/blocked→active 且轮次预算未耗尽,block 只许 active→blocked 并写 blockedReason,complete 从三种活相位到 complete。每次 durable 变更 revision 恰好 +1(withPhase 构造新快照),expectCurrent 拿调用方 ref 对牌,错了 GOAL_STALE_REVISION。clear 不删历史,写一条 revision+1 的墓碑。roundsStarted 与 maxGoalRounds 一比就知道预算是否耗尽;nextMutationTime 用 Math.max 钳住墙上时钟回拨。

Written in Chinese

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

第 0 秒,你试着把一个 paused 的目标直接 block 掉。报错消息不是模糊的”非法操作”,而是一句完整的判决:cannot block goal "goal-xxx" from phase "paused"; expected active。这条消息是写死在 transitionError 里的——它告诉你这条边不存在,而不是”你写错了”。Goal 的状态机不是一堆 if 拼的,它是一张列好边名、列好允许相位、列好动作副作用的表。

Goal 相位机:active 居中,左 paused、右 blocked、下 complete;pause 只许 active→paused,resume 从 active/paused/blocked 回 active 且预算未耗尽,block 只许 active→blocked 并写 blockedReason,complete 从三种活相位到 complete,clear 写 revision+1 墓碑把 goal 置空;侧栏两道计数器闸门:revision CAS 与 roundsStarted/maxGoalRounds 预算。

相位机:四条边,每条都有名字

先把整张表铺开。durable 相位只有四个:active / paused / blocked / complete。服务层的允许边是:

操作当前相位必须是目标相位activation 副作用
pauseactivepauseddisarm
resumeactive / paused / blockedactivearmed(且预算未耗尽)
blockactiveblockeddisarm + 写 blockedReason
completeactive / paused / blockedcompletedisarm
clear任意有 goal墓碑(goal 置空)disarm
edit任意有 goal不变保持 runtime.activation

注意几个反直觉的点:

  • paused 不能直接 block——必须先 resume 回 active,再 block。这是 fold 端写死的:requireSameDefinition 之后 switch 里 block 分支就认 current.phase !== 'active'。
  • complete 是单向门——没有 un-complete。想”复活”,按 create 规则:当前 goal 处于 complete 时允许新 create 替换。
  • edit 不动相位——fold 端 edit 分支显式校验 next.phase !== current.phase 与 blockedReason 深相等,任何相位漂移都拒。

transitionError 的错误格式是统一模板:cannot ${operation} goal "${id}" from phase "${current}"; expected ${allowed.join(' or ')}。这意味着任何一次非法转移,调用方拿到的都是一句能直接翻译给用户的话。

本节源码依据(5 处)

服务层 transition() 复用:一张 allowed 列表管所有边

pause 与 complete 都走同一个私有方法 transition(agent, ref, operation, allowed, phase, activation):prepareMutation → expectCurrent 对牌 → 检查 allowed.includes(current.phase) → 不满足就 transitionError → 满足就 commitCurrent 提交。

withPhase(current, phase) 是新快照的构造器:{id, revision: current.revision+1, objective, phase, maxGoalRounds}——只换 phase,其他字段原样搬。revision +1 在这里被硬编码进构造器,不会有”忘了 +1”的分支。

fold 端有同一张表的镜像实现——validateSnapshotTransition switch 里逐 case 校验。服务层挡写入请求,fold 挡坏日志:两道闸用同一份语义。

本节源码依据(4 处)

revision CAS:每次变更都是一次 compare-and-set

调用方每个变更方法都要传一个 ref: GoalRef——{id, revision}。服务层 expectCurrent 拿它跟当前投影对牌:

  • state 为 null → GOAL_NOT_FOUND(no current goal)
  • ref.id 不同,或 ref.revision 落后 → GOAL_STALE_REVISION,错误消息同时报”你手里是哪张牌”和”现在是哪张牌”。

这就是并发写的乐观锁。两个 fiber 同时基于 revision=3 改,第一个 commit 成功把 revision 推到 4;第二个走到 expectCurrent 时手里还是 3,直接被拒——它要么重读投影再试,要么把决定权交给上层。没有 last-write-wins 偷偷盖。

clear 也推 revision:tombstone 的 revision 是 current.revision + 1——所以墓碑本身也是”下一版”。fold 端 clear 分支同样过 requireNextRevision,墓碑 revision 不对也会拒。

本节源码依据(3 处)

轮次预算:resume 是唯一带预算检查的操作

resume 是服务层唯一一段手写的三条件检查,不走 transition():

  1. 当前相位 ∈ {active, paused, blocked};
  2. 如果当前已经是 active 且 runtime.activation 已是 armed——直接拒(goal "id" is already active and armed),避免重复武装;
  3. state.roundsStarted >= current.maxGoalRounds——预算耗尽,拒绝 resume,错误消息明确告诉用户:“increase maxGoalRounds before resuming”。

第 3 条是个纯函数判断:fold 出来的 roundsStarted 与快照里的 maxGoalRounds 一比。要绕开这个闸门,只有一条正路——edit 调大 maxGoalRounds(edit 不改相位、不动 blockedReason)。

为什么预算检查放在 resume 而不是每次轮次?因为”自动续轮”这个动作由 armed 状态驱动;预算耗尽时 goal 还可以是 active,但不再自动跑——这正是 resume 应该拒绝的时刻。fold 端 applyGoalEvent 也有镜像检查:轮次 user/message 必须不超过 maxGoalRounds。

本节源码依据(4 处)

activation 副作用矩阵:disarm 是默认,armed 只属于 resume

进程 activation 不是 durable 字段,但每个变更方法都决定它要不要被翻:

  • pause / block / complete / clear → disarm:任何”停”类动作都顺手把自动续轮关掉。
  • resume → armed:唯一翻成 armed 的动作。
  • edit → 保持 runtime.activation:改目标不改进程意图。

setActivation 只有在 runtime.activation 真的变了的时候才发 goal/activation-changed 广播——避免每次 mutation 都骚扰 UI。它还先查投影 state 是否注册、是否 failure——坏投影上不广播。

时间戳那一头,commitCurrent 调 nextMutationTime(state)——Math.max(Date.now(), state.updatedAt)。墙上时钟被 NTP 往回拨时,updatedAt 不跟着倒退;fold 端因此永远不会看到 updatedAt 倒退的脏事件。

本节源码依据(4 处)

推理段:为什么 paused 不能直接 block?

看起来多此一举——paused 的目标反正不会自动跑,block 它又何妨。但代码里没有这条边。理由藏在 activation 副作用里:pause 已经 disarm,block 再 disarm 一次是冗余动作;而 resume 是唯一 armed 的动作。如果 paused 可以直接 block,就出现一个问题:block 之后你怎么再把它带回 active?只能 resume——而 resume 的 resumable 集合本来就包含 blocked。换句话说:block 语义上是”在 active 时主动停下并记原因”,不是”把任何停住的状态再标记一下”。相位机上少一条边,换取 resume 路径的唯一。

同理,complete 不允许原地复活——complete 意味着目标已经验收过。要再做,就新建一个 goal(create 规则允许:当前 goal 是 complete 时可被替换)。这避免了”complete 之后偷偷又跑起来”这种审计噩梦。

blocked 相位:原因是落盘的,不是消息里的

block 写进去的不只是相位,还有一份 blockedReason: {code, message}——它随 GoalSnapshot 落盘。解码器对它有硬规矩:恰好 code+message 两字段、code 必须 lower-kebab-case、message 非空且已 trim。fold 端 decodeSnapshot 在 blocked 相位下要求七个字段,其他相位六个——多了少了都拒。

为什么 code 要机器可读?因为 resume 之外,UI 与上层策略可能想按 code 过滤(“把 round-limit 的 blocked goal 都列出来”)。message 是给人与模型看的解释。两者分开,是账本上常见的做法。

注意 edit 不改 blockedReason——fold 端 edit 分支深比对 JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)。想换原因?先 resume 再 block。

错误码一览:每个门都有独立牌子

GoalErrorCode 是一个闭合集合。把它摊开,能看出哪道门拦什么:

错误码触发点调用方拿到的消息关键词
NOT_LIVEprepareMutation:agent 不在 registryagent identity mismatch
NOT_FOUNDexpectCurrent:state 为 nullno current goal
ALREADY_EXISTScreate:当前 goal 非 completegoal already active
STALE_REVISIONexpectCurrent:ref 与投影对不上stale goal ref; current is revision N
INVALID_OBJECTIVEcreate/edit:objective 未 normalizedobjective shape
MAX_ROUNDSresume:roundsStarted ≥ maxGoalRoundsincrease maxGoalRounds before resuming
BLOCK_REASONblock:code/message 不合规block reason shape
EDITedit:phase/blockedReason 漂移edit must not change phase
TRANSITIONtransition:allowed 列表不含当前相位cannot X goal id from phase Y; expected A

这套闭合集合让上层不必做字符串匹配——按错误码 switch,就能把”用户输入问题”和”并发冲突”和”状态机非法”分开处理。

本节源码依据(3 处)

小结

  1. 相位机是一张列好的表:四条边各有名字,fold 与服务层同一份语义双重校验。
  2. revision 是 CAS:每次 durable 变更恰好 +1,错牌 GOAL_STALE_REVISION,clear 也推 revision。
  3. resume 是唯一带预算检查的操作:耗尽即拒,edit 调大预算是正路。
  4. disarm 是默认副作用:pause/block/complete/clear 都 disarm,只有 resume armed。
  5. 时钟回拨被 Math.max 钳住:updatedAt 不倒退,fold 不会见到脏时间。

下一章看 goal 之上的调度三件套:第 31 章 plan、todo、schedule 怎么把目标切成可执行步骤、再排成时间线。

只读验证

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD   # 应输出 477b4f420553e8a52c2fbccc464d7561b239c443

# 相位错误模板
grep -n 'cannot \${operation} goal' "$repo/packages/goal/goal/src/index.ts"
# 预算拒绝
grep -n "exhausted.*goal rounds" "$repo/packages/goal/goal/src/index.ts"
# 时钟钳制
grep -n "Math.max(Date.now(), state.updatedAt)" "$repo/packages/goal/goal/src/index.ts"