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/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,