多 Agent 拆除:cancel 与 dispose 的两个方向
拆一串子孙不是"杀掉进程"——cancel 自上而下传播中止(Agent.cancel:inbox.clear + phase.abort),dispose 自下而上收敛释放(finishDisposal 先拆孩子再等自己 idle)。dispose 本身是 memoized 的 inbox.close 事务,并发拆除者共享同一个 quiescence;失败聚成一条 ACTIVATION_TEARDOWN_FAILED 才抛。
本章以中文撰写
第 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 章:那章讲脚本怎样起一批孩子;这章讲那批孩子怎么整齐地没掉。
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 拆完之后,账本层面的收尾是固定六步:
| 步 | 动作 | 语义 |
|---|---|---|
| 1 | resident.delete(childId) | 移出注册表;之后投递找不到它 |
| 2 | activation.releaseSlot() | 归还 ActivationPool 槽位 |
| 3 | notifySettlement(...) | 给父发收尾通知(announced 闸门) |
| 4 | releaseOwnership(childId) | 释放 sendMessage 的身份闸门 |
| 5 | observer.settle(failure) | 通知监听方”这个 epoch 结束了” |
| 6 | if (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 实现”自动落地。