Phase、Revision、Round——三道演化闸门
GoalPhase四种持久化状态(active/paused/blocked/complete) vs GoalActivation两种进程本地状态(armed/disarmed)完全分离。Revision CAS:非create/clear要求revision=current+1。Round通过user/message的source.kind='goal'携带round号推进,不通过goal/change。block需blockedReason是策略驱动,pause是用户主动。
如果把 Goal 当成一个简单的 status 字段,后面会很快读歪:active 不是随手赋值,paused 也不是一个普通枚举;revision 不是随便加的版本号,round 也不是 turn 计数器。
错。
Goal 有三道独立的演化闸门:Phase(持久化状态机)、Revision(CAS 乐观锁)、Round(执行轮次)。这三道闸门互相独立,每一道都有严格的校验规则,任何一步非法都让 fold 失败、拒绝恢复。
更反直觉的是:Phase 里的 active 不代表它在跑。因为还有一个进程本地的 Activation 状态——persisted active 和 process-local armed 是两回事,完全分离。
别把 Phase / Revision / Round 当成一个 status 字段的三种写法,它们是三道互不相干的闸。
- Phase 管持久化状态:active、paused、blocked、complete。
- Revision 管变更有没有接在上一版后面:它就是一个 CAS。
- Round 管目标已经推进了几轮:它不是靠 goal/change 递增,而是靠 goal-source 的 user/message 侧向推进。
先把这三件事分开,再去看 active 为什么不等于 armed、resume 为什么能从 active 重新开始、block 为什么和 pause 不是一回事,脑子里就不会把它们搅成一锅。
Phase:持久化状态机,四条合法路径
GoalPhase 有四种值,全部持久化在 goal/change 事件里:
| Phase | 含义 | 能转换到 |
|---|---|---|
| active | 目标活跃(但不一定 armed) | pause, block, complete, resume(自己) |
| paused | 用户主动暂停 | resume |
| blocked | 策略阻塞(需要外部条件满足) | resume |
| complete | 目标完成 | (终态,只能 create 新 Goal 覆盖) |
合法转换只有这几条,没有其他路径:
stateDiagram-v2
[*] --> active: create (rev=1)
active --> paused: pause
active --> blocked: block (需reason)
active --> complete: complete
paused --> active: resume
blocked --> active: resume
active --> active: resume (disarmed→armed)
complete --> [*]: (终态)
注意几个关键点:
complete 是终态。 一旦 complete,不能 resume、不能 pause、不能 block。只能 create 一个新 Goal(新 id)覆盖它。
resume 能从 active 开始。 这看起来奇怪——已经是 active 了为什么还要 resume?因为 active 是持久化 phase,而 armed 是进程本地 activation。session 重启后 phase 可能还是 active,但 activation 是 disarmed。resume 在这种情况下不改变 phase,只把 activation 从 disarmed 拨到 armed。
edit 不改变 phase。 validateSnapshotTransition 里明确检查:edit 操作时 next.phase 必须等于 current.phase,也不能改 blockedReason。edit 只改 objective 和/或 maxGoalRounds,不碰状态。
block vs pause 是完全不同的语义。
- pause:用户主动暂停。比如用户说”先停一下,我改点东西”。不需要原因,随时可以 resume。
- block:策略驱动的阻塞。比如 Agent 发现缺少必要信息、需要用户审批、遇到权限问题。必须携带 blockedReason:
{code: string, message: string}。code 是稳定的机器可读标识符,message 是给人看的解释。
fold 里对每种 operation 都做严格的 phase 检查。比如 pause 要求 current.phase === ‘active’ 且 next.phase === ‘paused’;block 同样要求 current 是 active。如果你尝试从 paused 直接 block——不行,先 resume 到 active 才能 block。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "case 'pause'" "$repo/packages/goal/goal/src/fold.ts" -B2 -A8
grep -n "case 'block'" "$repo/packages/goal/goal/src/fold.ts" -B2 -A8
grep -n "case 'resume'" "$repo/packages/goal/goal/src/fold.ts" -B2 -A10
Revision:CAS 乐观锁,每次突变精确 +1
Revision 是 Goal 的乐观锁。每个 GoalSnapshot 都有一个 revision 号,每次 mutation(包括 clear)都必须精确推进一版——next.revision === current.revision + 1。
requireNextRevision(current, next, operation) 做这个检查:
- create:必须 revision=1。不能创建一个 revision=2 或 revision=0 的 Goal。
- edit/pause/resume/complete/block:必须 revision = current.revision + 1。
- clear:tombstone 的 revision 必须 = current.revision + 1。
没有跳过、没有回退、没有跳两版。每次 mutation 恰好 +1,不管是改内容还是改状态还是清除。
这是典型的 CAS(Compare-And-Swap)模式。API 层的所有操作都要求你传一个 GoalRef {id, revision}——这是你”认为”当前 Goal 的版本。expectCurrent 方法检查:
if (ref.id !== current.id || ref.revision !== current.revision) {
throw new GoalError(`stale goal ref ... revision ${ref.revision}; current is ... revision ${current.revision}`, 'GOAL_STALE_REVISION')
}
如果你传的 revision 和当前实际的不匹配,说明在你读和写之间有别的 mutation 已经发生了——你的操作基于过时数据,直接拒绝,抛 GOAL_STALE_REVISION。调用方需要重新读当前状态,基于新的 revision 重试。
为什么用 whole-value 快照 + CAS,而不是 delta 补丁?因为简单。whole-value 快照不需要复杂的合并逻辑——last-wins 直接替换。CAS 保证没有两个 writer 同时修改而丢失更新。delta 补丁在并发场景下可能需要 CRDT 或 OT,复杂度高得多,对于 Goal 这种低频操作完全没必要。
clear 也推进 revision 是个细节。tombstone 有自己的 revision = current + 1。这让你能引用”rev=5 的 clear 操作”,也让 fold 时能验证 clear 确实是紧跟在 rev=4 之后的。
Round:不通过 goal/change 推进,在用户消息里携带
Round 是最容易理解错的。你可能以为:每完成一轮(一个 turn),goal/change 里就会有个 round+1 的操作。错。
Round 不通过 goal/change 事件递增。它通过 user/message 事件 的 source.kind === 'goal' 携带 round 号推进。
具体来说:当 Goal 驱动自动续跑时,系统会注入一个 user/message,这个消息的 source 是:
{
kind: 'goal',
goalId: string,
revision: number,
round: number // 这是要推进到的 round
}
foldGoal 在处理 user/message 时,如果检测到 source.kind === ‘goal’,就做严格校验:
- 当前有 goal(不是 undefined)
- current.phase === ‘active’
- source.goalId === current.id(是给当前 Goal 的)
- source.revision === current.revision(revision 匹配)
- source.round === state.roundsStarted + 1(正好是下一轮,不能跳)
- source.round ≤ current.maxGoalRounds(不超过预算)
全部通过才执行 state.roundsStarted = source.round。
为什么这么设计?因为 Round 的语义是”模型开始执行第 N 轮”。这不是一个独立的 Goal 状态变更——它是由一个用户消息(来自 Goal 自动注入)触发的。Round 推进和消息投递是原子的:你看到这个 round=N 的消息,就说明第 N 轮开始了。不需要两个事件(一个 goal/round-start,一个 user/message)——消息本身就携带了 round 信息。
create 时要求 roundsStarted === 0。maxGoalRounds 默认是 256,这是防止 Goal 死循环的硬上限。resume 时检查 roundsStarted >= maxGoalRounds——如果轮次耗尽了,不能 resume,得先 edit 调大 maxGoalRounds。
Activation vs Phase:持久化和进程本地完全分离
这是最容易踩的坑。再强调一遍:
| 维度 | Phase | Activation |
|---|---|---|
| 值 | active/paused/blocked/complete | armed/disarmed |
| 持久化? | 是(在 goal/change 里) | 否(只在内存) |
| 跨 resume 存在? | 是 | 否(session-start 强制 disarmed) |
| 表示什么 | Goal 的持久化生命周期状态 | 当前进程是否自动续跑 |
phase=active 但 activation=disarmed 是完全合法且常见的状态。比如:
- Session 刚启动恢复了一个 active Goal,但 activation 是 disarmed(等用户显式 resume)
- Goal 被 pause 后又 resume——phase 从 paused→active,activation 从 disarmed→armed
- 远程进程 append 了一个 resume 的 goal/change——你本地 fold 到 phase=active,但 sync 逻辑把 activation 设为 disarmed(因为不是你本地 pending 的)
resume 方法里有这段逻辑:
if (current.phase === 'active' && cache.activation === 'armed') {
throw new GoalError(`goal "${current.id}" is already active and `armed, 'GOAL_INVALID_TRANSITION')
}
如果已经是 active+armed 再 resume 就报错。但如果是 active+disarmed(比如刚重启),resume 合法——不改变 phase(还是 active),只把 activation 设为 armed,revision 照样 +1。这就是为什么 resume 可以从 active 开始——它改变的是 activation,不是 phase。
flowchart TD
subgraph "持久化 (跨进程/重启)"
P1["phase: active"]
P2["phase: paused"]
P3["phase: blocked"]
P4["phase: complete"]
end
subgraph "进程本地 (内存)"
A1["armed"]
A2["disarmed"]
end
P1 -->|pause| P2
P2 -->|resume| P1
P1 -->|block| P3
P3 -->|resume| P1
P1 -->|complete| P4
A1 -->|pause/block/complete/session-start| A2
A2 -->|resume| A1
P1 -.-> A1
P1 -.-> A2
style P1 fill:#1a3a5c,color:#fff
style A2 fill:#8b4513,color:#fff
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "requireNextRevision" "$repo/packages/goal/goal/src/fold.ts" -B2 -A10
grep -n "GOAL_STALE_REVISION" "$repo/packages/goal/goal/src/index.ts" -B3 -A3
容易踩的坑
坑一:以为 phase=active 就会自动跑。 不会。还要看 activation 是不是 armed。session 刚启动时一定是 disarmed。你看到 UI 上 Goal 显示”active”,但它不会自己动——除非你点 resume。
坑二:CAS 失败 GOAL_STALE_REVISION。 这不是 bug,是正常的并发保护。你拿 ref={id, rev=2} 去操作,但当前已经是 rev=3 了——说明别人改了。重新 get() 拿最新 ref 再操作。
坑三:尝试直接修改 roundsStarted。 没有 API 让你直接 set round。Round 只能通过 Goal 自动注入的 user/message 推进。如果你想”手动推进一轮”,那不是 Goal 的工作方式——Goal 驱动自动续跑,你发普通消息就是普通 turn,不占用 Goal round。
坑四:混淆 pause 和 block。 pause 是用户按的暂停键。block 是 Agent 自己说”我卡住了需要帮助”,必须带原因。不要把 block 当 pause 用,也不要把 pause 当 block 用——UI 展示不同,恢复逻辑也可能不同。
坑五:complete 之后想 resume。 complete 是终态。一旦 complete,这个 Goal 就结束了,不能 resume、不能 edit、不能 block。要继续工作?create 一个新 Goal。
坑六:resume 时说 round budget exhausted。 roundsStarted >= maxGoalRounds 了。Goal 跑完了预算轮次还没 complete。这不是错误——是 Goal 防止无限循环的保护。你可以 edit 调大 maxGoalRounds 再 resume,或者 complete/clear 这个 Goal。
坑七:以为 createdAt/updatedAt/roundsStarted 会在非 create 操作里变。 不会。validateSnapshotTransition 明确检查:edit/pause/resume/complete/block 这些操作不改变 createdAt、不能让 updatedAt 倒退、也不改变 roundsStarted。只有 create 和 clear 重置这些字段;只有 goal-source user/message 推进 roundsStarted。
Goal 的状态机锁死了。但你在写 Agent 时,还会遇到三个和 Goal 经常一起出现的邻居:Plan Mode、Todo、Schedule。它们和 Goal 是什么关系?下一章对比给你看。