cleanup(subagent): hide manager-wide continuation drain

This commit is contained in:
Dudu-0223
2026-08-02 12:51:08 +08:00
committed by Tianyi Cui
parent 191c8cd640
commit f4a65a34e6
14 changed files with 41 additions and 66 deletions
@@ -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 .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
2026-07-28-continuable-subagent-conversations.md: 8e867254a726c57200936cff6c83831abb4c66ad
2026-07-28-continuable-subagent-conversations.zh.md: 3383a0b8fd7b17ad40c3a03f32a78df058afb120
2026-07-28-continuable-subagent-conversations.md: 43abcd88d172104ee5bc55e5a99b4e0306d6dd12
2026-07-28-continuable-subagent-conversations.zh.md: 57d09e176362c05791cdd6317d95b6a3f5082f40
@@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha
Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add.
Top-level teardown is host-owned rather than represented as another Activation. Manager unload uses `drainContinuable()` to close manager-wide admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents instead uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only the global drain precedes manager-scope disposal.
Top-level teardown is host-owned rather than represented as another Activation. Manager unload invokes its internal manager-wide drain to close admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only manager-wide drain precedes manager-scope disposal.
The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. Each materialization registers its barrier participant and snapshots its exact live ancestry before starting the inner transaction, then remains tracked until it installs an Activation or fully rolls back. The Activation retains weak membership of that ancestry, so an intermediate Agent may leave the registry without hiding a still-live descendant from its host root. Each Activation installs one memoized disposal promise before cancellation or recursive callbacks, allowing scoped host shutdown, global manager unload, child release, and normal settlement to converge without double release. Cancellation propagates top-down before slow descendant cleanup; handle release remains child-first. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining selected handles, and the aggregate drain reports failure after all selected branches settle. Durable child Sessions survive this process-local teardown.
@@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。
顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载使用 `drainContinuable()` 同步关闭管理器全局准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有全局 drain 会先于管理器作用域 dispose。
顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载会调用其内部的管理器全局 drain,同步关闭准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有管理器全局 drain 会先于管理器作用域 dispose。
activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会以弱引用方式记录其属于这组祖先,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。每个 Activation 都会在取消或递归回调前安装一个记忆化的 dispose promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。
-9
View File
@@ -1980,15 +1980,6 @@ async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>
*/
async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise<MessageId>
/**
* Close continuable admission synchronously, then dispose every live
* Activation forest child-first. A host calls this before disposing top-level
* agents so no descendant outlives the runtime that owns its teardown.
* @returns once every live Activation released its `AgentHandle`.
* @throws an aggregate error after all branches settle when any failed.
*/
async drainContinuable(): Promise<void>
/**
* Close continuable admission below exact live parent Agents, stop only their
* visible descendant Activations synchronously, then await admitted scoped
+2 -2
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 docs/core-data-structures/subagent.md
subagent.md: eff78bae6fcf7440cce66f122771ff623a5eb3c7
subagent.zh.md: a898f438c257db05a3d84e6318ce23e1f366e193
subagent.md: 379bc9fdd5ff14f9d516d3ddcf37a353f5318026
subagent.zh.md: 3cd43deadba095e67e6a0dd3b483ea676206fbbf
+1 -1
View File
@@ -129,7 +129,7 @@ For both operations the caller signal owns lookup, materialization, and admissio
Every Activation owns its `AgentHandle` and an `ownedChildren: Set<SessionId>`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal.
Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. `drainContinuable()` closes manager-wide admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown.
Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. Manager unload invokes an internal manager-wide drain that closes admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown.
```ts type-equiv
/** Attribution for a model coordinator's follow-up to one of its children. */
+1 -1
View File
@@ -129,7 +129,7 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `
每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set<SessionId>`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。
只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 会关闭管理器全局准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。
只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。
```ts type-equiv
/** Attribution for a model coordinator's follow-up to one of its children. */
@@ -892,10 +892,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise<MessageId>',
jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so every accepted message has\n * one observable order.\n * @param parent - the exact live direct parent authorizing this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, parent authority is\n * rejected, or the message was not admitted.\n */',
},
{
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 */',
+2 -2
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: 6fab6859e2c15fdb1ded023642cbc593e0457384
README.zh.md: 1f59807a545dcb1fafbac3f301746c7217d15f3a
README.md: cea62b90a4c5cb3c4ec74c98f4272daefe78b38e
README.zh.md: 73256ef503c23d391a2c6186c36f4ac933929c8a
-1
View File
@@ -31,7 +31,6 @@ Multiple providers may coexist under different names. This lets a deployment exp
| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. |
| `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.
-1
View File
@@ -31,7 +31,6 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 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。
-14
View File
@@ -201,20 +201,6 @@ export class SubagentService extends Service {
return this.requireContinuations().followup(parent, childId, content, options)
}
/**
* Close continuable admission synchronously, then dispose every live
* Activation forest child-first. A host calls this before disposing top-level
* agents so no descendant outlives the runtime that owns its teardown.
* @returns once every live Activation released its `AgentHandle`.
* @throws an aggregate error after all branches settle when any failed.
*/
async drainContinuable(): Promise<void> {
const manager = this.continuations
// Absent continuation services means nothing was ever materialized.
if (manager === undefined) return
await manager.drain()
}
/**
* Close continuable admission below exact live parent Agents, stop only their
* visible descendant Activations synchronously, then await admitted scoped
@@ -122,6 +122,18 @@ function followup(
})
}
/**
* Exercise manager-wide teardown through the package-private owner rather than
* adding the irreversible operation to the public service contract.
*/
function drainManager(ctx: Context): Promise<void> {
const manager = (ctx.subagents as unknown as {
continuations?: { drain(): Promise<void> }
}).continuations
if (manager === undefined) throw new Error('expected a bound continuation manager')
return manager.drain()
}
/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
await vi.waitFor(() => {
@@ -245,7 +257,7 @@ describe('SubagentService.startContinuable', () => {
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
})
expect(ends).toEqual([])
await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
await expect(drainManager(ctx)).resolves.toBeUndefined()
})
it('rejects a continuable child that would exceed the configured depth cap', async () => {
@@ -283,7 +295,7 @@ describe('SubagentService.startContinuable', () => {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: 'spawn',
})
await ctx.subagents.drainContinuable()
await drainManager(ctx)
})
it('records a declared tool filter in the descriptor', async () => {
@@ -316,7 +328,7 @@ describe('SubagentService.startContinuable', () => {
provider: 'spawn',
toolFilter: { deny: ['noop'] },
})
await ctx.subagents.drainContinuable()
await drainManager(ctx)
})
it('cold-resumes without inventing a model route the descriptor never declared', async () => {
@@ -341,7 +353,7 @@ describe('SubagentService.startContinuable', () => {
})
expect(resumed.options.provider).toBeUndefined()
expect(resumed.options.model).toBeUndefined()
await fresh.subagents.drainContinuable()
await drainManager(fresh)
})
it('numbers the descriptor turn after an inherited fork prefix', async () => {
@@ -649,7 +661,7 @@ describe('continuable durability and teardown', () => {
const disposals: SessionId[] = []
ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) })
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
// Let the held model call observe its cancellation so quiescence can settle.
hold.resolve(undefined)
await drained
@@ -861,7 +873,7 @@ describe('continuable durability and teardown', () => {
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
await ctx.subagents.drainContinuable()
await drainManager(ctx)
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
@@ -873,7 +885,7 @@ describe('continuable durability and teardown', () => {
const { ctx, parent } = await setup([])
const drains: Promise<void>[] = []
const accepted: MessageId[] = []
ctx.on('subagent/start', () => { drains.push(ctx.subagents.drainContinuable()) })
ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) })
ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
@@ -890,7 +902,7 @@ describe('continuable durability and teardown', () => {
const drains: Promise<void>[] = []
ctx.on('agent/created', (child) => {
if (child === parent) return
const draining = ctx.subagents.drainContinuable().then(() => { order.push('drain') })
const draining = drainManager(ctx).then(() => { order.push('drain') })
drains.push(draining)
})
ctx.on('agent/disposed', (child) => {
@@ -926,7 +938,7 @@ describe('continuable durability and teardown', () => {
// Let the child-lock operation reach the live admission cutoff. Admission
// and inbox submission must then complete in one synchronous span.
await Promise.resolve()
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await expect(delivery).resolves.toBeTypeOf('string')
@@ -943,7 +955,7 @@ describe('continuable durability and teardown', () => {
// Accepted into the inbox, but this queued turn never opens.
await followup(ctx, parent, started.childId, message('never logged'))
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await drained
await waitNoActivation(ctx, started.childId)
@@ -1024,7 +1036,7 @@ describe('continuable review regressions', () => {
expect(activation.accepted.size).toBe(0)
child.followup = realFollowup
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await drained
})
@@ -1131,7 +1143,7 @@ describe('continuable review regressions', () => {
throw new Error('scoped cleanup failed')
}
await expect(ctx.subagents.drainContinuable()).rejects.toThrow()
await expect(drainManager(ctx)).rejects.toThrow()
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
// Emitting before disposal would have reported this failed epoch as success.
expect(ends[0]!.stopReason).toBe('error')
@@ -1153,7 +1165,7 @@ describe('continuable review regressions', () => {
const activation = manager.activations.get(started.childId)!
activation.observer.capture = () => { throw new Error('capture failed') }
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
@@ -1177,7 +1189,7 @@ describe('continuable review regressions', () => {
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await drained
@@ -1196,7 +1208,7 @@ describe('continuable review regressions', () => {
// Activation must still reach settlement instead of waiting on that id.
await followup(ctx, parent, started.childId, message('discarded'))
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await drained
@@ -1456,7 +1468,7 @@ describe('continuable errors', () => {
})
// Begin the parent Activation's teardown, then try to give it a child.
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
await expect(ctx.subagents.startContinuable(startSpec(child)))
.rejects.toMatchObject({ code: 'DRAINING' })
hold.resolve(undefined)
@@ -1490,7 +1502,7 @@ describe('continuable errors', () => {
throw new Error('grandchild reap failed')
}
const drained = ctx.subagents.drainContinuable()
const drained = drainManager(ctx)
hold.resolve(undefined)
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
// The other branch still released, and durable sessions survive.
@@ -1552,14 +1564,6 @@ describe('continuable errors', () => {
await waitNoActivation(ctx, started.childId)
})
it('drains without continuation services as a no-op', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SubagentService)
// No `ctx.agents`, so no manager was ever bound and nothing was materialized.
await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
})
it('unloading the manager drains its live activations', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])
@@ -119,10 +119,10 @@ describe('SubagentService', () => {
expect('resume' in provider).toBe(false)
})
it('treats global and scoped drains as no-ops when no manager was bound', async () => {
it('does not expose manager teardown and treats a scoped drain as a no-op 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()
expect('drainContinuable' in subagents).toBe(false)
await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined()
})