Просмотр исходного кода

test(subagent): close continuable coverage and drop unreachable guards

Restores the one-shot settleRun coverage in its own file beside the helper,
covers fork's seed contribution, the post-transfer rollback, the descriptor
model route on cold resume, manager-unload drain, and a failing teardown branch.

Removes three redundant checks the surrounding contracts already own: the
duplicate-Activation and live-id pre-checks (AgentRegistry.enter is the
authoritative collision boundary) and a rollback lifecycle edge that could never
publish because the epoch had no start edge.
Dudu-0223 1 месяц назад
Родитель
Сommit
bc504195df

+ 2 - 2
docs/core-data-structures/subagent.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md
-subagent.md: 8f24afec47a970711aae49cae6b3535b9f532e5f
-subagent.zh.md: 50c5cb887ef814c074a85fc4fee9cd2fe85d685c
+subagent.md: a58ecf13ba1f5df0e8e35c793eaf9aefc1e8a900
+subagent.zh.md: 541eace7fc6c8ae10ee22639680918e12d7762b3

+ 139 - 120
docs/core-data-structures/subagent.zh.md

@@ -4,24 +4,25 @@
 
 subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。
 
-接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过由 Task 支撑的内部管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续后台 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
+接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
 
 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
 
 ## 两类能力,两种发现方式
 
-提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性则是可选方法;方法存在即为能力,TypeScript 的类型收窄即为发现机制:提供确认语义的在线 steering(中途引导)是 [`SubagentRun.steer`](#a-live-run-subagentrun),从持久化存储恢复是 [`SubagentProvider.resume`](#the-provider-seam-subagentprovider)。
+提供方通过一个静态描述符公布其**启动时**特性,服务单次 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。这些 flag 仅描述单次 [`start()`](#the-provider-seam-subagentprovider) 路径,即由提供方组合子 agent 的路径。**可继续**子 agent 由继续执行管理器自行组合,因此它们由唯一一个可选方法把关,方法存在即为能力,并以 TypeScript 的类型收窄作为发现机制:[`SubagentProvider.prepareContinuable`](#the-provider-seam-subagentprovider)。
 
 ```ts type-equiv
 /**
  * Which START-TIME features a provider supports. Checked by the service before delegating to
  * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
  * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
- * degradation" rule). These static flags cover features needed before a run exists; runtime
- * capabilities are optional methods whose presence is the capability — confirmed live steering
- * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each
- * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to
- * `maxDepth`; the other names match.
+ * degradation" rule). These flags describe the ONE-SHOT
+ * {@link SubagentProvider.start} path, where the provider composes the child;
+ * continuable children are composed by the continuation manager itself and are
+ * gated by {@link SubagentProvider.prepareContinuable} instead. Each flag
+ * corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit`
+ * to `maxDepth`; the other names match.
  */
 interface SubagentCapabilities {
   readonly outputSchema: boolean
@@ -31,16 +32,16 @@ interface SubagentCapabilities {
 }
 ```
 
-## 启动请求
+## 单次启动请求
 
 工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。
 
 ```ts type-equiv
 /**
- * What a caller asks for when starting a subagent. The tool layer builds this
- * from the model's `{ description, prompt }` plus its own config; the service
- * validates {@link SubagentCapabilities} against the named provider and
- * resolves a {@link SubagentProviderStartRequest} for dispatch.
+ * What a caller asks for when starting a ONE-SHOT subagent. The tool layer
+ * builds this from the model's `{ description, prompt }` plus its own config;
+ * the service validates {@link SubagentCapabilities} against the named provider
+ * before dispatching to {@link SubagentProvider.start}.
  */
 interface SubagentStartRequest {
   /** Content delivered as the child's user message. */
@@ -94,31 +95,41 @@ interface SubagentStartRequest {
 
 `signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。
 
-提供方会接收单独的已解析请求类型。`SubagentService.start()` 的参数类型不包含继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符
+提供方接收的正是此请求:单次委派不含由服务解析的继续执行状态,因为可继续子 agent 绝不会到达 `SubagentProvider.start()`
 
-```ts type-equiv
-/**
- * Provider-facing start request after the service resolves optional
- * continuation state. Ordinary callers use {@link SubagentStartRequest}; only
- * the Task-backed continuation path can attach a stable child identity and
- * durable descriptor.
- */
-interface SubagentProviderStartRequest extends SubagentStartRequest {
-  /**
-   * Continuable-child state resolved by `ctx.subagents` before provider dispatch.
-   * The provider MUST publish exactly `sessionId` as the child identity
-   * instead of allocating one internally, and MUST append the snapshotted,
-   * model-hidden `subagent/descriptor` before the initial prompt is admitted.
-   * Requires {@link SubagentProvider.resume} (the
-   * continuation capability); the service rejects the request otherwise.
-   */
-  readonly continuation?: SubagentContinuation | undefined
-}
+## 可继续子 agent 与激活
+
+**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、授权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。
+
+```text
+persisted Session
+  -> optional live Activation
+       -> one retained AgentHandle
+       -> Agent inbox as the only turn FIFO
+       -> zero or more owned child Activations
 ```
 
-## 可继续子 agent 与提供方恢复
+`SubagentService.startContinuable()` 会预留稳定的子 agent id,对版本化的 `subagent/descriptor` payload 建立快照,向指定提供方索取其分离的 `ContinuableCreateSpec`,通过私有的 activation-owner 作用域创建子 Agent,建立任何可继续父级的所有权,并提交初始 prompt。当收件箱(inbox)准入产出消息 id 时,它以 `{ childId, messageId }` resolve——无需等待轮次开始,也无需等待消息进入会话日志。在该准入之前的任何失败都会以两个 id 都不返回的方式 reject,并 dispose 任何已创建的 handle,回滚 Activation 与父级所有权。
+
+`SubagentService.followup()` 是唯一的继续执行消息操作,其路由仅取决于 Activation 的驻留状态:
+
+| Activation 状态 | 发送方 | `followup` |
+|---|---|---|
+| `running` | parent 或 user | 在同一 Activation 中入队 |
+| `waiting` | parent 或 user | 唤醒同一 Activation |
+| 无 Activation | parent 或 user | 冷恢复一个新的 Activation |
 
-**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过面向提供方的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.followup()` 沿用 `Agent` 的意图动词:它会引导实时激活,或在加载并授权已停止的子 agent 后,仅在内部向提供方分发已解析的恢复请求。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`followup()` 则报告内容是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都通过一个选项对象提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。
+`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose `AgentHandle` 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些状态,而非维护第二套执行状态机;`activationState()` 报告当前值(无存活 Activation 时为 `undefined`)。
+
+Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `Agent.followup()` FIFO 轮次,因此 parent 与 user 消息共享同一个可观测顺序,且后续消息无法改变已在进行中的轮次。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 与 `agent/inbox/discard` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。
+
+授权由受信任的宿主交互或一个确切的实时 Agent 工具上下文提供。仅当已认证的 Agent 是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级时,才会准入 parent 变体;只有受信任的宿主适配器才能提供 user 授权。`MessageSource` 与 `senderSessionId` 在准入之后是持久的来源凭据,不授予任何权限——可选的面向模型工具使用 `CoordinatorMessageSource`,宿主适配器则使用 `{ kind: 'user' }`。user 授权可以在不加载子 agent 历史父级的情况下冷恢复它。
+
+对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 subagent 取消或 steering(中途引导)操作。
+
+每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set<SessionId>`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。
+
+只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 是覆盖整个生命周期的停止路径:它同步关闭准入,随后以子级优先的方式 dispose 每一片存活的 Activation 森林,尽管个别分支失败仍会等待每个分支。持久化子会话不受该进程内拆卸的影响。
 
 ```ts type-equiv
 /** Attribution for a model coordinator's follow-up to one of its children. */
@@ -131,76 +142,90 @@ interface CoordinatorMessageSource {
 
 ```ts type-equiv
 /**
- * Options for following up with one continuable child.
+ * Who authorizes one continuable-subagent operation. Authority comes from a
+ * trusted host interaction or an exact live Agent tool context; durable
+ * {@link MessageSource} provenance never authorizes delivery.
  */
+type SubagentAuthority =
+  /** The exact live parent Agent whose tool context is making the call. */
+  | { readonly kind: 'parent'; readonly agent: Agent }
+  /** A trusted host adapter acting for the human user. */
+  | { readonly kind: 'user' }
+```
+
+```ts type-equiv
+/** Options for following up with one continuable child. */
 interface SubagentFollowupOptions {
-  /** Durable attribution retained on either live or resumed delivery. */
+  /** Durable attribution retained on the delivered message; it grants no authority. */
   readonly source: MessageSource
-  /** Caller cancellation for a live-delivery admission wait. */
+  /** Caller cancellation, owning the operation only until inbox acceptance. */
   readonly signal: AbortSignal
 }
 ```
 
+```ts type-equiv
+/** Identities returned once a continuable child accepted its initial prompt. */
+interface ContinuableStart {
+  /** The durable child session id, stable across activations. */
+  readonly childId: SessionId
+  /** The accepted initial prompt's inbox message id. */
+  readonly messageId: MessageId
+}
+```
+
 ```ts type-equiv
 /**
- * How a continuable follow-up was routed:
- * `steered` joined the running activation's existing Task without creating a
- * Task of its own; `started` created a fresh Task that cold-resumes the
- * durable child with the content. Failure is an exception, never a result —
- * undelivered content throws.
+ * The public residency state of one continuable child, derived from Agent
+ * quiescence and the owned-child set rather than a second state machine:
+ * `running` — the Agent has an active admission or turn, or waking inbox work;
+ * `waiting` — the Agent is quiescent but still owns undisposed children;
+ * `settled` — quiescent with every owned child disposed, so the manager
+ * disposes the `AgentHandle` and removes the Activation.
  */
-type SubagentFollowupResult =
-  | { readonly route: 'steered'; readonly taskId: TaskId }
-  | { readonly route: 'started'; readonly taskId: TaskId }
+type ActivationState = 'running' | 'waiting' | 'settled'
 ```
 
+提供方只参与准备初始创建 spec,`spawn` 与 `fork` 在此有所不同。其返回的 spec 只携带分离的、提供方专属的创建输入——目前是可选的父级历史种子——不含 Agent、`AgentHandle`、prompt 投递、结果、dispose 或 resume 操作。冷恢复根本不经由提供方分发:管理器折叠通用描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,并提交等待中的轮次。
+
 ```ts type-equiv
 /**
- * The resolved continuable-child identity and durable composition record the
- * service attaches before provider dispatch.
+ * What the continuation manager asks a provider for while materializing one
+ * continuable child's FIRST activation. The manager has already reserved the
+ * durable child identity and owns every later operation, so this request
+ * carries only what distinguishes a fresh child from one seeded with parent
+ * history.
  */
-interface SubagentContinuation {
-  /** Service-allocated stable child session id, published verbatim. */
+interface ContinuableCreateRequest {
+  /** The reserved durable child session id, for provider diagnostics. */
   readonly sessionId: SessionId
-  /** Snapshotted descriptor persisted in the child log for cold resume. */
-  readonly descriptor: SubagentDescriptorData
+  /** The delegating parent agent whose history a seeding provider reads. */
+  readonly parent: Agent
+  /**
+   * Caller cancellation, which owns preparation only until the manager accepts
+   * the initial prompt into the child's inbox.
+   */
+  readonly signal: AbortSignal
 }
 ```
 
 ```ts type-equiv
 /**
- * Provider-facing request for reconstructing a persisted continuable child.
- * The continuation manager loads the child log, folds and authorizes its
- * descriptor, then privately dispatches this resolved request to
- * {@link SubagentProvider.resume}. The provider reconstructs the declared
- * composition under the live parent's scope and drives one turn with `prompt`.
+ * A provider's detached contribution to one continuable child's creation. This
+ * is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt
+ * delivery, result, disposal, or resume operation, because the continuation
+ * manager owns the child's whole lifecycle after preparation.
  */
-interface SubagentProviderResumeRequest {
-  /** The persisted child session id to resume. */
-  readonly sessionId: SessionId
-  /** The follow-up message that starts the resumed activation's turn. */
-  readonly prompt: ContentBlock[]
-  /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */
-  readonly source: MessageSource
+interface ContinuableCreateSpec {
   /**
-   * The live parent agent — the direct parent recorded in the persisted child
-   * header. In-process backends reconstruct the child under this agent's
-   * currently loaded scope.
+   * Completed-turn prefix of the parent's log to seed the child session with,
+   * or absent for a fresh child. Same durable contract as
+   * `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced.
    */
-  readonly parent: Agent
-  /**
-   * Activation-owned cancellation signal, created before descriptor lookup.
-   * Same pre/post-publication contract as {@link SubagentStartRequest.signal}:
-   * an abort before publication rejects after rollback quiescence, and an
-   * abort afterward cancels the published child turn.
-   */
-  readonly signal: AbortSignal
-  /** The folded durable descriptor whose composition the provider reconstructs. */
-  readonly descriptor: SubagentDescriptorData
+  readonly seed?: readonly SessionEvent[]
 }
 ```
 
-描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照,包括提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;它绝不会对可通过合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则必须明确更改版本。描述符省略 `subagentDepth`(从持久化存储恢复时,以持久化 header 中的 `delegationDepth` 为单调下界)和 `outputSchema`(单次激活的结果契约,而非持久化组合配置)。`subagent/descriptor` 事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。
+描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照——提供方名称、已解析的子 agent `agentOptions.provider`/`model`、可选的 `persona`/`toolFilter`——绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。它省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次结果契约,而非持久化组合配置)。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前,追加对模型隐藏的 `subagent/descriptor` 事件;`header.seedLength` 仍是 fork 谱系边界,因此描述符查找会读取子 agent 自身的后缀。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。
 
 ## 终态结果:`SubagentResult`
 
@@ -249,17 +274,18 @@ interface SubagentStopReasonMap {
 }
 ```
 
-<a id="a-live-run-subagentrun"></a>
+## 单次 run:`SubagentRun`
 
-## 活跃 run:`SubagentRun`
-
-`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄;它表示一次可 dispose(资源释放)的激活,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可继续结果为 completed 还表示提供方已确认本次激活的最终状态具备持久性;必需检查点失败则会 reject。可选且提供确认语义的 `steer` 方法通过自身的存在公布在线投递功能,并且只有在请求快照准入该消息后才会兑现。从持久化存储恢复属于提供方级操作:`SubagentProvider.resume` 会根据子 agent 的持久化会话重建一个新 run,因为进程内 run 在 dispose 或进程重启后就不再存在。
+`SubagentRun` 是消费方持有的、指向一个就绪单次子 agent 的句柄——一次可 dispose 的前台委派,只有一个结果,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有无法表示的基础设施故障才会 reject。run 没有 steering,也没有 resume:可继续对话根本没有 run,因为继续执行管理器直接持有它们的 `AgentHandle`,并通过子 agent 自己的收件箱为每个轮次排序。
 
 ```ts type-equiv
 /**
- * Child handle returned only after readiness. Consumers await {@link result} and must always
- * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime
- * capability discovery; narrow their presence before calling.
+ * ONE-SHOT child handle returned only after readiness. Consumers await
+ * {@link result} and must always {@link dispose} to cancel remaining work and
+ * reach quiescence. A run is one disposable foreground delegation with one
+ * result; continuable conversations have no run — the continuation manager
+ * holds their `AgentHandle` directly and orders every turn through the child's
+ * own inbox.
  */
 interface SubagentRun {
   /**
@@ -278,10 +304,8 @@ interface SubagentRun {
    * Resolves with the child's terminal {@link SubagentResult} when the run
    * settles. Does NOT reject on a child-level failure — a model/transport
    * failure resolves with `stopReason: 'error'` so the consumer maps it to an
-   * `isError` tool result. For a continuable activation, a completed result
-   * also means the provider confirmed the activation's final state durable.
-   * Rejects on an infrastructure fault the seam cannot represent as a stop
-   * reason, including a failed required durability checkpoint.
+   * `isError` tool result. Rejects on an infrastructure fault the seam cannot
+   * represent as a stop reason.
    */
   readonly result: Promise<SubagentResult>
   /**
@@ -289,25 +313,16 @@ interface SubagentRun {
    * Idempotent.
    */
   dispose(): Promise<void>
-  /**
-   * OPTIONAL (confirmed live-steering capability): submit additional content
-   * to the active child and fulfill only after a committed request snapshot
-   * admits it. Rejects when terminal policy, cancellation, disposal, or a lost
-   * settlement race prevents admission; it never falls through to a queued
-   * untracked turn or cold resume. A run represents one disposable activation,
-   * so resuming a settled child goes through {@link SubagentProvider.resume}.
-   * `source` is retained on the admitted steering message without changing its
-   * user role in model history.
-   */
-  steer?(content: ContentBlock[], source: MessageSource): Promise<void>
 }
 ```
 
-本地 run 必须在 `start()` fulfill 前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。
+本地单次 run 必须在 `start()` fulfill 之前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切的子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。
+
+<a id="the-provider-seam-subagentprovider"></a>
 
 ## 提供方 seam:`SubagentProvider`
 
-每个提供方是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型措辞,而不暗示继承了工具、服务或权限。
+每个提供方都是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力,并拒绝在没有 `prepareContinuable` 的提供方上发起可继续 start。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型措辞,而不暗示继承了工具、服务或权限。
 
 ```ts type-equiv
 /**
@@ -327,33 +342,37 @@ interface SubagentProvider {
    */
   readonly inheritsParentContext: boolean
   /**
-   * Establish a child and return its handle only after publication. The
-   * service has already validated that every requested start-time capability
-   * is supported, so an implementation may assume e.g. `request.maxDepth` is
-   * honorable when present. If setup fails or `request.signal` aborts before
-   * fulfillment, the provider owns and cleans all partial resources before this
-   * promise rejects. Ownership transfers to the caller only on fulfillment.
+   * Establish a ONE-SHOT child and return its handle only after publication.
+   * The service has already validated that every requested start-time
+   * capability is supported, so an implementation may assume e.g.
+   * `request.maxDepth` is honorable when present. If setup fails or
+   * `request.signal` aborts before fulfillment, the provider owns and cleans
+   * all partial resources before this promise rejects. Ownership transfers to
+   * the caller only on fulfillment.
    */
-  start(request: SubagentProviderStartRequest): Promise<SubagentRun>
+  start(request: SubagentStartRequest): Promise<SubagentRun>
   /**
-   * OPTIONAL (continuation capability): reconstruct a persisted continuable
-   * child from its own transcript and declared descriptor, drive one
-   * follow-up turn, and return a fresh run. Method presence is the capability
-   * — the service rejects continuable starts and cold-resume dispatch on
-   * providers without it. Same publication contract as {@link start}: if
-   * reconstruction fails or `request.signal` aborts before fulfillment, the
-   * provider rolls its creation transaction back to quiescence before
-   * rejecting; after fulfillment the same signal cancels the published run.
+   * OPTIONAL (continuable-creation capability): contribute the detached
+   * creation inputs that distinguish this provider's continuable children —
+   * today only whether the child session is seeded with parent history. Method
+   * presence IS the capability: the service rejects continuable starts on
+   * providers without it, while a provider that has it may still serve
+   * ordinary one-shot delegations.
+   *
+   * This is the provider's ONLY participation in a continuable child. The
+   * continuation manager owns identity reservation, composition, Agent
+   * creation, prompt delivery, cold resume, ownership, and disposal, so a
+   * provider never sees the child's Agent, handle, turns, or teardown.
    */
-  resume?(request: SubagentProviderResumeRequest): Promise<SubagentRun>
+  prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
 }
 ```
 
-提供方的 `start()` 仅在 run 就绪时 fulfill;提供方的 `resume()` 采用相同的发布与生命周期观察契约,但只有继续执行管理器会分发它。服务铸造唯一 `runId`,从提供方确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离
+提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一 `runId`,从提供方确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常
 
 ## 进程内后端:深度与种子
 
-spawn 和 fork 后端通过 `parent.ctx` 创建一个普通 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇:
+spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose;而可继续子 agent 则由继续执行管理器通过其自己的 activation-owner 作用域创建。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇:
 
-- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。
-- **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `resume` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。
+- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。
+- **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。

+ 29 - 0
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts

@@ -211,6 +211,35 @@ describe('dsh-subagent-fork', () => {
     expect(ctx.subagents.list()).toEqual([])
   })
 
+  it('contributes the completed-turn prefix as a continuable child\'s seed', async () => {
+    const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child answer')])
+    const provider = ctx.subagents.getProvider('fork')!
+    const signal = new AbortController().signal
+
+    // Before any completed parent turn there is nothing to inherit, so the
+    // child starts fresh rather than carrying an empty seed.
+    const fresh = await provider.prepareContinuable!({
+      sessionId: SessionId('continuable-fresh'),
+      parent,
+      signal,
+    })
+    expect(fresh.seed).toBeUndefined()
+
+    // Complete one parent turn, then the prefix is captured once at creation.
+    parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
+    await parent.whenIdle()
+    const seeded = await provider.prepareContinuable!({
+      sessionId: SessionId('continuable-seeded'),
+      parent,
+      signal,
+    })
+    expect(seeded.seed).toBeDefined()
+    const lastSeeded = seeded.seed!.at(-1)
+    // The seed ends at a completed turn, so it replays as a valid child log.
+    expect(lastSeeded?.type).toBe('turn/end')
+    expect(seeded.seed!.map(event => event.seq)).toEqual(seeded.seed!.map((_event, index) => index))
+  })
+
   it('has the namespace-plugin export shape (no stray default)', () => {
     expect('default' in fork).toBe(false)
     expect(fork.name).toBe('subagent-fork')

+ 13 - 42
packages/subagent/subagent/src/continuation.ts

@@ -113,10 +113,10 @@ export interface ActivationObserver {
   /**
    * Publish the terminal edge exactly once. An epoch that never became resident
    * emits nothing, because it has no start edge to pair.
-   * @param child - the child agent whose final output the edge reports, if any.
+   * @param child - the child agent whose final output the edge reports.
    * @param failure - the teardown or durability failure, or `undefined` on success.
    */
-  settle(child: Agent | undefined, failure: unknown): void
+  settle(child: Agent, failure: unknown): void
 }
 
 /** Hooks the manager needs from the owning service. */
@@ -243,24 +243,6 @@ export class SubagentContinuationManager {
     }.bind(this), 'subagents.continuations()')
   }
 
-  /**
-   * Whether this manager still admits new materialization and delivery. Host
-   * teardown closes admission synchronously through {@link enterDraining}.
-   * @returns true once draining began.
-   */
-  get isDraining(): boolean {
-    return this.draining
-  }
-
-  /**
-   * Close admission synchronously: reject new creation, cold resume, and
-   * delivery so a host can drain the live Activation forest without racing new
-   * work. Idempotent.
-   */
-  enterDraining(): void {
-    this.draining = true
-  }
-
   /**
    * Read one durable child's live residency state.
    * @param childId - the durable child session id.
@@ -384,7 +366,9 @@ export class SubagentContinuationManager {
    * @throws an aggregate error when any branch failed to release.
    */
   async drain(): Promise<void> {
-    this.enterDraining()
+    // Close admission synchronously before the first await, so no new creation,
+    // cold resume, or delivery can race the snapshot below.
+    this.draining = true
     // 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>()
@@ -500,18 +484,10 @@ export class SubagentContinuationManager {
     signal: AbortSignal
   }): Promise<Activation> {
     const { childId, provider, parent } = inputs
-    if (this.activations.has(childId)) {
-      throw new SubagentError(
-        `subagent "${childId}" already has a live activation; the message was not delivered`,
-        'ACTIVATION_CONFLICT',
-      )
-    }
-    if (this.ctx.agents.get(childId) !== undefined) {
-      throw new SubagentError(
-        `subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`,
-        'OWNERSHIP_CONFLICT',
-      )
-    }
+    // No id pre-check here: the child lock serializes each durable child, both
+    // callers reach this only after confirming no Activation exists, and
+    // `AgentRegistry.enter()` is the authoritative collision boundary for an id
+    // some other owner holds — a duplicate would reject there with rollback.
     inputs.signal.throwIfAborted()
     const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) }
     const observer = this.host.observeActivation(provider, childId, parent)
@@ -535,7 +511,7 @@ export class SubagentContinuationManager {
     } catch (error: unknown) {
       // Agent creation provides rollback before handle transfer, so nothing
       // outlives this rejection; report the epoch that never became resident.
-      observer.settle(undefined, error)
+      // No start edge was published, so this epoch has no lifecycle to close.
       throw error
     }
 
@@ -558,16 +534,11 @@ export class SubagentContinuationManager {
     } catch (error: unknown) {
       // Roll the transfer back completely: the Activation leaves the map, the
       // parent's ownership membership is released, and the created handle is
-      // disposed before this rejection surfaces.
+      // disposed before this rejection surfaces. No lifecycle edge is published,
+      // because `observer.start()` below has not run for this epoch.
       this.activations.delete(childId)
       this.releaseOwnership(childId)
-      activation.disposal = (async () => {
-        try {
-          await handle.dispose()
-        } finally {
-          observer.settle(handle.agent, error)
-        }
-      })()
+      activation.disposal = handle.dispose()
       await activation.disposal.catch(() => undefined)
       throw error
     }

+ 4 - 3
packages/subagent/subagent/src/index.ts

@@ -369,7 +369,7 @@ export class SubagentService extends Service {
         started = true
         this.emitLifecycle('subagent/start', identity, parent)
       },
-      settle: (child: Agent | undefined, failure: unknown): void => {
+      settle: (child: Agent, failure: unknown): void => {
         // A failure before residency has no start edge to pair, and inventing
         // one would report a lifecycle the child never had.
         if (settled || !started) return
@@ -462,9 +462,10 @@ export class SubagentService extends Service {
 /**
  * The child's last assistant message content, for one Activation's terminal
  * lifecycle edge. Absent when no assistant message reached the log.
+ * @param child - the settling child agent whose log is read.
+ * @returns its final assistant content, or `undefined` when it produced none.
  */
-function lastAssistantOutput(child: Agent | undefined): ContentBlock[] | undefined {
-  if (child === undefined) return undefined
+function lastAssistantOutput(child: Agent): ContentBlock[] | undefined {
   const message = child.session.events.findLast(
     (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
   )

+ 175 - 7
packages/subagent/subagent/tests/continuation.spec.ts

@@ -630,13 +630,181 @@ describe('continuable public surface', () => {
 })
 
 describe('continuable errors', () => {
-  it('rejects a second live Activation for the same durable child', async () => {
-    const { ctx, parent } = await setup([textResponse('unused')])
-    // Occupy the id with an unmanaged live Agent.
-    const squatter = ctx.agentLoop.create(SessionId('squatted'), { provider: 'mock', model: 'mock' })
-    await ctx.sessions.flush(squatter.session)
-    await expect(followup(ctx, { kind: 'user' }, SessionId('squatted'), message('hello')))
+  it('rejects a duplicate Activation at the agent registry collision boundary', async () => {
+    const hold = Promise.withResolvers<void>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    const child = await vi.waitFor(() => {
+      const found = ctx.agents.get(started.childId)
+      expect(found).toBeDefined()
+      return found!
+    })
+    // Drop the Activation without disposing the Agent, leaving the id live but
+    // unmanaged. Materialization must not adopt it.
+    const manager = (ctx.subagents as unknown as {
+      continuations: { activations: Map<SessionId, unknown> }
+    }).continuations
+    manager.activations.delete(started.childId)
+
+    await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello')))
       .rejects.toThrow(SubagentError)
-    void parent
+    expect(ctx.agents.get(started.childId)).toBe(child)
+    hold.resolve()
+  })
+
+  it('rejects parent authority whose agent is no longer the live registry entry', async () => {
+    const { ctx, parent } = await setup([textResponse('first')])
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    const child = await vi.waitFor(() => {
+      const found = ctx.agents.get(started.childId)
+      expect(found).toBeDefined()
+      return found!
+    })
+    // A stale parent reference: same id, not the exact live entry.
+    const stale = { ...parent, id: parent.id } as unknown as Agent
+
+    await expect(followup(ctx, { kind: 'parent', agent: stale }, started.childId, message('stale')))
+      .rejects.toMatchObject({ code: 'UNAUTHORIZED' })
+    void child
+  })
+
+  it('rejects establishing a child under a parent whose disposal already began', async () => {
+    const hold = Promise.withResolvers<void>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    const child = await vi.waitFor(() => {
+      const found = ctx.agents.get(started.childId)
+      expect(found).toBeDefined()
+      return found!
+    })
+
+    // Begin the parent Activation's teardown, then try to give it a child.
+    const drained = ctx.subagents.drainContinuable()
+    await expect(ctx.subagents.startContinuable(startSpec(child)))
+      .rejects.toMatchObject({ code: 'DRAINING' })
+    hold.resolve()
+    await drained
+  })
+
+  it('reports a failing branch after every branch settles, without pinning the rest', async () => {
+    const hold = Promise.withResolvers<void>()
+    const adapter = new GatedAdapter([
+      { chunks: textResponse('child done') },
+      { chunks: textResponse('grandchild'), gate: hold.promise },
+    ])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    const child = await vi.waitFor(() => {
+      const found = ctx.agents.get(started.childId)
+      expect(found).toBeDefined()
+      return found!
+    })
+    const grandchild = await ctx.subagents.startContinuable(startSpec(child))
+    await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() })
+    // Make the grandchild's own handle disposal reject: scope teardown failure
+    // propagates, unlike a contained `agent/disposed` listener throw.
+    const manager = (ctx.subagents as unknown as {
+      continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
+    }).continuations
+    const branch = manager.activations.get(grandchild.childId)!
+    const realDispose = branch.handle.dispose.bind(branch.handle)
+    branch.handle.dispose = async () => {
+      await realDispose()
+      throw new Error('grandchild reap failed')
+    }
+
+    const drained = ctx.subagents.drainContinuable()
+    hold.resolve()
+    await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
+    // The other branch still released, and durable sessions survive.
+    expect(ctx.agents.get(started.childId)).toBeUndefined()
+    const loaded = await ctx.sessionPersistence.load(started.childId)
+    expect(loaded.meta.id).toBe(started.childId)
+  })
+
+  it('rolls the transfer back when ownership registration fails after handle transfer', async () => {
+    const hold = Promise.withResolvers<void>()
+    const adapter = new GatedAdapter([
+      { chunks: textResponse('parent child'), gate: hold.promise },
+      { chunks: textResponse('unused') },
+    ])
+    const { ctx, parent } = await setupWith(adapter)
+    const outer = await ctx.subagents.startContinuable(startSpec(parent))
+    const child = await vi.waitFor(() => {
+      const found = ctx.agents.get(outer.childId)
+      expect(found).toBeDefined()
+      return found!
+    })
+    // Begin the would-be parent's disposal, then race a grandchild into it. The
+    // handle transfers before ownership registration rejects, so the rollback
+    // must leave no Activation and no live Agent behind.
+    const manager = (ctx.subagents as unknown as {
+      continuations: { activations: Map<SessionId, { disposal: Promise<void> | undefined }> }
+    }).continuations
+    const before = new Set(ctx.agents.list().map(agent => agent.id))
+    manager.activations.get(outer.childId)!.disposal = Promise.resolve()
+
+    await expect(ctx.subagents.startContinuable(startSpec(child)))
+      .rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' })
+    await vi.waitFor(() => {
+      expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([])
+    })
+    hold.resolve()
+  })
+
+  it('reapplies the descriptor model route on cold resume', async () => {
+    const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed')])
+    const started = await ctx.subagents.startContinuable({
+      ...startSpec(parent),
+      request: {
+        prompt: message('routed work'),
+        parent,
+        agentOptions: { provider: 'mock', model: 'child-model' },
+      },
+    })
+    await waitNoActivation(ctx, started.childId)
+    const loaded = await ctx.sessionPersistence.load(started.childId)
+    expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data)
+      .toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' })
+
+    // The resumed Activation runs on the declared route, not the parent's.
+    await followup(ctx, { kind: 'user' }, started.childId, message('again'))
+    await vi.waitFor(() => {
+      expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model')
+    })
+    await waitNoActivation(ctx, started.childId)
+  })
+
+  it('drains without continuation services as a no-op', async () => {
+    const ctx = new Context()
+    await mountAgentLoopTestDependencies(ctx)
+    await ctx.plugin(SubagentService)
+    // No `ctx.agents`, so no manager was ever bound and nothing was materialized.
+    await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
+  })
+
+  it('unloading the manager drains its live activations', async () => {
+    const hold = Promise.withResolvers<void>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])
+    const ctx = new Context()
+    await mountAgentLoopTestDependencies(ctx)
+    const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-'))
+    roots.push(root)
+    await ctx.plugin(JsonlSessionPersistence, { root })
+    await ctx.plugin(AgentLoop, { agents: [] })
+    const serviceFiber = await ctx.plugin(SubagentService)
+    await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
+    ctx.llm.registerAdapter(['mock'], adapter)
+    const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() })
+
+    // Manager unload uses the same drain, so no child outlives its runtime.
+    const disposal = serviceFiber.dispose()
+    hold.resolve()
+    await disposal
+    expect(ctx.agents.get(started.childId)).toBeUndefined()
   })
 })

+ 79 - 0
packages/subagent/subagent/tests/run-settlement.spec.ts

@@ -0,0 +1,79 @@
+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'
+
+describe('outcome mapping helpers', () => {
+  it.each([
+    ['completed', { status: 'completed', output: 'partial' }],
+    ['aborted', { status: 'killed' }],
+    ['error', { status: 'failed', detail: 'error' }],
+    ['max-tokens', { status: 'failed', detail: 'max-tokens' }],
+    ['refusal', { status: 'failed', detail: 'refusal' }],
+    ['paused', { status: 'failed', detail: 'paused' }],
+  ] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => {
+    const output = [{ type: 'text' as const, text: 'partial' }]
+    await expect(settleRun({
+      id: SessionId('child'),
+      localAgent: undefined,
+      result: Promise.resolve({ output, stopReason: stopReason as never }),
+      dispose: () => Promise.resolve(),
+    })).resolves.toEqual(expected)
+  })
+
+  it('settleRun disposes the run before reporting, on both result paths', async () => {
+    const order: string[] = []
+    const completed = await settleRun({
+      id: SessionId('child-1'),
+      localAgent: undefined,
+      result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
+      dispose() { order.push('dispose'); return Promise.resolve() },
+    })
+    order.push('reported')
+    expect(completed).toEqual({ status: 'completed', output: 'ok' })
+    expect(order).toEqual(['dispose', 'reported'])
+
+    // An infrastructure rejection still disposes and reports failed.
+    let disposed = false
+    const failed = await settleRun({
+      id: SessionId('child-2'),
+      localAgent: undefined,
+      result: Promise.reject(new Error('transport gone')),
+      dispose() { disposed = true; return Promise.resolve() },
+    })
+    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,
+      result: Promise.resolve({ output: [], stopReason: 'completed' }),
+      dispose: () => Promise.reject(new Error('reap failed')),
+    })
+    expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
+
+    const bothFailed = await settleRun({
+      id: SessionId('child-5'),
+      localAgent: undefined,
+      result: Promise.reject(new Error('result failed')),
+      dispose: () => Promise.reject(new Error('reap failed')),
+    })
+    expect(bothFailed).toEqual({
+      status: 'failed',
+      detail: 'Error: result failed; dispose failed: Error: reap failed',
+    })
+  })
+})