青雲的博客
深入浅出 DeepSeek Harness 第五部:Goal与多Agent——谁在掌舵 第 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 脱离归属链。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
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 之上还有一层:AgentLoopFactoryOwnership。它管理的是整个 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 安全释放资源