fix(subagent): scope drains and soften final flush

This commit is contained in:
imccyu
2026-08-01 21:49:27 +08:00
committed by Tianyi Cui
parent d7153768f5
commit a1b3bebb61
18 changed files with 203 additions and 111 deletions

View File

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

View File

@@ -35,7 +35,7 @@ Committed-message output intentionally trades token-by-token latency for a clean
## Lifecycle
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then 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.
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 every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
## Running

View File

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

View File

@@ -362,7 +362,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`)
}
}
await Promise.all(records.map(record => record.dispose()))
const disposals = await Promise.allSettled(records.map(record => record.dispose()))
const failures: unknown[] = []
for (const result of disposals) {
if (result.status === 'rejected') failures.push(result.reason as unknown)
}
if (failures.length > 0) {
throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s)`)
}
})()
return quiescing
}

View File

@@ -96,6 +96,52 @@ describe('ACP connection ownership', () => {
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('awaits every owned session disposal before reporting one failure', async () => {
harness = await makeBridgeHarness()
const create = harness.ctx.agents.create.bind(harness.ctx.agents)
const releaseSecond = Promise.withResolvers<undefined>()
const warnings: string[] = []
let created = 0
let secondStarted = false
harness.ctx.logger.warn = (message: string) => { warnings.push(message) }
const createSpy = vi.spyOn(harness.ctx.agents, 'create').mockImplementation(async (options) => {
const handle = await create(options)
const originalDispose = handle.dispose.bind(handle)
if (created++ === 0) {
handle.dispose = async () => {
await originalDispose()
throw new Error('first session cleanup failed')
}
} else {
handle.dispose = async () => {
secondStarted = true
await releaseSecond.promise
await originalDispose()
}
}
return handle
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.closeClientTransport()
await vi.waitFor(() => { expect(secondStarted).toBe(true) })
expect(warnings.some(warning => warning.includes('connection-close teardown failed'))).toBe(false)
releaseSecond.resolve(undefined)
await vi.waitFor(() => {
expect(warnings.some(warning => warning.includes('ACP agent teardown failed for 1 session(s)'))).toBe(true)
expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined()
expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined()
})
createSpy.mockRestore()
const disposed = harness
harness = undefined
await disposed.dispose().catch(() => undefined)
})
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })

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: a27c64ad3b7c4260d769f030db5dacd20e772ac6
README.zh.md: d18e1159336515e9184dbc8f07dc92811a93d4e9
README.md: 5b7c0376367a942d91700739ad445cf2b7a4455a
README.zh.md: 30c5e0b501d0cf3cac749a788bb92a0353b466e9

View File

@@ -77,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). 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`.
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 awaits a best-effort `ctx.sessions.flush(child.session)` before handle disposal. A listener rejection is logged without failing the Activation because listener participation does not identify a persistence backend; the persisted state may therefore be missing or stale on resume.
## Lifecycle events
@@ -91,7 +91,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
Continuable Activations require final durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
## Model Experience

View File

@@ -77,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 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 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处于该等待图之外。最终结算会在 dispose handle 前等待 best-effort 的 `ctx.sessions.flush(child.session)`。listener rejection 会被记录,但不会使 Activation 失败,因为 listener 是否参与无法标识持久化后端;因此,恢复时持久化状态可能缺失或陈旧
## 生命周期事件
@@ -91,7 +91,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task其通用状态、收集和取消工具负责后续交互并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running``complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`
可继续 Activation 要求最终持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
可继续 Activation 会等待 best-effort 的最终会话 flush但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
## 模型体验

View File

@@ -917,9 +917,9 @@ export class SubagentContinuationManager {
* 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`.
* The final session flush is best effort and never prevents handle disposal
* or ownership release, because retaining a 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.
*/
@@ -951,7 +951,7 @@ export class SubagentContinuationManager {
.filter((child): child is Activation => child !== undefined)
const childDisposals = children.map(child => this.dispose(child))
let failure: Error | undefined
const failures: SubagentError[] = []
try {
// Release remains child-first even though cancellation propagated
// top-down: every owned child completes before this handle is removed.
@@ -965,71 +965,73 @@ export class SubagentContinuationManager {
}))
const reasons = childFailures.filter(reason => reason !== undefined)
if (reasons.length > 0) {
failure = new SubagentError(
failures.push(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
// Quiesce before the flush: a turn still running would keep
// appending events the flush cannot cover.
await idle
const durability = await this.checkpoint(activation)
failure ??= durability
await this.flushFinalState(activation)
// 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(
failures.push(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 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)
}
))
}
try {
await activation.handle.dispose()
} catch (error: unknown) {
failures.push(new SubagentError(
`subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`,
'ACTIVATION_TEARDOWN_FAILED',
{ cause: error },
))
}
let failure: SubagentError | undefined
if (failures.length === 1) {
failure = failures[0]
} else if (failures.length > 1) {
failure = new SubagentError(
`subagent "${childId}" activation teardown failed at ${failures.length} boundaries: `
+ failures.map(item => errorChain(item)).join('; '),
'ACTIVATION_TEARDOWN_FAILED',
{ cause: new AggregateError(failures) },
)
}
// 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
}
/**
* Request the final durability checkpoint. Only `true` confirms durability;
* `false` and rejection both report `DURABILITY_FAILED` so the persisted
* child state is known to be possibly missing or stale on a later resume.
* Request a best-effort final session flush after the child is quiescent.
* Listener failure is logged because flush participation cannot identify a
* particular persistence backend, and teardown must still release ownership.
* @param activation - the Activation whose final events should be flushed.
*/
private async checkpoint(activation: Activation): Promise<SubagentError | undefined> {
private async flushFinalState(activation: Activation): Promise<void> {
const child = activation.handle.agent
try {
const participated = await child.ctx.sessions.flush(child.session)
if (participated) return undefined
return new SubagentError(
`subagent "${activation.childId}" required durability checkpoint has no registered listener; `
+ 'the latest child state was not confirmed persisted and may be unavailable or stale on resume',
'DURABILITY_FAILED',
)
await child.ctx.sessions.flush(child.session)
} catch (error: unknown) {
return new SubagentError(
`subagent "${activation.childId}" durability checkpoint failed; the latest child state was not `
+ `confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`,
'DURABILITY_FAILED',
{ cause: error },
this.ctx.logger.warn(
`subagent "${activation.childId}" best-effort final session flush failed; `
+ `the persisted state may be unavailable or stale on resume: ${errorChain(error)}`,
)
}
}

View File

@@ -6,7 +6,6 @@
* @module @deepseek-ai/dsh-subagent/run-settlement
*/
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
import type { SubagentResult, SubagentRun } from './types.ts'
@@ -41,13 +40,6 @@ function runOutcome(result: SubagentResult): TaskOutcome {
}
}
/** Render infrastructure failure detail without hiding a durability diagnosis. */
function runFailureDetail(error: unknown): string {
return error instanceof HarnessError && error.code === 'DURABILITY_FAILED'
? error.message
: String(error)
}
/**
* Await the child result, dispose the run, then return its task outcome. Result
* and disposal failures become `failed`; when both fail, both details survive.
@@ -59,7 +51,7 @@ export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
try {
outcome = runOutcome(await run.result)
} catch (error: unknown) {
outcome = { status: 'failed', detail: runFailureDetail(error) }
outcome = { status: 'failed', detail: String(error) }
}
try {
await run.dispose()

View File

@@ -615,7 +615,7 @@ describe('continuable child ownership', () => {
})
describe('continuable durability and teardown', () => {
it('reports DURABILITY_FAILED without leaking a waiting Activation', async () => {
it('settles when the best-effort final flush has no listeners', async () => {
const releaseResponse = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise },
@@ -626,32 +626,60 @@ describe('continuable durability and teardown', () => {
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
// Remove every durability listener, so the final checkpoint cannot confirm.
// Remove every persistence listener; the final flush is advisory.
await disposePersistence!()
releaseResponse.resolve(undefined)
// The handle is still disposed and ownership released, so nothing is pinned.
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => {
expect(warnings.some(warning => warning.includes('durability'))).toBe(true)
})
expect(warnings.some(warning => warning.includes('final session flush'))).toBe(false)
})
it('reports DURABILITY_FAILED when the final checkpoint rejects', async () => {
it('logs a failed final flush after every listener settles without failing the Activation', async () => {
const { ctx, parent } = await setup([textResponse('answer')])
const warnings: string[] = []
const ends: SubagentRunEndInfo[] = []
let peerFlushed = false
ctx.logger.warn = (message: string) => { warnings.push(message) }
// A listener that throws makes flush reject rather than return false.
ctx.on('subagent/end', info => void ends.push(info))
ctx.on('session/flush', (session) => {
if (session.header.parentSession !== undefined) throw new Error('disk full')
})
ctx.on('session/flush', (session) => {
if (session.header.parentSession !== undefined) peerFlushed = true
})
const started = await ctx.subagents.startContinuable(startSpec(parent))
// The handle is still disposed and ownership released, so nothing is pinned.
await waitNoActivation(ctx, started.childId)
expect(peerFlushed).toBe(true)
expect(warnings.some(warning => warning.includes('best-effort final session flush failed'))).toBe(true)
expect(ends.at(-1)?.stopReason).toBe('completed')
})
it('logs a teardown failure reached through normal settlement', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const warnings: string[] = []
ctx.logger.warn = (message: string) => { warnings.push(message) }
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('normal settlement cleanup failed')
}
hold.resolve(undefined)
await vi.waitFor(() => {
expect(warnings.some(warning => warning.includes('durability checkpoint failed'))).toBe(true)
expect(warnings.some(warning => warning.includes('normal settlement cleanup failed'))).toBe(true)
})
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('disposes every live Activation forest child-first on manager teardown', async () => {
@@ -1184,7 +1212,38 @@ describe('continuable review regressions', () => {
expect(ends[0]!.stopReason).toBe('error')
})
it('cancels a running turn before the final durability checkpoint', async () => {
it('preserves independent pre-disposal and handle-disposal failures', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
const manager = (ctx.subagents as unknown as {
continuations: {
activations: Map<SessionId, {
handle: { dispose: () => Promise<void> }
observer: { capture: (child: Agent) => void }
}>
}
}).continuations
const activation = manager.activations.get(started.childId)!
const realDispose = activation.handle.dispose.bind(activation.handle)
activation.observer.capture = () => { throw new Error('capture failed') }
activation.handle.dispose = async () => {
await realDispose()
throw new Error('scoped cleanup failed')
}
const drained = drainManager(ctx)
hold.resolve(undefined)
const failure = await drained.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
expect(String(failure)).toContain('capture failed')
expect(String(failure)).toContain('scoped cleanup failed')
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('cancels a running turn before the best-effort final flush', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('slow'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)

View File

@@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { settleRun } from '../src/index.ts'
@@ -44,19 +43,6 @@ describe('outcome mapping helpers', () => {
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
const durabilityMessage = 'subagent "child-3" durability checkpoint failed; latest state unavailable: disk full'
const durabilityFailed = await settleRun({
id: SessionId('child-3'),
localAgent: undefined,
result: Promise.reject(new HarnessError(
durabilityMessage,
'DURABILITY_FAILED',
{ cause: new Error('disk full') },
)),
dispose: () => Promise.resolve(),
})
expect(durabilityFailed).toEqual({ status: 'failed', detail: durabilityMessage })
const disposeFailed = await settleRun({
id: SessionId('child-4'),
localAgent: undefined,