第五部: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。

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 为什么存成事件而不是字段。

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

Goal 为什么是事件流而不是字段

Goal 状态不是 Session 上的可变字段,而是通过 goal/change 事件流进行事件溯源。GoalFoldState 是 fold 纯函数对事件序列的归约投影,Phase/Revision/Round 三层演化控制保证状态机的确定性。每个 goal/change 事件携带完整快照(whole-value),投影只需 last-wins 而无需字段合并。

Written in Chinese

Source version
47f943859bef60e4160492346772ded9b24f765a
Verified on
Commit
47f943859bef60e4160492346772ded9b24f765a

你打开 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 一遍之后折出来的结果。

这一点一旦站稳,后面几件事就都顺了:

  1. goal/change 为什么写 whole-value 快照——因为投影要的是 last-wins,不是字段级 merge。
  2. clear 为什么是 tombstone——因为在事件流世界里,“清空”也得是一条可重放、可审计的事实。
  3. 为什么后面还要单独讲 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
editphase 不变,objective 或 maxGoalRounds 变完整 GoalSnapshot
pauseactive -> paused完整 GoalSnapshot
resumeactive/paused/blocked -> active完整 GoalSnapshot
complete非 complete -> complete完整 GoalSnapshot
blockactive -> blocked,带 blockedReason完整 GoalSnapshot
clear清除当前 Goaltombstone {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)
}

验证五个条件:

  1. revision 必须是 1(新生)
  2. phase 必须是 active(不能 create 一个 paused 的 Goal)
  3. roundsStarted 必须是 0(没跑过)
  4. 如果已有 Goal,它的 phase 必须是 complete(只有完成的 Goal 才能被替换)
  5. 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: '...' }) 时,整条路径是:

  1. prepareMutation(agent) —— 验证 agent 是 live 的,sync cache
  2. 构造 GoalSnapshotChangeMeta(完整快照 + 元数据)
  3. 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
}

十六、事件溯源的实际效果

回到最初的问题:为什么不是字段?

  1. 可审计性:每次 Goal 状态变更都有完整记录(谁、什么时候、从什么状态变到什么状态)。字段覆盖只有当前值,历史消失了。

  2. 确定性恢复:session 恢复时,从头 replay 所有事件就能得到精确的当前状态。不需要额外的持久化逻辑、不需要 schema migration。

  3. 并发安全:Revision 作为 CAS 乐观锁,配合 fold 的严格验证,任何并发修改都会被 GOAL_STALE_REVISION 拒绝。字段覆盖的 last-write-wins 会丢失中间操作。

  4. 投影简单性:whole-value 快照意味着投影只需要 last-wins 替换,不需要字段合并或 delta 叠加。代码简单,bug 少。

  5. 关注点分离: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 的状态变更锁死在合法路径上。