fix(acp): scope connection-owned continuation drain

This commit is contained in:
Dudu-0223
2026-07-31 13:51:46 +08:00
committed by Tianyi Cui
parent 8f3613c4b7
commit 191c8cd640
22 changed files with 522 additions and 118 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5
README.md: 9a48fdec3330cd364c1ab6de4c117b20af0f443f
README.zh.md: 65732f41277a8760bfd2824aea12b0f240ae8025

View File

@@ -35,7 +35,7 @@ Committed-message output intentionally trades token-by-token latency for a clean
## Lifecycle
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then disposes all owned agent handles in parallel and awaits their loop/session cleanup. An ACP-only plugin reload therefore leaves no orphan agent.
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting their loop/session cleanup. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
## Running

View File

@@ -35,7 +35,7 @@
## 生命周期
客户端断开连接与 Cordis 的 dispose资源释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose并等待它们的循环会话清理完成。因此单独重载 ACP 插件不会遗留孤儿 agent。
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle并等待它们的循环会话清理完成。其他共享该上下文的前端会保留其可继续森林和准入。因此 ACP 插件重载不会遗留 agent。
## 运行

View File

@@ -49,8 +49,11 @@ export const inject = ['agents']
* shutdown hook; an absent service means nothing continuable was materialized.
*/
interface ContinuableDrain {
/** Close continuable admission, then dispose every live Activation child-first. */
drainContinuable(): Promise<void>
/**
* Close admission below exact host-owned parents, then dispose only their
* continuable descendants child-first.
*/
drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
}
/** Preserve invalid-parameter detail in the SDK wire error message. */
@@ -345,15 +348,16 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
quiescing = (async () => {
// Continuable subagents outlive the turn that started them, and their
// Activations own descendant teardown. Drain that forest child-first
// BEFORE disposing the top-level agents, so no descendant is left holding
// a runtime its owner already released.
// Activations own descendant teardown. Drain only these sessions' forests
// child-first BEFORE disposing the top-level agents, so no descendant is
// left holding a runtime its owner already released and another frontend
// sharing this Context remains live.
// Read the one teardown method structurally: the bridge needs no other
// part of the subagent seam, so it does not depend on that package.
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
if (subagents !== undefined) {
try {
await subagents.drainContinuable()
await subagents.drainContinuableDescendants(records.map(record => record.agent))
} catch (error: unknown) {
logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`)
}

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
@@ -28,21 +29,25 @@ describe('ACP connection ownership', () => {
it('drains continuable subagents before disposing its own sessions', async () => {
harness = await makeBridgeHarness()
const order: string[] = []
let drainedParents: readonly Agent[] = []
// A continuable Activation outlives the turn that started it, so the bridge
// must release that forest before the agents whose runtime it depends on.
harness.ctx.provide('subagents', {
drainContinuable: () => {
drainContinuableDescendants: (parents: readonly Agent[]) => {
drainedParents = parents
order.push('drained')
return Promise.resolve()
},
} as never)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
harness.ctx.on('agent/disposed', () => { order.push('agent disposed') })
await harness.acpFiber.dispose()
expect(order).toEqual(['drained', 'agent disposed'])
expect(drainedParents).toEqual([agent])
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
@@ -51,7 +56,7 @@ describe('ACP connection ownership', () => {
const order: string[] = []
const release = Promise.withResolvers<undefined>()
harness.ctx.provide('subagents', {
drainContinuable: async () => {
drainContinuableDescendants: async () => {
order.push('drain started')
await release.promise
order.push('drain finished')
@@ -79,7 +84,7 @@ describe('ACP connection ownership', () => {
const warnings: string[] = []
harness.ctx.logger.warn = (message: string) => { warnings.push(message) }
harness.ctx.provide('subagents', {
drainContinuable: () => Promise.reject(new Error('activation teardown failed')),
drainContinuableDescendants: () => Promise.reject(new Error('activation teardown failed')),
} as never)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })

View File

@@ -896,6 +896,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async drainContinuable(): Promise<void>',
jsDoc: '/**\n * Close continuable admission synchronously, then dispose every live\n * Activation forest child-first. A host calls this before disposing top-level\n * agents so no descendant outlives the runtime that owns its teardown.\n * @returns once every live Activation released its `AgentHandle`.\n * @throws an aggregate error after all branches settle when any failed.\n */',
},
{
signature: 'async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>',
jsDoc: '/**\n * Close continuable admission below exact live parent Agents, stop only their\n * visible descendant Activations synchronously, then await admitted scoped\n * materializations and release those forests child-first. The scoped cutoff\n * lasts until each exact parent leaves the registry; unrelated parent trees\n * remain live.\n * @param parents - exact host-owned parent Agents entering teardown.\n * @returns once every retained descendant Activation released its `AgentHandle`.\n * @throws an aggregate error after all scoped branches settle when any failed.\n */',
},
{
signature: 'registerProvider(provider: SubagentProvider): () => void',
jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
README.md: 06047ac87e84d50d8dc1a965c7d2499cbe58076d
README.zh.md: 206a7d6e95f61ab152829e614cfaf1d15c5bec33
README.md: 6fab6859e2c15fdb1ded023642cbc593e0457384
README.zh.md: 1f59807a545dcb1fafbac3f301746c7217d15f3a

View File

@@ -32,6 +32,7 @@ Multiple providers may coexist under different names. This lets a deployment exp
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
| `drainContinuable()` | Close continuable admission synchronously, await every materialization that already passed admission through publication or rollback, then dispose the stable live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. |
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
`SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
@@ -76,7 +77,7 @@ The manager derives three internal residency conditions from Agent quiescence an
The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input.
A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` or rejection reports `DURABILITY_FAILED` and still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting`.
A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Teardown propagates Agent cancellation top-down before awaiting slow descendants, while handle release remains child-first. Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` or rejection reports `DURABILITY_FAILED` and still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting`.
## Lifecycle events

View File

@@ -32,6 +32,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
| `startContinuable(spec)` | 建立一个持久化可继续子 agent并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
| `drainContinuable()` | 同步关闭可继续准入,等待每个已经通过准入的物化过程完成发布或回滚,然后按 child-first 顺序 dispose 稳定的在线 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 |
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation因此调用方后续取消既不会取消已接受的轮次也不会 dispose 子 agent。
@@ -76,7 +77,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。
受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose子先于父。顶层及其他非继续执行的 Agent 没有 Activation处于该等待图之外。最终结算只把 `ctx.sessions.flush(child.session) === true` 视为持久性确认;`false` 或拒绝会报告 `DURABILITY_FAILED`,但仍会 dispose 句柄并释放所有权,因为保留失败的子 agent 会使其祖先永久停留在 `waiting`
受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose子先于父拆卸会先自顶向下传播 Agent 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 Agent 没有 Activation处于该等待图之外。最终结算只把 `ctx.sessions.flush(child.session) === true` 视为持久性确认;`false` 或拒绝会报告 `DURABILITY_FAILED`,但仍会 dispose 句柄并释放所有权,因为保留失败的子 agent 会使其祖先永久停留在 `waiting`
## 生命周期事件

View File

@@ -131,6 +131,12 @@ interface Activation {
readonly provider: string
/** The retained live Agent handle, disposed exactly once at settlement. */
readonly handle: AgentHandle
/**
* Exact live Agent ancestry observed when this Activation materialized.
* Weak membership preserves host-scope identity across an intermediate
* ancestor leaving the registry without retaining that ancestor's runtime.
*/
readonly ancestry: WeakSet<Agent>
/**
* Session ids of the child Activations this one owns. Because one Session has
* at most one live Activation, the id identifies the live child without
@@ -168,6 +174,16 @@ interface MaterializeInputs {
signal: AbortSignal
}
/**
* One admitted materialization and the exact live ancestry observed at its
* synchronous admission boundary. Retaining identities lets a scoped teardown
* keep waiting even if an intermediate Agent leaves the registry meanwhile.
*/
interface Materialization {
readonly lineage: readonly Agent[]
readonly settled: Promise<void>
}
/**
* Read one Activation's current disposal transaction. This indirection exists
* because TypeScript would otherwise narrow repeated reads of the mutable field
@@ -218,10 +234,17 @@ export class SubagentContinuationManager {
/** Child session id → its live Activation. Process-local, never durable. */
private activations = new Map<SessionId, Activation>()
/** Materializations admitted before drain, tracked through publication or rollback. */
private readonly materializations = new Set<Promise<void>>()
private readonly materializations = new Set<Materialization>()
private readonly locks = new ChildLock()
/** Structural Cordis owner of every Activation handle. */
private readonly ownerCtx: Context
/**
* Exact roots whose host teardown has begun, with the live lineage members
* observed under each root. Entries remain until that exact root leaves the
* Agent registry, closing admission throughout its host's teardown without
* poisoning a later same-id replacement.
*/
private readonly closingScopes = new Map<Agent, Set<Agent>>()
private draining = false
constructor(
@@ -236,6 +259,9 @@ export class SubagentContinuationManager {
// child-first ordering.
const scope = ctx.plugin(function activationOwner() {})
this.ownerCtx = scope.ctx
ctx.on('agent/disposed', (agent) => {
this.closingScopes.delete(agent)
})
ctx.effect(function* (this: SubagentContinuationManager) {
yield scope.dispose
yield () => this.drain()
@@ -258,10 +284,10 @@ export class SubagentContinuationManager {
* @returns the durable child id and the accepted initial prompt's message id.
*/
async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart> {
this.assertAdmitting()
this.requirePersistence()
const request = spec.request
const parent = request.parent
this.assertAdmitting(parent)
this.requirePersistence()
assertSubagentMaxDepth(request.maxDepth)
const childId = SessionId(randomUUID())
const childDepth = resolveChildDepth(parent, request.maxDepth)
@@ -283,7 +309,7 @@ export class SubagentContinuationManager {
signal: spec.signal,
})
spec.signal.throwIfAborted()
this.assertAdmitting()
this.assertAdmitting(parent)
const lineageSeedLength = prepared.seed?.length ?? 0
const seed = seedDescriptorTurn(childId, prepared.seed, descriptor)
@@ -331,7 +357,7 @@ export class SubagentContinuationManager {
content: ContentBlock[],
options: SubagentFollowupOptions,
): Promise<MessageId> {
this.assertAdmitting()
this.assertAdmitting(parent)
while (true) {
const live = await this.locks.run(childId, async () => {
const activation = this.activations.get(childId)
@@ -350,7 +376,7 @@ export class SubagentContinuationManager {
/* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that
* race reaches the retry below, which then cold-resumes a new Activation. */
if (live !== undefined) return live
this.assertAdmitting()
this.assertAdmitting(parent)
options.signal.throwIfAborted()
/* v8 ignore stop */
}
@@ -370,7 +396,7 @@ export class SubagentContinuationManager {
// already past that cutoff remain tracked until their handle is installed
// or rollback completes, producing a stable forest for the later snapshot.
this.draining = true
await Promise.all([...this.materializations])
await Promise.all([...this.materializations].map(materialization => materialization.settled))
// Snapshot roots after closing admission: a root is an Activation no live
// Activation owns, so disposing roots recurses child-first into the forest.
const owned = new Set<SessionId>()
@@ -396,14 +422,128 @@ export class SubagentContinuationManager {
}
}
/** Reject new admission once the host or manager began draining. */
private assertAdmitting(): void {
/**
* Stop only the continuable descendants of exact live host-owned parents.
* Admission stays closed for those parent trees until each exact parent
* leaves the Agent registry; unrelated trees and manager-wide admission stay
* live.
* @param parents - exact live roots whose continuable descendants must stop.
* @returns once every retained descendant Activation released its handle.
* @throws an aggregate error after all scoped branches settle when any failed.
*/
async drainDescendants(parents: readonly Agent[]): Promise<void> {
const roots = new Set(parents.filter(parent => this.ctx.agents.get(parent.id) === parent))
if (roots.size === 0) return
// Publish the scoped admission cutoff before the first await. Merge with an
// earlier call for the same exact root so a converging drain cannot forget
// descendants whose release is already in flight.
for (const root of roots) {
this.closingMembers(root).add(root)
}
const targets: Activation[] = []
for (const activation of this.activations.values()) {
const lineage = this.liveLineage(activation.handle.agent)
// Strict descendants only: a continuable Agent may itself be a
// host-owned root, and its host remains responsible for that root handle.
const owners = [...roots].filter(root => activation.handle.agent !== root
&& activation.ancestry.has(root))
if (owners.length === 0) continue
targets.push(activation)
for (const owner of owners) {
const members = this.closingMembers(owner)
members.add(activation.handle.agent)
for (const agent of lineage) members.add(agent)
}
}
const materializations = [...this.materializations].filter((materialization) => {
const owners = [...roots].filter(root => materialization.lineage.includes(root))
for (const owner of owners) {
const members = this.closingMembers(owner)
for (const agent of materialization.lineage) members.add(agent)
}
return owners.length > 0
})
const ownedTargets = new Set<SessionId>()
for (const activation of targets) {
for (const child of activation.ownedChildren) ownedTargets.add(child)
}
const targetRoots = targets.filter(activation => !ownedTargets.has(activation.childId))
// Open every selected transaction before the materialization barrier.
// Disposal propagates cancellation top-down in the same synchronous span;
// handle release remains child-first.
for (const activation of targets) {
const disposal = this.dispose(activation)
void disposal.catch(() => undefined)
}
await Promise.all(materializations.map(materialization => materialization.settled))
const failures = await Promise.all(targetRoots.map(async (activation) => {
try {
await this.dispose(activation)
return undefined
} catch (error: unknown) {
return error
}
}))
const reasons = failures.filter(failure => failure !== undefined)
if (reasons.length > 0) {
throw new SubagentError(
`continuable subagent teardown failed for ${reasons.length} scoped activation(s): `
+ reasons.map(reason => errorChain(reason)).join('; '),
'ACTIVATION_TEARDOWN_FAILED',
)
}
}
/** Return the retained member set for one exact scoped-teardown root. */
private closingMembers(root: Agent): Set<Agent> {
const existing = this.closingScopes.get(root)
if (existing !== undefined) return existing
const members = new Set<Agent>()
this.closingScopes.set(root, members)
return members
}
/**
* Return the exact currently resolvable ancestry from `agent` upward. The
* first element is always the supplied identity, even when it is already
* stale; each ancestor after it must be the registry's current exact entry.
*/
private liveLineage(agent: Agent): Agent[] {
const lineage = [agent]
const seen = new Set<SessionId>([agent.id])
let parentSession = agent.session.header.parentSession
while (parentSession !== undefined) {
const parent = this.ctx.agents.get(parentSession)
if (parent === undefined || seen.has(parent.id)) break
lineage.push(parent)
seen.add(parent.id)
parentSession = parent.session.header.parentSession
}
return lineage
}
/** Reject new admission once the manager or this exact parent tree began draining. */
private assertAdmitting(agent: Agent): void {
if (this.draining) {
throw new SubagentError(
'continuable subagents are draining; the operation was not admitted',
'DRAINING',
)
}
const lineage = this.liveLineage(agent)
for (const [root, members] of this.closingScopes) {
if (members.has(agent) || lineage.includes(root)) {
throw new SubagentError(
`continuable subagents below parent "${root.id}" are draining; the operation was not admitted`,
'DRAINING',
)
}
}
}
/**
@@ -443,7 +583,7 @@ export class SubagentContinuationManager {
}
// The persistence seam takes no signal; recheck before any child work.
options.signal.throwIfAborted()
this.assertAdmitting()
this.assertAdmitting(parent)
// Authorize the persisted header before folding: only the durable child's
// exact live direct parent may continue it.
this.authorizeLineage(parent, childId, loaded.meta.parentSession)
@@ -505,11 +645,16 @@ export class SubagentContinuationManager {
* and no ownership membership.
*/
private materialize(inputs: MaterializeInputs): Promise<Activation> {
this.assertAdmitting()
this.assertAdmitting(inputs.parent)
const settled = Promise.withResolvers<void>()
this.materializations.add(settled.promise)
return this.materializeTracked(inputs).finally(() => {
this.materializations.delete(settled.promise)
const lineage = this.liveLineage(inputs.parent)
const materialization: Materialization = {
lineage,
settled: settled.promise,
}
this.materializations.add(materialization)
return this.materializeTracked(inputs, lineage).finally(() => {
this.materializations.delete(materialization)
settled.resolve()
})
}
@@ -519,7 +664,10 @@ export class SubagentContinuationManager {
* registered until this either returns a resident Activation or finishes
* rollback.
*/
private async materializeTracked(inputs: MaterializeInputs): Promise<Activation> {
private async materializeTracked(
inputs: MaterializeInputs,
parentLineage: readonly Agent[],
): Promise<Activation> {
const { childId, provider, parent } = inputs
// No id pre-check here: the child lock serializes each durable child, both
// callers reach this only after confirming no Activation exists, and
@@ -551,6 +699,7 @@ export class SubagentContinuationManager {
childId,
provider,
handle,
ancestry: new WeakSet([handle.agent, ...parentLineage]),
ownedChildren: new Set(),
observer,
disposal: undefined,
@@ -562,7 +711,7 @@ export class SubagentContinuationManager {
this.activations.set(childId, activation)
try {
inputs.signal.throwIfAborted()
this.assertAdmitting()
this.assertAdmitting(parent)
this.acquireOwnership(parent, childId)
// Every accepted id leaves the inbox exactly once, through dequeue or
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
@@ -685,7 +834,7 @@ export class SubagentContinuationManager {
signal: AbortSignal,
): MessageId {
signal.throwIfAborted()
this.assertAdmitting()
this.assertAdmitting(parent)
/* v8 ignore next 6 -- only a synchronous re-entrant disposer can change
* this field between the caller's live check and this no-await boundary. */
if (disposalOf(activation) !== undefined) {
@@ -767,83 +916,100 @@ export class SubagentContinuationManager {
}
/**
* Release one Activation child-first: dispose owned children, checkpoint
* durability, dispose the handle, and release parent ownership. Memoized, so
* host shutdown, manager unload, child release, and normal settlement
* converge on one teardown.
* Stop one Activation immediately, then release it child-first. The memoized
* transaction is installed before cancellation or recursive callbacks, so
* admission and reentrant teardown converge on the same owner.
*
* A failed final checkpoint is reported but never prevents handle disposal or
* ownership release, because retaining a failed child would permanently pin
* its ancestors in `waiting`.
* @param activation - the residency epoch to stop and release.
* @returns the one disposal transaction owned by this Activation.
*/
private dispose(activation: Activation): Promise<void> {
return (activation.disposal ??= (async () => {
// The memoized assignment above already closed admission for this child:
// no caller may send to a handle after its disposal transaction begins.
this.wake(activation)
const { childId } = activation
let failure: Error | undefined
try {
// Child-first: every owned child must complete disposal before this
// handle is released.
const children = [...activation.ownedChildren]
.map(child => this.activations.get(child))
.filter((child): child is Activation => child !== undefined)
const childFailures = await Promise.all(children.map(async (child) => {
try {
await this.dispose(child)
return undefined
} catch (error: unknown) {
return error
}
}))
const reasons = childFailures.filter(reason => reason !== undefined)
if (reasons.length > 0) {
failure = new SubagentError(
`subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`,
'ACTIVATION_TEARDOWN_FAILED',
)
const existing = activation.disposal
if (existing !== undefined) return existing
const completion = Promise.withResolvers<void>()
// Presence is the admission cutoff. Assign it before the async helper starts
// because that helper cancels Agents and may synchronously re-enter callers.
activation.disposal = completion.promise
void this.finishDisposal(activation).then(completion.resolve, completion.reject)
return completion.promise
}
/**
* Propagate stop synchronously, then finish the child-first release.
* @param activation - the Activation whose disposal transaction is installed.
* @returns once the handle and ownership edge are released.
*/
private async finishDisposal(activation: Activation): Promise<void> {
this.wake(activation)
const { childId } = activation
// Stop top-down before the first await. Slow descendant cleanup may delay
// release, but it cannot let this ancestor continue model or tool work.
activation.handle.agent.cancel({ kind: 'parent' })
const idle = activation.handle.agent.whenIdle()
const children = [...activation.ownedChildren]
.map(child => this.activations.get(child))
.filter((child): child is Activation => child !== undefined)
const childDisposals = children.map(child => this.dispose(child))
let failure: Error | undefined
try {
// Release remains child-first even though cancellation propagated
// top-down: every owned child completes before this handle is removed.
const childFailures = await Promise.all(childDisposals.map(async (disposal) => {
try {
await disposal
return undefined
} catch (error: unknown) {
return error
}
// Quiesce before the checkpoint: a turn still running would keep
// appending events the flush cannot cover, and a slow flush would let
// model and tool work continue for the whole shutdown.
activation.handle.agent.cancel({ kind: 'parent' })
await activation.handle.agent.whenIdle()
const durability = await this.checkpoint(activation)
failure ??= durability
// Capture the child-dependent edge data while the child is still live:
// handle disposal unregisters it, and consumers read its log and scope.
activation.observer.capture(activation.handle.agent)
}))
const reasons = childFailures.filter(reason => reason !== undefined)
if (reasons.length > 0) {
failure = new SubagentError(
`subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`,
'ACTIVATION_TEARDOWN_FAILED',
)
}
// Quiesce before the checkpoint: a turn still running would keep
// appending events the flush cannot cover.
await idle
const durability = await this.checkpoint(activation)
failure ??= durability
// Capture the child-dependent edge data while the child is still live:
// handle disposal unregisters it, and consumers read its log and scope.
activation.observer.capture(activation.handle.agent)
} catch (error: unknown) {
failure ??= new SubagentError(
`subagent "${childId}" activation teardown failed: ${errorChain(error)}`,
'ACTIVATION_TEARDOWN_FAILED',
{ cause: error },
)
} finally {
try {
await activation.handle.dispose()
} catch (error: unknown) {
failure ??= new SubagentError(
`subagent "${childId}" activation teardown failed: ${errorChain(error)}`,
`subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`,
'ACTIVATION_TEARDOWN_FAILED',
{ cause: error },
)
} finally {
try {
await activation.handle.dispose()
} catch (error: unknown) {
failure ??= new SubagentError(
`subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`,
'ACTIVATION_TEARDOWN_FAILED',
{ cause: error },
)
} finally {
// Only now is the Activation gone: keeping the entry until disposal
// settles makes a racing delivery wait for release rather than
// cold-resume into the still-registered agent.
this.activations.delete(childId)
// Release ownership even on failure: a retained failed child would
// pin its ancestors in `waiting` forever.
this.releaseOwnership(childId)
// Emit once the disposal outcome is known, so a rejecting scoped
// cleanup cannot be reported as a successful epoch.
activation.observer.settle(failure)
}
// Only now is the Activation gone: keeping the entry until disposal
// settles makes a racing delivery wait for release rather than
// cold-resume into the still-registered agent.
this.activations.delete(childId)
// Release ownership even on failure: a retained failed child would pin
// its ancestors in `waiting` forever.
this.releaseOwnership(childId)
// Emit once the disposal outcome is known, so a rejecting scoped cleanup
// cannot be reported as a successful epoch.
activation.observer.settle(failure)
}
if (failure !== undefined) throw failure
})())
}
if (failure !== undefined) throw failure
}
/**

View File

@@ -215,6 +215,23 @@ export class SubagentService extends Service {
await manager.drain()
}
/**
* Close continuable admission below exact live parent Agents, stop only their
* visible descendant Activations synchronously, then await admitted scoped
* materializations and release those forests child-first. The scoped cutoff
* lasts until each exact parent leaves the registry; unrelated parent trees
* remain live.
* @param parents - exact host-owned parent Agents entering teardown.
* @returns once every retained descendant Activation released its `AgentHandle`.
* @throws an aggregate error after all scoped branches settle when any failed.
*/
async drainContinuableDescendants(parents: readonly Agent[]): Promise<void> {
const manager = this.continuations
// Absent continuation services means nothing was ever materialized.
if (manager === undefined) return
await manager.drainDescendants(parents)
}
/**
* Register a provider under its name. Registration is effect-scoped and HMR
* safe; removing a provider blocks new starts but does not revoke runs that

View File

@@ -663,6 +663,199 @@ describe('continuable durability and teardown', () => {
expect(loaded.meta.id).toBe(started.childId)
})
it('drains one parent forest without disabling a sibling parent forest', async () => {
const releaseTarget = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
const releaseSibling = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('target child'), gate: releaseTarget.promise },
{ chunks: textResponse('sibling child'), gate: releaseSibling.promise },
{ chunks: textResponse('target grandchild'), gate: releaseGrandchild.promise },
{ chunks: textResponse('sibling follow-up') },
])
const { ctx, parent } = await setupWith(adapter)
const siblingParent = ctx.agentLoop.create(
SessionId('sibling-parent'),
{ provider: 'mock', model: 'mock' },
)
const target = await ctx.subagents.startContinuable(startSpec(parent))
const sibling = await ctx.subagents.startContinuable(startSpec(siblingParent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const targetChild = ctx.agents.get(target.childId)!
const siblingChild = ctx.agents.get(sibling.childId)!
const grandchild = await ctx.subagents.startContinuable(startSpec(targetChild))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
const convergedDrain = ctx.subagents.drainContinuableDescendants([parent])
// The scoped cutoff stops only the selected forest. The sibling child stays
// resident and can accept later work while target cleanup is still blocked.
expect(cancellations).toEqual([target.childId, grandchild.childId])
expect(ctx.agents.get(target.childId)).toBe(targetChild)
expect(ctx.agents.get(grandchild.childId)).toBeDefined()
expect(ctx.agents.get(sibling.childId)).toBe(siblingChild)
await expect(followup(ctx, siblingParent, sibling.childId, message('still live')))
.resolves.toBeTypeOf('string')
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
await expect(followup(ctx, parent, target.childId, message('too late')))
.rejects.toMatchObject({ code: 'DRAINING' })
releaseTarget.resolve(undefined)
releaseGrandchild.resolve(undefined)
await Promise.all([drained, convergedDrain])
expect(ctx.agents.get(target.childId)).toBeUndefined()
expect(ctx.agents.get(grandchild.childId)).toBeUndefined()
expect(ctx.agents.get(sibling.childId)).toBe(siblingChild)
// The exact root remains closed until its host disposes it, even after all
// current descendants are gone.
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
releaseSibling.resolve(undefined)
await waitNoActivation(ctx, sibling.childId)
})
it('retains a continuable root while draining only its descendants', async () => {
const releaseChild = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('child'), gate: releaseChild.promise },
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const drained = ctx.subagents.drainContinuableDescendants([child])
expect(cancellations).toEqual([grandchild.childId])
expect(ctx.agents.get(started.childId)).toBe(child)
releaseGrandchild.resolve(undefined)
await drained
expect(ctx.agents.get(grandchild.childId)).toBeUndefined()
expect(ctx.agents.get(started.childId)).toBe(child)
await expect(ctx.subagents.startContinuable(startSpec(child)))
.rejects.toMatchObject({ code: 'DRAINING' })
releaseChild.resolve(undefined)
await waitNoActivation(ctx, started.childId)
})
it('finds scoped descendants after an intermediate one-shot Agent leaves the registry', async () => {
const releaseIntermediate = Promise.withResolvers<undefined>()
const releaseDescendant = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('one-shot'), gate: releaseIntermediate.promise },
{ chunks: textResponse('continuable descendant'), gate: releaseDescendant.promise },
])
const { ctx, parent } = await setupWith(adapter)
const run = await ctx.subagents.start('spawn', {
prompt: message('one-shot task'),
parent,
signal: testSignal,
})
const intermediate = run.localAgent
expect(intermediate).toBeDefined()
if (intermediate === undefined) throw new Error('spawn must publish a local Agent')
const descendant = await ctx.subagents.startContinuable(startSpec(intermediate))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const intermediateId = intermediate.id
const disposingIntermediate = run.dispose()
releaseIntermediate.resolve(undefined)
await disposingIntermediate
expect(ctx.agents.get(intermediateId)).toBeUndefined()
expect(ctx.agents.get(descendant.childId)).toBeDefined()
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
expect(cancellations).toEqual([descendant.childId])
releaseDescendant.resolve(undefined)
await drained
expect(ctx.agents.get(descendant.childId)).toBeUndefined()
})
it('awaits and rolls back an admitted materialization below a scoped root', async () => {
const { ctx, parent } = await setup([])
const manager = (ctx.subagents as unknown as {
continuations: { ownerCtx: Context }
}).continuations
const agents = manager.ownerCtx.agents
const create = agents.create.bind(agents)
const published = Promise.withResolvers<SessionId>()
const releaseMaterialization = Promise.withResolvers<undefined>()
const createSpy = vi.spyOn(agents, 'create').mockImplementation(async (options) => {
const handle = await create(options)
published.resolve(handle.agent.id)
await releaseMaterialization.promise
return handle
})
try {
const starting = ctx.subagents.startContinuable(startSpec(parent))
const childId = await published.promise
let drainResolved = false
const drained = ctx.subagents.drainContinuableDescendants([parent]).then(() => {
drainResolved = true
})
await Promise.resolve()
expect(drainResolved).toBe(false)
releaseMaterialization.resolve(undefined)
await expect(starting).rejects.toMatchObject({ code: 'DRAINING' })
await drained
expect(ctx.agents.get(childId)).toBeUndefined()
} finally {
createSpy.mockRestore()
}
})
it('ignores a stale scoped root without disabling its live same-id Agent', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const stale = { ...parent, id: parent.id } as unknown as Agent
await ctx.subagents.drainContinuableDescendants([stale])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
})
it('reports a scoped teardown failure after releasing the selected branch', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('target child'), gate: hold.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const manager = (ctx.subagents as unknown as {
continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
}).continuations
const activation = manager.activations.get(started.childId)!
const realDispose = activation.handle.dispose.bind(activation.handle)
activation.handle.dispose = async () => {
await realDispose()
throw new Error('scoped child reap failed')
}
const drained = ctx.subagents.drainContinuableDescendants([parent])
hold.resolve(undefined)
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('rejects new materialization and delivery once draining begins', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable(startSpec(parent))

View File

@@ -119,10 +119,11 @@ describe('SubagentService', () => {
expect('resume' in provider).toBe(false)
})
it('drains continuable activations as a no-op when no manager was bound', async () => {
it('treats global and scoped drains as no-ops when no manager was bound', async () => {
const { subagents } = await service()
// Without `ctx.agents` no manager exists, so nothing was ever materialized.
await expect(subagents.drainContinuable()).resolves.toBeUndefined()
await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined()
})
it('rejects continuable operations when their runtime services are absent', async () => {