收束: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 脱离归属链。
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 是停止信号。信号从上往下走:
- 父 agent 收到 cancel,
abort.abort(cause)中止自己当前的 LLM 请求和工具调度 finishDisposal在第一个await之前同步调activation.handle.agent.cancel({ kind: 'parent' })- 子收到 cancel,abort 自己的 signal,子的子同理
- 全部在同一个事件循环 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 是资源释放。释放从下往上走:
- 先递归 dispose 所有子(并行启动,然后
await Promise.all) - 等所有子都完全释放了,再
await idle等自己的 turn 结束 - 再做 flush、capture、handle dispose、registry 清理
- 最后
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 的组合保证了:
- 不会有新工作启动(cancel 同步完成)
- 不会有资源在还被使用时被释放(child-first 等待)
- 整个过程最终一定完成(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
}
逻辑:
- 检查
activation.disposal——如果已有值,返回它(别人在 dispose 了,你等着就行) - 如果没有,创建
Promise.withResolvers - 赋值给
activation.disposal(在启动 finishDisposal 之前赋值——这很关键,因为 finishDisposal 里 cancel 是同步的,可能同步触发其他代码尝试 dispose 同一个 activation) - 启动 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 插件卸载时:
accepting = false——不再接受新 agent 创建teardown.abort()——通知所有正在创建中的 agent(setup 阶段)中止inactive.resolve()——释放waitWhileActive()等待的配置启动任务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 内部的结构:
- abort signal(让 setup 中的所有
raceAbort拒绝) - 如果 machine 还没 ready,await machineReady
machine.cancel({ kind: 'disposed' })——同步 cancel agentawait machine.whenIdle()——等 turn 结束await machine.scope.dispose()——卸载 scope(Cordis fiber quiesce)detachAgent()+detachSession()——从 registry 移除untrack()——从 FactoryOwnership 的 liveAgents 集合移除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()
})())
}
步骤:
- 关闭 admission
- 释放”发起 teardown 的调用链自身”——不能让 teardown 等待自己 settle
- 等所有其他活跃 initiator runs 自然结束(Promise 到达或 error)
- 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 过程中有重入。比如:
session/createdlistener 里同步调disposeCurrentLifecycle()——在创建通知还没全部派发完时就开始 teardownagent/createdlistener 里同步 disposeagent/session-startlistener 里同步 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 安全释放资源