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

多 Agent 拆除:cancel 与 dispose 的两个方向

拆一串子孙不是"杀掉进程"——cancel 自上而下传播中止(Agent.cancel:inbox.clear + phase.abort),dispose 自下而上收敛释放(finishDisposal 先拆孩子再等自己 idle)。dispose 本身是 memoized 的 inbox.close 事务,并发拆除者共享同一个 quiescence;失败聚成一条 ACTIVATION_TEARDOWN_FAILED 才抛。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,父 Agent 被人按了停止键。你以为 runtime 会”杀掉这串进程”?不会——没有进程可杀(孩子都在同一进程里),于是拆分成两个方向:cancel 自上而下传播中止(每个 Agent 的 inbox 清空、进行中的 turn abort),dispose 自下而上收敛释放(先拆孩子,再等自己安静,最后才从注册表里除名)。最反直觉的一点:dispose 本身不做任何事——dispose(activation) 只是 activation.inbox.close(() => this.finishDisposal(...))。真正拆除是那个 close 事务里的回调;而因为 close 是 memoized 的,从信号、从 drain、从 watchSettlement 三路同时拆,三方 await 的是同一个 promise。

这一章接着第 35 章:那章讲脚本怎样起一批孩子;这章讲那批孩子怎么整齐地没掉。

拆除双方向:顶部红条 cancel——Agent.cancel 默认 inbox.clear + phase.abort,dispose 路径 keepInbox:true;中部琥珀条 finishDisposal child-first 顺序——cancel → 并行 dispose 孩子 → whenIdle → flushFinalState → capture → handle.dispose;底部绿条收敛六步——resident.delete → releaseSlot → notifySettlement → releaseOwnership → settle → 有 failure 才 throw;左底蓝条 memoization——dispose = inbox.close(finishDisposal),并发拆除者共享同一 quiescence。

Agent.cancel:一行 abort,一个 inbox 闸门

最底层的 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)
}

两个细节决定了上层设计。第一,keepInbox 选项:默认连收件箱一起清——取消就是”排队的消息不要了”;但 disposal 路径必须传 keepInbox: true——为什么?因为拆除的最后一步是把收尾通知投给父,中间还要等 notifySettlement 落账,inbox 清空了通知就没地方进。第二,phase.abort.abort(cause)——只中止进行中的 turn,idle 时什么都不做。cancel 不产生新 turn,它只是掐断现在。

wakeDriver 里有一句注释把拆除纪律说透了:“disposal never latches, so teardown waits on no model turn”——中止中/维护中的 Agent 可以把 wake 记下等收敛后重放,唯独 disposal 不记。拆除不等任何模型回合,abort 即停。

本节源码依据(3 处)

finishDisposal:为什么必须 child-first

finishDisposal(自然结束路径,finalStateFlushed = false)的顺序是这章的题眼:

activation.handle.agent.cancel({ kind: 'parent' })
const idle = activation.handle.agent.whenIdle()
const childDisposals = [...activation.ownedChildren]
  .map(child => this.resident.get(child))
  .filter(...)
  .map(child => this.dispose(child))
const childFailures = await Promise.all(childDisposals.map(async d => { try { await d } catch (e) { return e } }))
...
await idle
await this.flushFinalState(activation)
activation.observer.capture(activation.handle.agent)
await activation.handle.dispose()

顺序是:cancel 自己 → 并行拆全部孩子 → 等自己 idle → 刷最终状态 → 快照 → 拆 handle。

为什么不能反过来(先拆自己再拆孩子)?因为正在进行的 turn 里,孩子可能还在跑、还在给父发信。父先除名,孩子的信就投进了不存在的地址。child-first 保证:当父真正从 resident 里消失时,它名下的每个孩子都已经 settle——父的收尾通知发出去时,孩子不会再多出一句”补充说明”。

失败纪律同样写死:孩子的 disposal 失败不炸兄弟——每个孩子单独 catch,失败聚成一句 child teardown failed(聚合所有孩子的 errorChain)。单个孩子拆不掉,其余照拆。

本节源码依据(3 处)

收敛六步与 memoization:拆两遍是同一件事

孩子的 handle 拆完之后,账本层面的收尾是固定六步:

步动作语义
1resident.delete(childId)移出注册表;之后投递找不到它
2activation.releaseSlot()归还 ActivationPool 槽位
3notifySettlement(...)给父发收尾通知(announced 闸门)
4releaseOwnership(childId)释放 sendMessage 的身份闸门
5observer.settle(failure)通知监听方”这个 epoch 结束了”
6if (failure !== undefined) throw failure失败最后才抛

注意顺序里藏着一个判断:通知父(步 3)发生在注册删除(步 1)之后、身份释放(步 4)之前——此时这个 child 对父来说”已经不是注册表里的孩子”,但 notifySettlement 走的是 ctx.agents.get(parentSession) 直接找父,不经过注册表。收尾通知是点名的,不靠注册表活着。

而最外层的 dispose 入口只有一行:

dispose(activation: Activation, finalStateFlushed = false): Promise<void> {
  return activation.inbox.close(() => this.finishDisposal(activation, finalStateFlushed))
}

inbox.close 是 memoized——第二次调用拿到的是同一个 promise。这意味着:信号 cancel 触发一路、drain 触发一路、watchSettlement 自然结束又触发一路,三路并发拆除观察到的是同一个 quiescence。没有”重复拆除炸两遍”,也没有”拆了一半又被别人重启”。

本节源码依据(3 处)

watchSettlement:自然结束怎么自己长出拆除

孩子不是被取消才死——它跑完自己的任务也会结束。watchSettlement 是个长循环:

while (true) {
  await activation.handle.agent.whenIdle()
  if (activation.inbox.closing !== undefined) return
  const readiness = this.locks.run(childId, () => this.settlementState(activation, idleObservation))
  if (readiness === 'closed') return
  if (readiness === 'retry') continue
  if (readiness === 'wait') { await idleObservation.promise; continue }
  // ready:在 runMaintenance 里启动 dispose
  void activation.handle.agent.runMaintenance(() => { done = this.dispose(activation, true); return Promise.resolve() })
}

settlementState 的判定不读 Agent 执行状态,只看三样:inbox 关了没?poke 还是不是那次?inbox 有 pending 或 ownedChildren 非空?——closed / retry / wait / ready 四态。ready 才动手,而且动手方式很讲究:在 runMaintenance(只在 idle 才能跑)里启动 dispose。注释说”idle ownership 和 Inbox closure 共享一个 turn”——自然结束的拆除与第 34 章的投递截断共用同一份临界区纪律。

为什么自然结束也要走”拆除”而不是直接结束? 因为”孩子跑完”和”父被告知、槽位归还、账本落账”是三件事——runMaintenance 启动的 dispose 就是那三件事的执行器。

本节源码依据(3 处)

drain 三兄弟:谁有权拆、拆哪些

三种拆除入口,对应三种授权:

入口授权范围
drain()registry 级(draining=true)全部 root Activation;之后新 admission 全拒
drainDescendants(parents)精确 live host-owned 父这些父的全部 continuable 子孙(按 ancestry)
drainChildren(parent, childIds)精确 live 直接父 + 血缘核对精选的若干直接子

拉起之后,assertAdmitting 立刻转闸门:registry drain → “continuable subagents are draining”;父链 drain → “continuable subagents below parent X are draining”,错误码 DRAINING。拆除不是隐蔽的——之后的每一次新投递都显式地被拒。

drainChildren 还多一道血缘核对:activation.parentSession !== parent.id || !activation.ancestry.has(parent) 即拒——“你说你是它父,账本上可不是”。

本节源码依据(4 处)

Agent 层对齐:FactoryOwnership 的反向 teardown

最底层(每个 Agent 自己)的拆除顺序在 prepare 里写成注释:“Reverse teardown, memoized so every racing owner awaits one quiescence: stop the machine, drain and close the session’s write path, leave the registries, unwind the scope, release bookkeeping.”

执行顺序:

machine.cancel({ kind: 'disposed' })
await machine.whenIdle()
await machine.scope.dispose()
await handle?.close()        // 账本 closing events 落盘
detachAgent?.(); detachSession?.()
untrack()

三层 abort 早就焊在一起(caller 信号 / 拥有者 fiber unload / factory teardown),注册在任何资源存在之前——“an unload arriving while the scope is still minting finds a working disposer instead of a leak”。FactoryOwnership.dispose 只是最后总闸:accepting=false → teardown.abort → await 全部 liveAgents + startupTasks。

AgentRegistry 这层还有 initiator scope 的收尾:fiber 进 UNLOADING 且是生命周期祖先时 closeInitiators();effect 注册的顺序是先 disposeInitiators() 后 closeInitiators()——先拆干净,再关门,与 Activation 层 child-first 完全同构。

本节源码依据(4 处)

小结

  • 两个方向:cancel 自上而下(inbox.clear + phase.abort),dispose 自下而上(先孩子后自己)。
  • child-first 不可反:父除名前,名下每个孩子必须已 settle——否则收尾通知投进空地址。
  • dispose 是 memoized 事务:三路并发拆除共享同一个 quiescence promise。
  • 失败不吞不炸兄弟:聚成一条 ACTIVATION_TEARDOWN_FAILED,最后才抛。
  • 自然结束也走拆除:watchSettlement ready 后在 runMaintenance 里长出 dispose。
  • drain 之后 admission 关门:新投递一律 DRAINING,拆除不隐蔽。

下一章进入第六部:第 37 章——Service/Provider/Consumer 的三角:一个抽象基类怎么靠 cordis 的 Service 注册机制,让”全局唯一 shell 实现”自动落地。