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/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 () => {