第五部:Goal与多Agent——谁在掌舵
Goal不是Session上的currentGoal字段,而是事件流里追加的goal/change快照。从单个Agent的Goal控制,到Subagent父子树,再到Worker线程里的Workflow脚本编排——多Agent协作的每一步都有严格的边界。
![[图片占位:一艘蓝鲸船在海上航行,船长(Goal)拿着罗盘站在甲板上,多个小船(Subagent)从母船出发执行任务,Worker Thread潜水艇在水下。Workflow旗帜飘扬。夜空+星光导航感。色调:深蓝+金色罗盘光。]](/static/images/handbook/deepseek-harness-internals/parts/05-goal-multi-agent.png)
展开阅读路线与实验入口
你以为 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。
accTitle: 第五部阅读路径
accDescr: Goal 以 event-sourced 方式管理 Phase/Revision/Round,Plan/Todo 驱动调度,Subagent 继承 lineage 经 parent mailbox 通信,Workflow 跑在独立 Worker 线程,多 Agent 按 child-first 顺序 teardown
accDescription: 第五部阅读路径流程图,从 Goal 事件流开始,经过三道闸门(Phase/Revision/Round)、三个邻居(Plan/Todo/Schedule),然后到 Subagent 出生、Parent Mailbox、Workflow Worker,最后收束到 Cancel top-down 和 Dispose child-first 的安全清理。
flowchart TD
A["Goal 事件流\ngoal/change 快照 + tombstone"] --> B["三道闸门\nPhase / Revision / Round"]
B --> C["三个邻居\nPlan / Todo / Schedule"]
A --> D["Subagent 出生\ndepth / lineage / policy snapshot"]
D --> E["Parent Mailbox\nreport / wakeup / settlement"]
A --> F["Workflow Worker\nvm 沙箱 / parallel / pipeline"]
E --> G["收束清理\nCancel top-down / Dispose child-first"]
F --> G
style A fill:#1a3a5c,stroke:#d4af37,color:#fff
style G fill:#1a3a5c,stroke:#d4af37,color:#fff
所有证据来自固定 commit 的源码。实验命令只读不写,用 $DSH_SOURCE_DIR 指向官方 checkout。
从最反直觉的设计开始:Goal 为什么存成事件而不是字段。
Goal 为什么是事件流而不是字段
Goal 状态不是 Session 上的可变字段,而是通过 goal/change 事件流进行事件溯源。GoalFoldState 是 fold 纯函数对事件序列的归约投影,Phase/Revision/Round 三层演化控制保证状态机的确定性。每个 goal/change 事件携带完整快照(whole-value),投影只需 last-wins 而无需字段合并。
你打开 Session 对象,找 currentGoal 字段——毕竟大多数框架里 Goal 就是 Session 上的一个可变属性,想改就改,想清就清。但在 Harness 里,你翻遍 Session 的公开接口也找不到任何 Goal 字段。因为 Goal 的状态根本不以字段形式存在。
Goal 是事件流。你看到的”当前 Goal”,是一个纯函数对 session 日志中所有 goal/change 事件做 fold(归约)后得到的投影。没有任何地方存着”当前值”可以直接覆盖。这就是事件溯源(event sourcing):历史永远在,状态是算出来的,不是存出来的。
下面从机制层面把这件事拆开:goal/change 事件长什么样,GoalFoldState 怎么累加,foldGoal 这类纯函数怎么归约,以及 Phase / Revision / Round 三层演化控制怎样一起保证状态机确定。
这一章别先盯着 foldGoal、GoalFoldState 这些名字。先认准一件事:Goal 的“当前状态”不是存在哪个字段里,而是把历史 goal/change 事件 replay 一遍之后折出来的结果。
这一点一旦站稳,后面几件事就都顺了:
goal/change为什么写 whole-value 快照——因为投影要的是 last-wins,不是字段级 merge。- clear 为什么是 tombstone——因为在事件流世界里,“清空”也得是一条可重放、可审计的事实。
- 为什么后面还要单独讲 Phase / Revision / Round——因为当前 Goal 既然是 fold 出来的,那演化规则也得靠事件内容本身来保住确定性。
这一章讲的是 Goal 这套东西的底账。下一章再去看三道闸门,就会顺很多。
一、Goal 不是字段——是 session 日志里的事件序列
打开 packages/core/session/src/known-event-types.ts,你能看到 'goal/change' 注册在全局事件类型表里:
export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
// ...数十种事件类型...
'goal/change',
// ...
])
这意味着 Goal 的每一次状态变更——创建、编辑、暂停、恢复、完成、阻塞、清除——都是往 session 的 append-only 事件日志里追加一条 goal/change 事件。没有 session.goal = xxx 这种赋值操作。
Goal 模块通过两个 declaration merging 声明了事件溯源的类型合同:SessionEventMap 中 'goal/change' 的 payload 类型是 GoalChangeMeta;SessionProjectionMap 中 'goal' 的投影结果是 GoalProjection | null。
二、GoalChangeMeta——whole-value 快照与 tombstone
每个 goal/change 事件的 payload 是一个 GoalChangeMeta,它是一个 discriminated union:
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
快照变体(非 clear 操作)携带完整的 post-mutation 状态:
export interface GoalSnapshotChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: Exclude<GoalOperation, 'clear'>
readonly goal: GoalSnapshot // 完整快照,不是 diff
readonly roundsStarted: number
readonly createdAt: number
readonly updatedAt: number
}
tombstone 变体(clear 操作)只携带身份标记:
export interface GoalClearChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: 'clear'
readonly cleared: GoalRef // {id, revision}
readonly clearedAt: number
}
关键洞察:非 clear 的 goal/change 事件携带的是 whole-value 快照,不是 delta。每次 mutation 都把 Goal 的完整当前状态全量写入事件。这意味着投影逻辑极其简单——last-wins,最后一个快照就是当前状态,不需要做字段合并或 delta 叠加。
七种 GoalOperation 对应七种 verb:
| 操作 | GoalPhase 约束 | 写入形式 |
|---|---|---|
| create | 新 Goal,phase=active,revision=1 | 完整 GoalSnapshot |
| edit | phase 不变,objective 或 maxGoalRounds 变 | 完整 GoalSnapshot |
| pause | active -> paused | 完整 GoalSnapshot |
| resume | active/paused/blocked -> active | 完整 GoalSnapshot |
| complete | 非 complete -> complete | 完整 GoalSnapshot |
| block | active -> blocked,带 blockedReason | 完整 GoalSnapshot |
| clear | 清除当前 Goal | tombstone {id, revision} |
flowchart TD
subgraph "append-only session log"
E1["goal/change: create\ngoal={id:'g-1', rev:1, phase:'active'}\nroundsStarted:0"]
E2["goal/change: edit\ngoal={id:'g-1', rev:2, phase:'active'}\nroundsStarted:0"]
E3["goal/change: pause\ngoal={id:'g-1', rev:3, phase:'paused'}\nroundsStarted:2"]
E4["goal/change: resume\ngoal={id:'g-1', rev:4, phase:'active'}\nroundsStarted:2"]
E5["goal/change: clear\ncleared={id:'g-1', rev:5}"]
end
E1 --> E2 --> E3 --> E4 --> E5
E5 --> FOLD["foldGoal(events)"]
FOLD --> RESULT["FoldedGoal:\ngoal: undefined\nroundsStarted: 0\nlastRef: {id:'g-1', rev:5}"]
style FOLD fill:#1a3a5c,stroke:#d4af37,color:#fff
style RESULT fill:#2d5016,color:#fff
三、GoalFoldState——纯 fold 累加器
投影的核心数据结构是 GoalFoldState:
export interface GoalFoldState {
goal: GoalSnapshot | undefined // 当前快照(last-wins)
roundsStarted: number // 已开始的 round 数
createdAt: number | undefined // 创建时间戳
updatedAt: number | undefined // 最后更新时间戳
lastRef: GoalRef | undefined // 最后一个 change 的 ref
seenGoalIds: Set<GoalSnapshot['id']> // 历史所有 Goal id(防重用)
}
emptyGoalFoldState() 返回空的初始累加器,所有值为 undefined/0/空 Set。这个累加器是私有的、可变的——但它只在 fold 过程中被修改,fold 函数本身是纯的(相同输入永远得到相同输出)。
注意 seenGoalIds 是 Set<GoalId>——它记录整个 session 生命期内所有见过的 Goal id,包括已经 clear 掉的。这是防止 id 重用的关键防线,后面详细分析。
四、foldGoal——从事件序列归约出当前状态
foldGoal 是整个 Goal 事件溯源的入口:
export function foldGoal(events: readonly SessionEvent[]): FoldedGoal {
const state = emptyGoalFoldState()
for (const event of events) applyGoalEvent(state, event)
return {
...state.goal === undefined ? {} : { goal: { ...state.goal } },
roundsStarted: state.roundsStarted,
...state.createdAt === undefined ? {} : { createdAt: state.createdAt },
...state.updatedAt === undefined ? {} : { updatedAt: state.updatedAt },
...state.lastRef === undefined ? {} : { lastRef: { ...state.lastRef } },
}
}
纯函数。输入是 session 事件数组(按 seq 顺序),输出是 FoldedGoal——一个不可变的快照对象。没有副作用,没有 IO。你在任何时候传入相同的事件序列,得到的结果完全一致。
返回值 FoldedGoal 是 GoalFoldState 的只读”公开视图”:
export interface FoldedGoal {
readonly goal?: GoalSnapshot
readonly roundsStarted: number
readonly createdAt?: number
readonly updatedAt?: number
readonly lastRef?: GoalRef
}
注意它不包含 seenGoalIds——那是 fold 过程的内部验证状态,不需要对外暴露。
五、applyGoalEvent——逐事件归约的两条路径
applyGoalEvent 是 fold 的核心分发器,它处理两种事件类型:
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): void {
if (event.type === 'goal/change') {
const change = decodeGoalChange(event.data)
if (change === undefined) throw new Error(...)
applyGoalChange(state, change)
return
}
if (event.type === 'user/message') {
const source = goalSource(event.data.source)
if (source === undefined) return
// 验证 round 连续性,递增 roundsStarted
...
state.roundsStarted = source.round
}
}
路径一:goal/change 事件。 先通过 decodeGoalChange 做严格解码和格式验证(版本号、字段完整性、类型约束),然后交给 applyGoalChange 做状态转换验证。
路径二:user/message 事件。 如果消息的 source.kind 是 'goal'(即这是一个 Goal Round 驱动的自动消息),则验证 goalId、revision、round 号的合法性,然后递增 roundsStarted。Round 不是通过 goal/change 推进的——是通过用户消息推进的。这是 Phase/Revision/Round 三层控制中 Round 层的体现。
六、applyGoalChange——严格的状态转换验证
applyGoalChange 是 fold 的核心验证逻辑。它对每个 GoalChangeMeta 执行分支检查:
clear 分支
if (change.operation === 'clear') {
const current = state.goal
if (current === undefined) throw new Error('goal clear requires a current goal')
requireNextRevision(current, change.cleared, change.operation)
if (change.clearedAt < state.updatedAt) {
throw new Error('goal clear timestamp cannot precede the current goal update')
}
state.goal = undefined
state.roundsStarted = 0
state.createdAt = undefined
state.updatedAt = undefined
state.lastRef = ref
return
}
验证:必须有当前 Goal、revision 必须是 current+1(CAS)、时间戳不能倒退。然后清空所有状态。
create 分支
if (change.operation === 'create') {
if (change.goal.revision !== 1 || change.goal.phase !== 'active'
|| change.roundsStarted !== 0
|| (state.goal !== undefined && state.goal.phase !== 'complete')
|| state.seenGoalIds.has(change.goal.id)) {
throw new Error('goal create requires a fresh active revision-one goal...')
}
state.seenGoalIds.add(change.goal.id)
}
验证五个条件:
- revision 必须是 1(新生)
- phase 必须是 active(不能 create 一个 paused 的 Goal)
- roundsStarted 必须是 0(没跑过)
- 如果已有 Goal,它的 phase 必须是 complete(只有完成的 Goal 才能被替换)
- id 不能在 seenGoalIds 里出现过(防重用)
其他操作分支
对于 edit/pause/resume/complete/block,先验证当前有 Goal、revision 是 current+1(通过 requireNextRevision),再调用 validateSnapshotTransition 检查具体的 phase 转换合法性:
switch (change.operation) {
case 'edit':
// phase 和 blockedReason 不能变
break
case 'pause':
// active -> paused
break
case 'resume':
// active/paused/blocked -> active,且 round 预算未耗尽
break
case 'complete':
// 非 complete -> complete
break
case 'block':
// active -> blocked
break
}
所有校验通过后,state.goal = change.goal——整个快照替换,不是字段合并。这就是 whole-value 快照的核心:投影逻辑是 last-wins 替换。
七、decodeGoalChange——严格解码作为第一道防线
在 applyGoalChange 之前,decodeGoalChange 先执行一轮格式验证。这不是简单的 JSON parse——它验证每个字段的类型、取值范围、字段集合的完整性:
export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined {
if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined
if (value['version'] !== GOAL_CHANGE_VERSION) {
throw new Error(`unsupported goal change version ${String(value['version'])}`)
}
// ...验证 operation、字段集、各字段类型和值域...
}
解码器检查的内容包括:
kind必须是'goal/change'(否则返回 undefined,不是 goal 事件)version必须是GOAL_CHANGE_VERSION(当前为 1)operation必须是七种合法值之一- 字段集必须精确匹配预期(不多不少,通过
Object.keys().sort().join(',')比对) - GoalSnapshot 内部:id 非空字符串、objective 非空且 normalized、phase 在四种合法值内、revision 是正整数、maxGoalRounds 是正整数
- blockedReason(仅 blocked phase):code 是 lower-kebab-case、message 非空且 normalized
如果 kind 不匹配返回 undefined(不是 goal 事件,跳过);如果 kind 匹配但内容格式错误,抛出异常——这是 fail-loud 设计,恶意或 bug 导致的格式错误不会被静默吞掉。
八、GoalService 如何使用 fold——增量 sync 与 cache
GoalService(ctx.goals)不会每次 get() 都从头 fold 整个事件日志。它维护一个 per-session 的 GoalCache:
interface GoalCache {
readonly state: GoalFoldState
activation: GoalActivation
observedSeq: number
pendingActivation: { readonly seq: number; readonly activation: GoalActivation } | undefined
}
首次访问时,cache() 方法对当前 session 的所有事件做一次完整 fold:
private cache(session: Session): GoalCache {
let cache = this.caches.get(session)
if (cache !== undefined) return cache
const state = emptyGoalFoldState()
for (const event of session.events) applyGoalEvent(state, event)
cache = { state, activation: 'disarmed', observedSeq: session.seq, ... }
this.caches.set(session, cache)
return cache
}
之后每次操作前调用 sync(),只增量 apply 从 observedSeq 之后的新事件:
private sync(session: Session, cache: GoalCache): void {
for (const event of session.events.slice(cache.observedSeq)) {
applyGoalEvent(cache.state, event)
if (event.type === 'goal/change') {
cache.activation = cache.pendingActivation?.seq === event.seq
? cache.pendingActivation.activation
: 'disarmed'
}
cache.observedSeq += 1
}
}
注意 sync 中的 activation 逻辑:如果当前事件的 seq 匹配 pendingActivation(即这是本进程自己 append 的事件),就用 pending 的 activation 值;否则一律 'disarmed'。这意味着从日志 replay 来的、或从其他进程来的 goal/change 永远不会自动 arm 你的本地 Agent。
九、applyGoalProjection——投影注册的 last-wins 快捷路径
除了严格的 foldGoal(用于 replay 验证),还有一个轻量的 applyGoalProjection 用于 session projection 注册:
export function applyGoalProjection(
state: GoalProjection | null,
event: SessionEvent
): GoalProjection | null {
if (event.type !== 'goal/change') return state
let change: GoalChangeMeta | undefined
try {
change = decodeGoalChange(event.data)
} catch (_invalidPersistedGoalChange) {
return state // fail-soft:same reference
}
if (change === undefined) return state
return change.operation === 'clear'
? null
: { goal: change.goal, roundsStarted: change.roundsStarted, ... }
}
关键区别:这个函数是 fail-soft 的。解码失败返回同一引用(projection registry 的 Object.is gate 会认为没变化)。而 foldGoal 和 applyGoalEvent 是 fail-loud 的——解码失败抛错。
为什么两套策略?projection drive 在每个 committed event 上运行,如果它抛异常会 tear down 所有注册 unit 的 drive。写入端(GoalService)已经验证过了,projection 端做的是展示——格式错误不应该炸掉 UI。严格验证的责任在写入端和 foldGoal(用于 session 恢复时的完整性校验)。
十、seenGoalIds——为什么 id 绝不能重用
你可能觉得:clear 掉一个 Goal 之后,这个 id 就没用了,再 create 一个同 id 的 Goal 应该无害吧?
不行。seenGoalIds 在 fold 过程中记录所有历史 Goal id。create 时检查:
if (state.seenGoalIds.has(change.goal.id)) {
throw new Error('goal create requires a fresh active revision-one goal with zero rounds')
}
state.seenGoalIds.add(change.goal.id)
原因:GoalRef 是 {id, revision} 的二元组,它是整个 CAS(compare-and-set)乐观锁的基础。如果 id 可以重用,一个旧的 GoalRef {id: "goal-xxx", revision: 2} 可能指向:
- 旧 Goal 的 revision 2(已 clear)
- 新 Goal 的 revision 2(刚 create 的同 id Goal)
这会导致 CAS 校验失效——你拿着旧 ref 调用 edit(agent, ref, changes),系统无法区分你是在操作旧 Goal 还是新 Goal。id 唯一性保证了 GoalRef 在整个 session 生命期内的全局唯一性。
实际上 create 生成的 id 是 goal-${randomUUID()},UUID 碰撞概率为零。seenGoalIds 是防御性检查,防止 bug 或恶意构造的事件流重用 id。
十一、nextMutationTime——单调时间戳保证
分布式系统中墙上时钟(wall clock)是不可靠的。NTP 同步可以把系统时间往回拨。如果上一个 goal/change 的 updatedAt 是 T,然后时钟回拨了,下一个 mutation 的 Date.now() 可能是 T-5min。
fold 里有硬校验:
if (change.clearedAt < state.updatedAt) {
throw new Error('goal clear timestamp cannot precede the current goal update')
}
时间戳倒退直接让 fold 失败。为了避免这种情况,GoalService 在写入端做了保护:
private nextMutationTime(cache: GoalCache): number {
const updatedAt = cache.state.updatedAt
if (updatedAt === undefined) throw new Error('current goal cache lacks updatedAt')
return Math.max(Date.now(), updatedAt)
}
Math.max(Date.now(), updatedAt) 保证时间戳单调不减。时钟正常时用 Date.now();时钟回拨时用上一个 updatedAt——至少不往回走。这是对”墙上时钟不可靠”的简单防御,不依赖 monotonic clock API(performance.now() 不适合持久化)。
十二、GoalActivation——进程本地的 armed/disarmed
Goal 有两种正交的状态维度:
GoalPhase(持久化):active / paused / blocked / complete。写在 goal/change 事件里,fold 可以算出来,跨进程、跨 resume 都存在。
GoalActivation(进程本地):armed / disarmed。绝不持久化。只存在当前进程的 GoalCache.activation 字段里。
armed:Goal-round-driver 会在 Agent idle 时自动注入下一轮 goal round 消息disarmed:即使 phase=active,也不会自动续跑
session-start 事件触发时,activation 强制设为 disarmed:
ctx.on('agent/session-start', ({ agent }) => {
this.cache(agent.session).activation = 'disarmed'
})
不管日志里 Goal 的 phase 是什么——哪怕是 active——session 刚启动时一定是 disarmed。你必须显式调用 resume() 才能 arm 起来:
resume(agent: Agent, ref: GoalRef): GoalView {
// ...验证...
return this.commitCurrent(agent, cache, 'resume', this.withPhase(current, 'active'), 'armed')
}
为什么这么设计?想象你关闭 CLI 时 Goal 正在 active + armed 状态。下次打开时,如果 activation 从日志 replay 出来,Agent 会立刻开始自动执行——但用户可能只是想看看上次聊到哪了。强制 disarmed 给用户控制权:想让它跑,显式 resume。
十三、commit 路径——从 mutation 到事件到通知
当你调用 ctx.goals.create(agent, { objective: '...' }) 时,整条路径是:
prepareMutation(agent)—— 验证 agent 是 live 的,sync cache- 构造
GoalSnapshotChangeMeta(完整快照 + 元数据) commit(agent, cache, change, activation)——- 设置
cache.pendingActivation(本进程的 activation 意图) - 调用
agent.session.append('goal/change', change)(追加事件到日志) - 调用
this.sync(session, cache)(增量 apply 刚 append 的事件) - 清除 pendingActivation
- 构造
GoalChanged通知,emit'goal/changed'cordis 事件
- 设置
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
const ref = goalChangeRef(change)
cache.pendingActivation = { seq: agent.session.seq, activation }
try {
agent.session.append('goal/change', change)
this.sync(agent.session, cache)
} finally {
cache.pendingActivation = undefined
}
const goal = this.view(cache)
const notification: GoalChanged = { operation: change.operation, ref: { ...ref }, ...goal ? { goal } : {} }
agentEvents(this.ctx, agent).emit('goal/changed', { change: notification })
}
注意 pendingActivation 的 try/finally 模式:如果 append 或 sync 抛异常,pendingActivation 也会被清除,不会泄漏到下一次操作。
十四、Phase/Revision/Round——三层演化控制
Goal 的状态变更被三道闸门锁定:
Phase(相态):四种持久化相态构成有限状态机,validateSnapshotTransition 检查每次转换的合法性。不是所有 phase 之间都能直接转——比如 paused 不能直接 block,必须先 resume 到 active。
Revision(修订号):每次 mutation 递增 1,是 CAS 乐观锁的基础。requireNextRevision 确保 next.revision === current.revision + 1。如果你拿着一个旧的 ref 去操作,revision 不匹配会被拒绝(GOAL_STALE_REVISION)。
Round(轮次):通过 user/message 事件的 source.round 推进。applyGoalEvent 验证 source.round === state.roundsStarted + 1——round 必须严格递增,不能跳号、不能回退。round 有上限 maxGoalRounds,达到上限时 goal-round-driver 自动 block。
三层协同:Phase 控制”能做什么操作”,Revision 控制”在哪个版本上做”,Round 控制”还能自动跑几轮”。任何一层不满足条件,mutation 就被拒绝。
十五、Goal-round-driver 如何消费投影
goal-round-driver 插件是 Goal 自动续跑的执行者。它监听 goal/changed 和 agent/status 事件,在 Agent idle 且 Goal armed + active 时,构造下一轮的 goal round prompt 并 followup 到 agent inbox:
const round = goal.roundsStarted + 1
const content = renderGoalRoundPrompt(goal, round)
const message = createUserMessage({
content,
source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round },
})
agent.followup(message)
这个 followup 的消息带着 source: { kind: 'goal', goalId, revision, round }——这就是 applyGoalEvent 在 user/message 路径上要验证的那个 source。当这个消息被 session append 为 user/message 事件时,fold 会识别到它是一个 goal round,递增 roundsStarted。
如果 goal.roundsStarted >= goal.maxGoalRounds(round 预算耗尽),driver 不会尝试续跑,而是直接 block:
if (goal.roundsStarted >= goal.maxGoalRounds) {
ctx.goals.block(agent, goalRef(goal), {
code: 'round-limit',
message: `Goal reached its configured limit of ${goal.maxGoalRounds} rounds.`,
})
return
}
十六、事件溯源的实际效果
回到最初的问题:为什么不是字段?
-
可审计性:每次 Goal 状态变更都有完整记录(谁、什么时候、从什么状态变到什么状态)。字段覆盖只有当前值,历史消失了。
-
确定性恢复:session 恢复时,从头 replay 所有事件就能得到精确的当前状态。不需要额外的持久化逻辑、不需要 schema migration。
-
并发安全:Revision 作为 CAS 乐观锁,配合 fold 的严格验证,任何并发修改都会被 GOAL_STALE_REVISION 拒绝。字段覆盖的 last-write-wins 会丢失中间操作。
-
投影简单性:whole-value 快照意味着投影只需要 last-wins 替换,不需要字段合并或 delta 叠加。代码简单,bug 少。
-
关注点分离:activation 是进程本地状态,不污染持久化日志。phase 变更可以跨进程传播,但”要不要自动跑”的决定权留在本地。
十七、容易踩的坑
坑一:以为 Goal 是可变字段。 Session 上没有 goal 属性可以直接赋值。Goal 通过 ctx.goals 服务操作,每次操作 append 事件到日志,投影负责算出当前状态。
坑二:clear 后尝试复用同一 id。 seenGoalIds 拒绝。create 总是生成新 UUID,不要自己指定 id。
坑三:以为 phase=active 就意味着 Goal 在自动跑。 不一定。activation 是进程本地状态——phase=active + activation=disarmed 意味着 Goal 逻辑上活跃但不会自动续跑。必须显式 resume 才会 arm。
坑四:resume 后发现 Goal 不跑。 检查 roundsStarted >= maxGoalRounds。round 预算耗尽时 resume 会抛 GOAL_INVALID_TRANSITION。需要先 edit 调大 maxGoalRounds。
坑五:系统时钟回拨后 Goal 操作报错。 nextMutationTime 用 Math.max 防止了正常 mutation 的时间戳倒退。但如果日志里已经有一个未来时间的事件(手动改时钟写的),fold 校验会失败。这种情况需要修复日志。
坑六:混淆 foldGoal 和 applyGoalProjection 的容错策略。 foldGoal 是 fail-loud(抛异常),用于 session 恢复时的完整性校验。applyGoalProjection 是 fail-soft(返回同引用),用于 UI 层的 projection drive。写入端的正确性保证在 GoalService,不在投影端。
Goal 的事件模型建立了。七种操作之间不是随便转的——Phase 有合法转换图,Revision 是 CAS 乐观锁,Round 通过用户消息推进。下一章深入看这三道演化闸门怎么把 Goal 的状态变更锁死在合法路径上。