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

收束:Cancel、Dispose 和多 Agent 清理

Cancel 是 top-down 快速传播停止信号(从父到子),Dispose 是 child-first 等待完成后释放资源(从子到父)。finishDisposal 精确步骤序列:wake → 同步 cancel → 拿 idle Promise → 收集子 → 并行启动子 dispose → await 子完成(child-first)→ await idle → best-effort flush → observer.capture → handle dispose → activations.delete → notifySettlement(先于 releaseOwnership)→ releaseOwnership → observer.settle。Disposal memoization 保证幂等——多个并发调用 await 同一个 Promise。Cancel 不回滚 side effects。keepInbox interrupt 只停当前 turn 保留 inbox。drainDescendants 只停指定父下的后代。Initiator scope 通过 closeInitiators 级联清理,withoutInitiator 脱离归属链。

Written in Chinese

Source version
47f943859bef60e4160492346772ded9b24f765a
Verified on
Commit
47f943859bef60e4160492346772ded9b24f765a

Cancel 和 dispose 很容易被混成一件事:都是“停掉”。但在多 Agent 树里,它们不是一个方向,也不是一个阶段。cancel 负责把停止信号向下传播;dispose 负责安全释放资源,而且必须等子树先收完。

全错。

Cancel 和 Dispose 方向相反。Cancel 是 top-down:父先收到停止信号,同步传给子,在第一个 await 之前所有相关方都已经知道”要停了”。Dispose 是 child-first:子先完全释放,父才能释放自己。Flush 是 best-effort——失败只 log 不 throw,否则一个持久化故障会把整棵 Agent 树钉死在内存里。Cancel 不是事务回滚——已经写的文件不会删,已经启动的进程不会杀。Disposal Promise 被 memoize,多个并发调用等的是同一次销毁。

多 Agent 清理是系统里最容易出资源泄漏的地方。每一个顺序选择、每一个方向选择,都有具体原因。

Cancel vs Dispose:方向相反

这是最反直觉的设计决策:cancel 和 dispose 走相反的方向。

Cancel:Top-Down 快速传播

Cancel 是停止信号。信号从上往下走:

  1. 父 agent 收到 cancel,abort.abort(cause) 中止自己当前的 LLM 请求和工具调度
  2. finishDisposal 在第一个 await 之前同步调 activation.handle.agent.cancel({ kind: 'parent' })
  3. 子收到 cancel,abort 自己的 signal,子的子同理
  4. 全部在同一个事件循环 tick 里完成

为什么 top-down?因为父是决策者。父被 cancel 了,它发起的所有子任务都失去存在意义。如果不先让子知道”要停了”,子可能还在继续启动新的 LLM 请求、新的工具调用,产生孤儿结果。Cancel 要快——同步在第一个 await 之前完成,保证在等任何异步操作完成之前,整个树的所有节点都已经收到停止信号,不会启动新工作。

看 ReactLoopAgent.cancel() 的实现:

cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
  if (!options.keepInbox) {
    this.inbox.clear()
    if (this.phase.kind !== 'idle') this.phase.wakeRequested = false
  }
  if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
}

完全同步。清 inbox(除非 keepInbox:true)、abort signal。没有 await,没有回调,没有延迟。Agent 的 step 循环在各种 await 点检查 signal.throwIfAborted()——一旦 signal aborted,下一次检查立刻抛出,turn 结束。

Dispose:Child-First 等待完成

Dispose 是资源释放。释放从下往上走:

  1. 先递归 dispose 所有子(并行启动,然后 await Promise.all)
  2. 等所有子都完全释放了,再 await idle 等自己的 turn 结束
  3. 再做 flush、capture、handle dispose、registry 清理
  4. 最后 releaseOwnership 让父知道这个子走了

为什么 child-first?因为父”拥有”子。子持有引用指向父的资源——parentSession、ownership 计数、inbox 投递目标。如果先释放父,子可能还在尝试:

  • 往父的 inbox 发 settlement notice
  • 访问父 session 做持久化
  • 向父报告结果

先释放父会导致子操作已释放资源。必须保证:子完全结束了、所有对父的引用不再被使用,父才能安全地走。

两个方向的配合

Cancel 和 Dispose 不是互相替代,是配合使用:

时间线 →
父:  cancel(信号) ─────────── await children dispose ─── await idle ─── release self
子:  收到cancel  → abort → turn结束 → 释放handle → notify parent
孙:  收到cancel  → abort → turn结束 → 释放handle → notify parent(子)

Cancel 是”通知所有人停”(快速,同步,top-down),Dispose 是”等所有人确认停完了再释放”(慢速,异步,child-first)。先 cancel 再 dispose 的组合保证了:

  1. 不会有新工作启动(cancel 同步完成)
  2. 不会有资源在还被使用时被释放(child-first 等待)
  3. 整个过程最终一定完成(memoize 保证幂等,best-effort 保证不阻塞)
flowchart TD
    subgraph "Cancel: top-down (同步信号)"
        direction TB
        CA["Root cancel()"] --> CB["Child cancel()"]
        CB --> CC["Grandchild cancel()"]
        CA --> CD["Child2 cancel()"]
    end
    subgraph "Dispose: child-first (await完成)"
        direction BT
        GC["Grandchild dispose done"] --> GB["Child dispose done"]
        GD["Child2 dispose done"] --> GA["Root dispose done"]
        GB --> GA
    end
    style CA fill:#8b0000,color:#fff
    style GA fill:#2d5016,color:#fff

finishDisposal:精确步骤顺序

finishDisposal(activation) 是 Continuable 子 agent 销毁的核心流程。每一步的位置都经过精心设计,不能随意调换。

步骤一览

private async finishDisposal(activation: Activation): Promise<void> {
  // 1. wake:唤醒可能在等待的操作
  this.wake(activation)
  const { childId } = activation

  // 2. 同步 cancel(top-down,第一个 await 之前)
  activation.handle.agent.cancel({ kind: 'parent' })

  // 3. 拿 idle Promise(不 await,只捕获引用)
  const idle = activation.handle.agent.whenIdle()

  // 4. 收集子,映射到 Activation
  const children = [...activation.ownedChildren]
    .map(child => this.activations.get(child))
    .filter((child): child is Activation => child !== undefined)

  // 5. 启动所有子的 dispose(不 await)
  const childDisposals = children.map(child => this.dispose(child))

  // 6. await 所有子 dispose 完成(child-first)
  await Promise.all(childDisposals.map(async (disposal) => { ... }))

  // 7. await idle(自己的 turn 真正结束)
  await idle

  // 8. best-effort flush
  await this.flushFinalState(activation)

  // 9. observer.capture(handle 还活时捕获最终状态)
  activation.observer.capture(activation.handle.agent)

  // 10. handle.dispose()
  await activation.handle.dispose()

  // 11. 从 activations map 删除
  this.activations.delete(childId)

  // 12. notifySettlement(先于 releaseOwnership!)
  this.notifySettlement(activation, activation.observer.terminal(failure))

  // 13. releaseOwnership
  this.releaseOwnership(childId)

  // 14. observer.settle
  activation.observer.settle(failure)
}

为什么这个顺序

步骤 2 在所有 await 之前: cancel 必须同步完成。如果先 await 了什么(比如先等子 dispose),那在 await 期间 agent 还在跑——可能启动新的 LLM 请求、新的工具调用。同步 cancel 保证:从这一刻起,不会有新工作。

步骤 3 拿 idle 但不立刻 await: whenIdle() 返回一个 Promise,代表 agent 从 running 变成 idle 的时刻。我们在 cancel 之后立刻拿它,但先去处理子——子和自己的 idle 可以并发进行。

步骤 5-6 先启动子 dispose 再 await: 所有子的 dispose 并行启动。children.map(child => this.dispose(child)) 是同步遍历,每个 this.dispose(child) 立刻开始(它内部也会同步 cancel 子的 agent)。然后 Promise.all 等所有子完成。这样子之间是并行的,不是串行的。

步骤 6 在步骤 7 之前: 先等子完成,再等自己 idle。为什么?因为子 dispose 的过程中可能向父发送 settlement notice(步骤 12),这些 notice 到达父的 inbox 或 step queue。如果先 await idle 了自己,那 idle 之后 inbox 里可能还没收到子的 settlement notice,导致计数不对。实际上步骤 6 和 7 是有序的——先保证所有子的 settlement 已经投递完了,再确认自己真的停了。

步骤 8 flush 是 best-effort: flush 可能失败。看代码:

private async flushFinalState(activation: Activation): Promise<void> {
  const child = activation.handle.agent
  try {
    await child.ctx.sessions.flush(child.session)
  } catch (error: unknown) {
    this.ctx.logger.warn(...)
    // catch住了,不 throw
  }
}

失败只 log,不 throw。原因:一个 flush 失败如果阻止 dispose,这个 child 会一直留在父的 ownedChildren 里。父永远等不到”所有子完成”,父不能 dispose,父的父也不能 dispose——整棵树泄漏。丢最后几个事件比进程 OOM 好。

步骤 9 capture 在 handle dispose 之前: observer.capture 需要读 agent 的 session log 和 scope——handle dispose 之后 agent 从 registry 移除,scope 卸载,这些数据不再可靠。必须在 handle 还活着时捕获。

步骤 11 delete 在 notify 之前: 保留 entry 直到 disposal settle,这样一个 racing delivery(比如 report 消息)在 agent 还在 map 里时到达,能正常等待 disposal 完成,而不是看到 agent 不在然后尝试 cold-resume。

步骤 12 在步骤 13 之前(关键): notifySettlement 告诉父”你的子完成了”。releaseOwnership 从父的 ownedChildren 集合里移除这个子。顺序必须是先通知再释放。如果反过来:释放 ownership → 父发现自己没有子了 → 父的 watcher 判断”我 childless 且 quiet,可以 settle 了” → 父开始 dispose → 父 cancel() 清空 inbox → 然后 settlement notice 到了但 inbox 已经被清了 → 消息丢失。先通知保证:父在看到自己”无子”之前,已经收到了所有子的结算通知。

Disposal Memoization:幂等保证

一个 Activation 的 dispose 可能从多个方向同时到达:

  • 父被 dispose 时递归 dispose 子
  • drain() 批量 dispose
  • drainDescendants() 范围 dispose
  • 子自然结束后自动 dispose
  • Factory 卸载触发所有 tracked agent dispose

如果并发执行,会重复 cancel、重复释放 handle、重复发 settle 事件——各种 bug。

解法:memoization。

private dispose(activation: Activation): Promise<void> {
  const existing = activation.disposal
  if (existing !== undefined) return existing
  const completion = Promise.withResolvers<void>()
  activation.disposal = completion.promise
  void this.finishDisposal(activation).then(completion.resolve, completion.reject)
  return completion.promise
}

逻辑:

  1. 检查 activation.disposal——如果已有值,返回它(别人在 dispose 了,你等着就行)
  2. 如果没有,创建 Promise.withResolvers
  3. 赋值给 activation.disposal(在启动 finishDisposal 之前赋值——这很关键,因为 finishDisposal 里 cancel 是同步的,可能同步触发其他代码尝试 dispose 同一个 activation)
  4. 启动 finishDisposal,完成后 resolve/reject

结果:

  • 无论多少个调用方并发 dispose,只有一个 finishDisposal 在跑
  • 所有调用方拿到同一个 Promise
  • dispose 是幂等的

注意赋值时机:“Assign it before the async helper starts because that helper cancels Agents and may synchronously re-enter callers”。这句注释解释了为什么赋值在 finishDisposal 之前——finishDisposal 内部同步 cancel 了 agent,cancel 可能触发状态变化,导致其他代码路径同步尝试 dispose 同一个 activation。如果赋值在 finishDisposal 之后,重入会跑第二次 finishDisposal。

FactoryOwnership:工厂级生命周期管理

在 subagent 之上还有一层:AgentLoop 的 FactoryOwnership。它管理的是整个 agent-loop 插件级别的生命周期。

class FactoryOwnership {
  private accepting = true
  private readonly teardown = new AbortController()
  private readonly inactive = Promise.withResolvers<void>()
  private readonly liveAgents = new Set<() => Promise<void>>()
  private startupTasks = new Set<Promise<void>>()

  async dispose(): Promise<void> {
    this.accepting = false
    this.teardown.abort(new Error('agent loop is not active'))
    this.inactive.resolve()
    await Promise.all([
      ...[...this.liveAgents].map(dispose => dispose()),
      ...this.startupTasks,
    ])
  }
}

当 AgentLoop 插件卸载时:

  1. accepting = false——不再接受新 agent 创建
  2. teardown.abort()——通知所有正在创建中的 agent(setup 阶段)中止
  3. inactive.resolve()——释放 waitWhileActive() 等待的配置启动任务
  4. Promise.all 并行等待所有 live agent dispose + 所有 startup tasks settle

每个 live agent 在 prepare() 时通过 this.ownership.track(dispose) 注册自己的 dispose 函数。prepare() 内部创建了一个融合的 AbortController,fuse 三个源:

  • 调用者的 cancellation signal
  • owner fiber 的 unload
  • factory teardown(this.ownership.signal)

任何一个源 abort 都会触发同一个 dispose 路径。

prepare() 的融合 abort

prepare() 是一个关键方法——在 session 和 agent 还没发布时就安装好所有清理路径:

private prepare(ownerCtx, id, options, session, callerSignal?): PreparedAgent {
  const abort = new AbortController()
  // 融合三个源
  callerSignal?.addEventListener('abort', onCallerAbort, { once: true })
  this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true })

  // owner fiber unload 也触发 abort
  unfollowOwner = ownerCtx.effect(() => () => {
    abort.abort(new Error(`... owner disposed during setup`))
    return dispose(true)
  }, `agentLoop.lifecycle(${id})`)

  // memoized dispose
  const dispose = (ownerTriggered = false): Promise<void> => (disposing ??= (async () => {
    abort.abort(new Error(`... lifecycle disposed`))
    // ... cancel → whenIdle → scope.dispose → detach → untrack
  })())
}

注意 dispose 内部的结构:

  1. abort signal(让 setup 中的所有 raceAbort 拒绝)
  2. 如果 machine 还没 ready,await machineReady
  3. machine.cancel({ kind: 'disposed' })——同步 cancel agent
  4. await machine.whenIdle()——等 turn 结束
  5. await machine.scope.dispose()——卸载 scope(Cordis fiber quiesce)
  6. detachAgent() + detachSession()——从 registry 移除
  7. untrack()——从 FactoryOwnership 的 liveAgents 集合移除
  8. unfollowOwner()——退订 owner fiber effect

这和 subagent 的 finishDisposal 是同构的:先 cancel,再等 idle,再释放资源。区别是 prepare 管的是单个 agent handle 的生命周期,finishDisposal 管的是 subagent continuation 里一棵树的递归清理。

Cancel 语义:不回滚 side effects

Cancel 的语义是”停止继续”,不是”回到过去”。

如果 agent 已经:

  • 写了文件——文件不删
  • 启动了 shell 进程——不杀(除非进程管理器自己的清理逻辑)
  • 发了网络请求——已经出去了
  • 调用了 MCP 工具——不回滚
  • append 了 session 事件——不删

cancel 做什么:

  • this.phase.abort.abort(cause) 让 AbortSignal aborted
  • 正在跑的 LLM stream signal.throwIfAborted() 抛出
  • 正在等工具结果的 await 通过 signal 中断
  • 工具调度器在下一次 signal.throwIfAborted() 拒绝启动新工具
  • turn 结束,turn/end 事件的 reason 标记为 { kind: 'aborted', reason: cause }

它是 cooperative 的——代码在各个 await 点主动检查 signal,如果 aborted 就停。已经完成的操作就是完成了,Agent 操作的副作用是真实世界的,你不能让外部世界回到过去。

keepInbox:Interrupt vs Cancel

两种 cancel 模式:

// 默认 cancel:停止当前 turn + 清空 inbox
agent.cancel(cause)
// interrupt:停止当前 turn + 保留 inbox
agent.cancel(cause, { keepInbox: true })

默认 cancel 清空 inbox:已排队的消息全部丢弃。适用于”这个 agent 的任务结束了,不需要做任何后续工作”的场景——比如用户主动取消、父 dispose 子。

keepInbox:true 保留 inbox:只中断当前正在执行的 turn,但 inbox 里排队的消息还在。适用于 interrupt 场景:“停一下当前的工作,但别丢掉等着处理的消息”。Subagent continuation 的 interrupt() 方法用的就是这个:

activation.handle.agent.cancel(
  authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' },
  { keepInbox: true },
)

打断子 agent 正在执行的 turn,但保留它 inbox 里其他子发来的报告、用户后续补充的消息。下次 wake 时这些消息还在,agent 可以继续处理。

drain 和 drainDescendants:两种范围

drain():全量清理

drain() 关闭整个 continuation manager 的 admission,等所有 materializations 完成,然后 dispose 所有根节点(child-first 递归整棵树):

async drain(): Promise<void> {
  this.draining = true  // 同步关闭 admission
  await Promise.all([...this.materializations].map(m => m.settled))
  // 找根:没有被任何 live Activation 拥有的 Activation
  const owned = new Set<SessionId>()
  for (const activation of this.activations.values()) {
    for (const child of activation.ownedChildren) owned.add(child)
  }
  const roots = [...this.activations.values()]
    .filter(a => !owned.has(a.childId))
  await this.disposeRoots(roots, 'activation(s)')
}

drain 之后:

  • this.draining = true——新的 startContinuable 会被拒绝
  • materializations(正在创建但还没拿到 handle 的子)等它们 settle
  • 所有活着的 Activation 从根开始 child-first dispose

drainDescendants():范围隔离

drainDescendants(parents) 只清指定父 agent 下面的 continuable 后代,不影响其他树:

async drainDescendants(parents: readonly Agent[]): Promise<void> {
  const roots = new Set(parents.filter(p => this.ctx.agents.get(p.id) === p))
  if (roots.size === 0) return
  // 设置 scoped admission cutoff
  for (const root of roots) {
    this.closingMembers(root).add(root)
  }
  // 找这些 roots 的 strict descendants
  const targets: Activation[] = []
  for (const activation of this.activations.values()) {
    const owners = [...roots].filter(root =>
      activation.handle.agent !== root && activation.ancestry.has(root))
    if (owners.length === 0) continue
    targets.push(activation)
  }
  // 先对所有 targets 开启 dispose(cancel top-down 同步完成)
  for (const activation of targets) {
    const disposal = this.dispose(activation)
    void disposal.catch(() => undefined)
  }
  // 等 materializations settle → dispose target roots
  await Promise.all(materializations.map(m => m.settled))
  await this.disposeRoots(targetRoots, 'scoped activation(s)')
}

注意:

  • 不设 this.draining = true——全局 admission 不关
  • 只关这些父的 lineage(通过 closingMembers)
  • strict descendants only——父自己不被 dispose(它还活着,由 host 管)
  • 先对所有 targets 调 this.dispose()(同步 cancel 在这一步传播完毕)
  • 再等 materializations,再等 root dispose

使用场景:一个特定插件/scope 卸载了,需要清理它创建的子 agent 树,但不影响其他 scope 创建的子 agent。

Initiator Scope:谁创建的谁负责

多 Agent 系统的归属问题:Agent A 的上下文(fiber)创建了 Agent B,然后 A 的 fiber 卸载了,B 怎么办?

答案:B 也应该被清理。创建者没了,B 作为它发起的任务失去归属。

closeInitiators:拒绝新边界

AgentRegistry 通过 AsyncLocalStorage 追踪当前 initiator。closeInitiators() 在 fiber UNLOADING 时触发:

ctx.on('internal/status', (fiber) => {
  if (fiber.state === FiberState.UNLOADING && this.hasLifecycleAncestor(fiber)) {
    this.closeInitiators()
  }
})

closeInitiators() 设 this.initiatorState = 'closing'——之后任何 withInitiator() 或 withoutInitiator() 调用都会抛 "agent initiator scope is disposed"。这不是”dispose 所有被 initiate 的 agent”,而是”拒绝建立新的 initiator 边界”。

disposeInitiators:完整清理

紧跟着 closeInitiators,disposeInitiators() 做完整清理:

private disposeInitiators(): Promise<void> {
  return (this.initiatorDisposal ??= (async () => {
    this.closeInitiators()
    this.releaseReentrantInitiatorRuns()  // 排除发起 teardown 的链
    if (this.activeInitiatorRuns !== 0) {
      this.initiatorDrain ??= Promise.withResolvers<void>()
      await this.initiatorDrain.promise   // 等所有活跃 runs settle
    }
    this.initiatorState = 'disposed'
    this.initiators.disable()
    this.initiatorRuns.disable()
  })())
}

步骤:

  1. 关闭 admission
  2. 释放”发起 teardown 的调用链自身”——不能让 teardown 等待自己 settle
  3. 等所有其他活跃 initiator runs 自然结束(Promise 到达或 error)
  4. disable AsyncLocalStorage——之后 getStore() 返回 undefined

withoutInitiator:脱离归属

withoutInitiator() 让操作脱离 initiator 链:在这个边界里创建的 agent 不归属任何 initiator,不会因为某个 fiber 卸载而被级联清理。

ScheduleRuntime 用它:

run = this.ctx.agents.withoutInitiator(() => this.runRequested())

为什么?Schedule 是 root agent 上的持久化定时器。如果定时器触发的 followup 操作归属在某个 initiator 下,initiator 的 fiber 卸载时 closeInitiators 会阻止新的 initiator boundary 建立。但 schedule 的工作是长期的、跨越多个 fiber 生命周期的——它不应该被任何特定 fiber 的卸载影响。

withoutInitiator 内部就是 this.initiators.run(undefined, operation)——把 ALS 里的值设为 undefined,让 currentInitiator() 在这个上下文里返回 undefined。

withInitiator:建立归属

withInitiator(agent, operation) 建立归属边界。ReactLoopAgent 的 kick loop 用它:

this.loopCtx.agents.withInitiator(this, () => this.kick())

意思是:“这个 agent 发起的异步操作链,归属于这个 agent”。如果 kick 里创建了子 agent、发起了 tool call,它们的 initiator 是这个 agent。

withInitiator 内部追踪一个 InitiatorRun:

  • 同步操作:run 立即释放
  • Promise 操作:注册 .then 在 settle 时释放
  • 异常:在 catch 里释放

activeInitiatorRuns 计数器追踪还有多少活跃 runs。disposeInitiators 等这个计数归零。

Scope Dispose:Cordis 层面的资源释放

除了 Agent 层面的 cancel/dispose,还有 Cordis 框架层面的 scope 清理。createScope() 返回的 Scope 包含:

export function createScope(ctx, key, options?): Scope {
  const fiber = ctx.plugin(scope)  // 注册一个空 plugin 拿到 fiber
  const scoped = fiber.ctx.extend({ [kScope]: key })
  let disposing: Promise<void> | undefined
  return {
    ctx: scoped,
    rawDispose: fiber.dispose,           // Cordis fiber 原始 disposer
    dispose: () => (disposing ??= quiesceFiber(fiber)),  // memoized
  }
}

scope.dispose() 也是 memoized 的——多次调用返回同一个 Promise。quiesceFiber 等 fiber 完全停下来:

async function quiesceFiber(fiber: Fiber): Promise<void> {
  await Promise.resolve(fiber.dispose())
  while (fiber.inertia !== undefined) await fiber.inertia
}

当 scope 被 dispose:

  • 通过该 scope context 注册的所有 effects 被卸载
  • 通过该 scope context 注册的 tools、prompt sections、listeners 全部移除
  • 该 scope 创建的 plugins 被卸载

这就是为什么测试里验证”registrations through a disposed agent ctx throw INACTIVE_EFFECT”——scope dispose 之后,agent context 变成 inactive,任何注册操作都会抛异常。

完整清理链条:从 Factory 到 Scope

把所有层次串起来,一个 agent 的完整 teardown 链条是:

FactoryOwnership.dispose()
  ├── this.accepting = false
  ├── this.teardown.abort()  ──→  所有 prepare() 中的融合 signal 触发
  ├── this.inactive.resolve()
  └── Promise.all(
        liveAgents.map(dispose)  ──→  每个 live agent:
        │   ├── abort signal
        │   ├── machine.cancel({ kind: 'disposed' })  ←── top-down cancel
        │   ├── await machine.whenIdle()
        │   ├── await machine.scope.dispose()  ←── Cordis scope 卸载
        │   ├── detachAgent() + detachSession()  ←── registry 移除
        │   └── untrack()
        startupTasks  ──→  等创建中的 agent settle
      )

对于 subagent continuation 管理的 agent:

SubagentContinuationManager.drain()
  ├── this.draining = true
  ├── await materializations.settled
  └── disposeRoots(roots)
        └── finishDisposal(activation)
              ├── wake(activation)
              ├── cancel({ kind: 'parent' })  ←── top-down
              ├── idle = whenIdle()
              ├── childDisposals = children.map(dispose)  ←── 递归
              ├── await Promise.all(childDisposals)  ←── child-first
              ├── await idle
              ├── await flushFinalState()  ←── best-effort
              ├── observer.capture()
              ├── await handle.dispose()  ←── 触发 scope dispose
              ├── activations.delete()
              ├── notifySettlement()  ←── BEFORE releaseOwnership
              ├── releaseOwnership()
              └── observer.settle()

两条链最终都到达同一个终点:agent 的 scope 被 Cordis dispose,所有注册被卸载,context 变 inactive。

重入安全:Reentrant Teardown

测试 scope-lifecycle.spec.ts 反复验证一个场景:teardown 过程中有重入。比如:

  1. session/created listener 里同步调 disposeCurrentLifecycle()——在创建通知还没全部派发完时就开始 teardown
  2. agent/created listener 里同步 dispose
  3. agent/session-start listener 里同步 dispose

代码处理这些情况的模式是 assertLive() 检查点:

const assertLive = (): void => {
  if (!abort.signal.aborted) return
  throw abort.signal.reason instanceof Error ? abort.signal.reason : ...
}

在每个关键边界(enter、announce、session-start 之后)检查是否还活着。如果 listener 里触发了 dispose,abort signal 已经 aborted,下一个 assertLive() 就抛出,publication 中止,进入 dispose 路径。

这保证了:

  • listener 里的重入 dispose 不会创建第二条销毁路径(memoize 保证)
  • 已经派发的 session/created 有对应的 session/disposed 配对
  • 已经派发的 agent/created 有对应的 agent/disposed 配对
  • 未派发的通知不会被派发(assertLive() 在 announce 前检查)

容易踩的坑

坑一:以为 cancel 会撤销 side effects。 不会。Cancel 是 cooperative stop。已完成的操作已经发生了。要做可撤销操作?在工具层面设计补偿机制(写了文件就记一个 cleanup callback),不要指望 cancel 帮你 undo。

坑二:flush 失败就阻塞 dispose。 这会导致整棵树泄漏。Flush 是 best-effort——catch 住异常只 log。丢最后几个事件比 OOM 好。存储层有自己的重试,dispose 路径不管它。

坑三:并发 dispose 重复执行。 不会。activation.disposal memoize,赋值在 finishDisposal 启动之前。所有并发调用方等同一个 Promise。

坑四:cancel 默认清空 inbox。 是的。如果你想保留排队消息,必须 { keepInbox: true }。默认行为是”完全停止一切”——turn 停了,排队的也不要了。

坑五:以为 dispose 是 top-down。 信号是 top-down(cancel),释放是 child-first(dispose await children first)。混淆方向 = 访问已释放资源。

坑六:drain() 之后还能创建子。 不能。this.draining = true 同步设置,之后 assertAdmitting() 会拒绝。但 drainDescendants 不设全局 draining——其他树还能继续创建。

坑七:schedule 的 followup 被 initiator teardown 杀。 withoutInitiator() 防止这个。Schedule 的工作跨越 fiber 生命周期,不能归属任何 initiator。

坑八:notifySettlement 在 releaseOwnership 之后。 顺序错了就是竞态——父看到自己无子,判定 settled,cancel 清 inbox,settlement notice 丢失。代码注释明确说:BEFORE releasing ownership。

第五部收口

从 Goal 的事件溯源到多 Agent 树的收束,控制平面的全貌大概是这样:

  • Goal:事件溯源 + tombstone,fold 纯函数验证,Phase/Revision/Round 三道闸门
  • 邻居:Plan Mode 协作开关,Todo 整体替换,Schedule root 专属持久定时器
  • Subagent:两种模式(one-shot/continuable),depth 单调不减,policy 权限只降不升
  • Mailbox:wakeup/quiet 投递,notifySettlement 顺序关键,ChildLock 串行化
  • Workflow:Worker 线程沙箱,预定义脚本,parallel/pipeline 组合子
  • 收束:Cancel top-down / Dispose child-first,best-effort flush,disposal memoize,initiator scope 级联

整个多 Agent 系统能可靠工作,靠的是这些约束一起咬住:

  • append-only 事件溯源——所有状态变化追加,不修改历史
  • 严格验证——非法状态转换直接拒绝
  • 顺序敏感——每个顺序选择都是防竞态
  • best-effort 不阻塞——失败只 log,不阻止释放
  • 权限只降不升——子 agent 永远不能提升权限
  • 幂等 memoize——dispose、settle 等操作重复调用安全
  • 方向一致——cancel top-down 快速传播停止,dispose child-first 安全释放资源