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
第 0 秒,你试着把一个 paused 的目标直接 block 掉。报错消息不是模糊的”非法操作”,而是一句完整的判决:cannot block goal "goal-xxx" from phase "paused"; expected active。这条消息是写死在 transitionError 里的——它告诉你这条边不存在,而不是”你写错了”。Goal 的状态机不是一堆 if 拼的,它是一张列好边名、列好允许相位、列好动作副作用的表。
相位机:四条边,每条都有名字
先把整张表铺开。durable 相位只有四个:active / paused / blocked / complete。服务层的允许边是:
| 操作 | 当前相位必须是 | 目标相位 | activation 副作用 |
|---|---|---|---|
| pause | active | paused | disarm |
| resume | active / paused / blocked | active | armed(且预算未耗尽) |
| block | active | blocked | disarm + 写 blockedReason |
| complete | active / paused / blocked | complete | disarm |
| 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():
- 当前相位 ∈
{active, paused, blocked}; - 如果当前已经是 active 且 runtime.activation 已是 armed——直接拒(
goal "id" is already active and armed),避免重复武装; 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_LIVE | prepareMutation:agent 不在 registry | agent identity mismatch |
| NOT_FOUND | expectCurrent:state 为 null | no current goal |
| ALREADY_EXISTS | create:当前 goal 非 complete | goal already active |
| STALE_REVISION | expectCurrent:ref 与投影对不上 | stale goal ref; current is revision N |
| INVALID_OBJECTIVE | create/edit:objective 未 normalized | objective shape |
| MAX_ROUNDS | resume:roundsStarted ≥ maxGoalRounds | increase maxGoalRounds before resuming |
| BLOCK_REASON | block:code/message 不合规 | block reason shape |
| EDIT | edit:phase/blockedReason 漂移 | edit must not change phase |
| TRANSITION | transition:allowed 列表不含当前相位 | cannot X goal id from phase Y; expected A |
这套闭合集合让上层不必做字符串匹配——按错误码 switch,就能把”用户输入问题”和”并发冲突”和”状态机非法”分开处理。
本节源码依据(3 处)
小结
- 相位机是一张列好的表:四条边各有名字,fold 与服务层同一份语义双重校验。
- revision 是 CAS:每次 durable 变更恰好 +1,错牌 GOAL_STALE_REVISION,clear 也推 revision。
- resume 是唯一带预算检查的操作:耗尽即拒,edit 调大预算是正路。
- disarm 是默认副作用:pause/block/complete/clear 都 disarm,只有 resume armed。
- 时钟回拨被 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"