fix(acp): scope connection-owned continuation drain
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
## 生命周期
|
||||
|
||||
客户端断开连接与 Cordis 的 dispose(资源释放)共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose,并等待它们的循环/会话清理完成。因此,单独重载 ACP 插件不会遗留孤儿 agent。
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待它们的循环/会话清理完成。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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 */',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 生命周期事件
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user