refactor(subagent-subprocess): shared provider skeleton for out-of-process backends

The duplication gate flagged three ACP/SDK clones; the shared halves move
into dsh-subagent-subprocess as provider.ts: NO_START_CAPABILITIES (frozen
all-false advertisement), assertPositiveFinite (prefix-parameterized timing
validation), settleRunResult (never-reject result settlement with contained
onError sink and listener hygiene), and subprocessRunHandle (idempotent
dispose publication). Both backends now compose these; the previously
unreachable cancelled-rejection branch is directly unit-tested at the
library level instead of v8-ignored in each backend. knip learns the
subagent-sdk workspace (e2e entry outside vitest unit includes).
This commit is contained in:
Tianyi Cui
2026-07-27 04:58:11 +08:00
parent 34aabc4183
commit 441110f6da
14 changed files with 340 additions and 96 deletions

View File

@@ -10,7 +10,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
export const name = 'subagent-acp'
@@ -66,13 +66,6 @@ export const Config: z<Config> = z.object({
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
/** A dispose grace must be a positive finite number (it bounds the teardown wait). */
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`subagent-acp: ${name} must be a positive finite number`)
}
}
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
@@ -82,7 +75,7 @@ type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
* a request needing any of them before `start` runs).
*/
class AcpProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
// Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary.
readonly inheritsParentContext = false
@@ -110,8 +103,8 @@ class AcpProvider implements SubagentProvider {
export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
assertPositiveFinite('subagent-acp', 'disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('subagent-acp', 'disposeGraceMs', resolved.disposeGraceMs)
// Interpret a relative configured cwd against the harness launch directory
// ONCE, at load, and fail a misconfigured directory here — not per start.
const configuredCwd = validateConfiguredCwd('subagent-acp', resolved.cwd)

View File

@@ -26,7 +26,7 @@ import {
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess'
import { buildChildEnv, disposeChildProcess, settleRunResult, spawnFailure, subprocessRunHandle } from '@deepseek-ai/dsh-subagent-subprocess'
/** Fixed response to child permission requests: reject by default, or select the first allow option. */
export type PermissionPolicy = 'allow' | 'reject'
@@ -267,48 +267,36 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id')
const remoteSessionId = sessionId
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
try {
// Race the remote turn against local cancellation.
// Race the remote turn against local cancellation; the shared settlement
// flattens failures under the seam's never-reject contract.
const result: Promise<SubagentResult> = settleRunResult({
attempt: async () => {
const prompt = async (): Promise<SubagentResult> => {
// The startup phase cannot fulfill without assigning the session id.
const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) })
return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
}
return await Promise.race([
return Promise.race([
prompt(),
cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
])
} catch (error: unknown) {
// Cover a process rejection already queued when cancellation arrives.
/* v8 ignore next */
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
// Flatten post-publication transport failures while preserving diagnostics.
try {
spec.onError?.(toError(error), 'error')
} catch {
// The diagnostic sink cannot reject the run result.
}
return { output: collectOutput(), stopReason: 'error' }
} finally {
request.signal.removeEventListener('abort', onAbort)
}
})()
let disposal: Promise<void> | undefined
return {
id,
localAgent: undefined,
result,
dispose(): Promise<void> {
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
requestCancel()
// The shared platform-aware ladder awaits exit. ACP normally quiesces from
// stdin EOF, including the final flush, so this backend uses a wider EOF
// grace before process termination escalates.
disposal = disposeProcess()
return disposal
},
}
collectOutput,
cancelled: () => flags.cancelled,
onError: spec.onError,
signal: request.signal,
onAbort,
})
// The shared platform-aware ladder awaits exit. ACP normally quiesces from
// stdin EOF, including the final flush, so this backend uses a wider EOF
// grace before process termination escalates.
return subprocessRunHandle({
id,
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: disposeProcess,
})
}

View File

@@ -13,7 +13,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
import {
DEFAULT_DISPOSE_EOF_GRACE_MS,
DEFAULT_DISPOSE_GRACE_MS,
@@ -79,13 +79,6 @@ export const Config: z<Config> = z.object({
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
/** A timing bound must be a positive finite number (it bounds a teardown wait). */
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`subagent-sdk: ${name} must be a positive finite number`)
}
}
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
@@ -95,7 +88,7 @@ type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
* service rejects a request needing any of them before `start` runs).
*/
class SdkProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
// Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary.
readonly inheritsParentContext = false
@@ -125,9 +118,9 @@ class SdkProvider implements SubagentProvider {
export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveFinite('shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
assertPositiveFinite('subagent-sdk', 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveFinite('subagent-sdk', 'disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('subagent-sdk', 'disposeGraceMs', resolved.disposeGraceMs)
// Interpret a relative configured cwd against the harness launch directory
// ONCE, at load, and fail a misconfigured directory here — not per start.
const configuredCwd = validateConfiguredCwd('subagent-sdk', resolved.cwd)

View File

@@ -14,7 +14,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
import { buildChildEnv, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent-subprocess'
/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */
export interface SdkRunSpec {
@@ -175,43 +175,32 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
return text.length > 0 ? [{ type: 'text', text }] : []
}
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
try {
// Race the child turn against local cancellation; the shared settlement
// flattens failures under the seam's never-reject contract.
const result: Promise<SubagentResult> = settleRunResult({
attempt: async () => {
const turn = await Promise.race([
harness.session(childSessionId).run(request.prompt, { onNotification: observe }),
cancelSettled.then(() => 'cancelled' as const),
])
if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' }
return { output: collectOutput(), stopReason: sdkStopReason(turn.reason) }
} catch (error: unknown) {
// Cover a transport rejection already queued when cancellation arrives.
/* v8 ignore next */
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
// Flatten post-publication transport failures while preserving diagnostics.
try {
spec.onError?.(toError(error), 'error')
} catch {
// The diagnostic sink cannot reject the run result.
}
return { output: collectOutput(), stopReason: 'error' }
} finally {
request.signal.removeEventListener('abort', onAbort)
}
})()
let disposal: Promise<void> | undefined
return {
id,
localAgent: undefined,
result,
dispose(): Promise<void> {
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
// There is no wire-level prompt cancel: settle the result locally, then
// the bounded shutdown request + dispose ladder tears the child down.
requestCancel()
disposal = harness.close()
return disposal
},
}
collectOutput,
cancelled: () => flags.cancelled,
onError: spec.onError,
signal: request.signal,
onAbort,
})
// There is no wire-level prompt cancel: dispose settles the result locally,
// then the bounded shutdown request + dispose ladder tears the child down.
return subprocessRunHandle({
id,
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: () => harness.close(),
})
}

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
README.md: 6ae1778af1ca38a6c49c7f462e536a9c16c7e6bb
README.zh.md: 01847710df7c12ace7f87e45abc8e83958469740
README.md: b41b5d2b3ca50f5c117e4a0f848c232e83e741b8
README.zh.md: 5b017f23f71405966d84f409c6d616a8a5db52a3

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md).
Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md) and the [SDK backend](../subagent-sdk/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, resolve the child's working directory, publish the seam run handle, and isolate the child from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md).
Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library.
@@ -28,6 +28,14 @@ The two graces (`DisposeLadderGraces`) come from the consuming plugin's `dispose
The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child.
### `assertUsableCwd` / `validateConfiguredCwd` / `resolveChildCwd`
Child working-directory resolution, shared verbatim by the ACP and SDK backends: a configured `cwd` override is validated ONCE at load (`validateConfiguredCwd` — rejects the empty string, resolves a relative path against the harness launch directory, requires an enterable directory), and `resolveChildCwd` applies it per start, else validates the delegating parent session's cwd — never the server process's own cwd, because one server process serves many sessions. `assertUsableCwd` is the underlying probe: absolute, existing, and searchable (`X_OK` — what a subprocess cwd actually needs; a mode-600 directory passes `isDirectory()` but fails spawn with EACCES). Every diagnostic is prefixed with the consuming plugin's name.
### `NO_START_CAPABILITIES` / `settleRunResult` / `subprocessRunHandle`
The provider-side skeleton every out-of-process backend shares. `NO_START_CAPABILITIES` is the frozen all-false advertisement (an out-of-process child cannot honor parent-enforced start features, so the service rejects such requests before `start`). `settleRunResult` settles the run result under the seam's never-reject contract: an attempt rejection reads as `aborted` when local cancellation already settled, else flattens to `stopReason: 'error'` through a throw-contained diagnostic sink, always removing the abort listener. `subprocessRunHandle` publishes the seam handle with idempotent dispose: remove the listener, settle local cancellation, then await the backend's teardown to actual exit.
### `createIsolatedConfigDir(prefix, pinnedPath?)`
A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
用于**进程外 subagent 后端** 的共享机制:这类提供方会把外部 agent智能体作为子进程派生例如 [ACP 后端](../subagent-acp/README.md)。这是纯库(无提供方、无注册、无 Config提供所有「派生 CLI 子进程」后端都需要的机制:阻止父级部署凭据进入子进程、把子进程清理至完全停稳,以及将子进程与宿主用户的磁盘 CLI 状态隔离。设计理由见 [Claude Code / Codex subagent 后端 Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md)。
用于**进程外 subagent 后端** 的共享机制:这类提供方会把外部 agent智能体作为子进程派生例如 [ACP 后端](../subagent-acp/README.md)和 [SDK 后端](../subagent-sdk/README.md)。这是纯库(无提供方、无注册、无 Config提供所有「派生 CLI 子进程」后端都需要的机制:阻止父级部署凭据进入子进程、把子进程清理至完全停稳、解析子进程工作目录、发布接缝 run 句柄,以及将子进程与宿主用户的磁盘 CLI 状态隔离。设计理由见 [Claude Code / Codex subagent 后端 Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md)。
每个可调项都是**参数**dispose资源释放阶梯每次调用时接收宽限时间配置目录辅助函数接收可选的固定路径。默认值位于各个消费插件的 Config 中(带默认值且经过校验的字段,可从 `cordis.yml` 修改),绝不位于本库。
@@ -28,6 +28,14 @@
退出等待逻辑位于该阶梯内部。无论结算结果如何,它们都会清理自己的 timer 和监听器,因此升级过程不会在子进程上累积监听器。
### `assertUsableCwd` / `validateConfiguredCwd` / `resolveChildCwd`
子进程工作目录解析,被 ACP 与 SDK 后端逐字共享:配置的 `cwd` 覆盖在加载时校验一次(`validateConfiguredCwd`——拒绝空字符串、把相对路径按 harness 启动目录解析、要求可进入的目录),`resolveChildCwd` 在每次 start 应用它,否则校验发起委托的父会话 cwd——绝不用服务器进程自己的 cwd因为一个服务器进程服务多个会话。`assertUsableCwd` 是底层探针:绝对、存在且可搜索(`X_OK`——子进程 cwd 真正需要的权限mode-600 目录能过 `isDirectory()` 却让 spawn 以 EACCES 失败)。所有诊断都带消费插件名前缀。
### `NO_START_CAPABILITIES` / `settleRunResult` / `subprocessRunHandle`
每个进程外后端共享的 provider 侧骨架。`NO_START_CAPABILITIES` 是冻结的全 false 能力宣告(进程外子进程无法执行父方强制的启动期特性,服务会在 `start` 之前拒绝此类请求)。`settleRunResult` 在接缝的绝不拒绝契约下定格 run 结果:尝试的拒绝在本地取消已定格时读作 `aborted`,否则经吞掉自身异常的诊断汇压平为 `stopReason: 'error'`,并总是移除 abort 监听器。`subprocessRunHandle` 发布幂等 dispose 的接缝句柄:移除监听器、定格本地取消,然后等待后端的拆除直至真正退出。
### `createIsolatedConfigDir(prefix, pinnedPath?)`
为外部 CLI 子进程创建每次运行独立的隔离配置目录(`CLAUDE_CONFIG_DIR` / `CODEX_HOME` 式重定向的目标),使子进程行为只取决于部署配置,绝不取决于宿主上任何 `~/.claude` / `~/.codex` 式状态。返回一个 `IsolatedConfigDir` 句柄:`path` 写入子进程环境,`remove()` 在 dispose 时运行。

View File

@@ -28,10 +28,15 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -12,6 +12,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
export * from './cwd.ts'
export * from './provider.ts'
/**
* Credential-shaped ambient env vars are NOT forwarded to a child by default

View File

@@ -0,0 +1,129 @@
/**
* Shared provider-side vocabulary for out-of-process subagent backends: the
* no-capabilities advertisement, timing-bound validation for the dispose
* ladder's graces, and the standard run-handle publication that owns dispose
* idempotence and abort-listener hygiene.
*
* @module @deepseek-ai/dsh-subagent-subprocess/provider
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
/**
* The capability advertisement of an out-of-process backend: NONE. A child in
* another process cannot honor parent-enforced start features
* (`outputSchema`/`maxDepth`/`toolFilter`/`persona`), so the service rejects a
* request needing any of them before `start` runs — never accepted-then-ignored.
*/
export const NO_START_CAPABILITIES: SubagentCapabilities = Object.freeze({
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
})
/**
* Assert a configured timing bound is a positive finite number (it bounds a
* teardown or shutdown wait; zero, negative, or NaN would skip or wedge it).
* @param prefix - the consuming plugin's diagnostic prefix (e.g. `subagent-acp`).
* @param name - the config field name, for the diagnostic.
* @param value - the configured value.
*/
export function assertPositiveFinite(prefix: string, name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${prefix}: ${name} must be a positive finite number`)
}
}
/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */
function toError(value: unknown): Error {
// The rejecting surfaces (wire clients, spawn error events) only throw
// `Error`s; the `String(value)` arm is a defensive fallback for a non-Error
// throw the typed surfaces cannot produce.
/* v8 ignore next */
return value instanceof Error ? value : new Error(String(value))
}
/** Inputs to {@link settleRunResult}. */
export interface RunResultSettlement {
/** The turn attempt (typically racing local cancellation); returns the terminal result. */
attempt: () => Promise<SubagentResult>
/** Snapshot of the child output streamed so far (a partial answer survives failure). */
collectOutput: () => ContentBlock[]
/** Whether local cancellation settled (an in-flight rejection then reads as `aborted`). */
cancelled: () => boolean
/** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */
onError?: ((error: Error, stopReason: SubagentStopReason) => void) | undefined
/** The request's cancellation signal (the listener is removed at settlement). */
signal: AbortSignal
/** The abort listener registered on {@link signal} at start. */
onAbort: () => void
}
/**
* Settle an out-of-process run result under the seam contract: `result` never
* rejects after publication. A rejection from the attempt resolves as
* `aborted` when cancellation already settled locally, else it is flattened
* to `stopReason: 'error'` through the contained diagnostic sink; the abort
* listener is removed on every path.
* @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring.
* @returns the terminal result (never a rejection).
*/
export async function settleRunResult(parts: RunResultSettlement): Promise<SubagentResult> {
try {
return await parts.attempt()
} catch (error: unknown) {
// Cover a rejection already queued when cancellation arrives.
if (parts.cancelled()) return { output: parts.collectOutput(), stopReason: 'aborted' }
// Flatten post-publication transport failures while preserving diagnostics.
try {
parts.onError?.(toError(error), 'error')
} catch {
// The diagnostic sink cannot reject the run result.
}
return { output: parts.collectOutput(), stopReason: 'error' }
} finally {
parts.signal.removeEventListener('abort', parts.onAbort)
}
}
/** Inputs to {@link subprocessRunHandle}. */
export interface SubprocessRunHandleParts {
/** The parent-scoped run id. */
id: SubagentRun['id']
/** The flattened, never-rejecting result (the seam contract). */
result: Promise<SubagentResult>
/** The request's cancellation signal (the listener is removed on dispose). */
signal: AbortSignal
/** The abort listener registered on {@link signal} at start. */
onAbort: () => void
/** Settle local cancellation so {@link result} resolves without the child. */
requestCancel: () => void
/** Tear the child process down to quiescence (backend-owned ladder). */
teardown: () => Promise<void>
}
/**
* Publish the seam run handle for an out-of-process child. `dispose()` is
* idempotent (one memoized teardown): it removes the abort listener, settles
* local cancellation — there is no assumption the child cooperates — and then
* awaits the backend's teardown to actual exit.
* @param parts - the run identity, result, cancellation wiring, and teardown.
* @returns the seam run handle (`localAgent` is `undefined` for remote runs).
*/
export function subprocessRunHandle(parts: SubprocessRunHandleParts): SubagentRun {
let disposal: Promise<void> | undefined
return {
id: parts.id,
localAgent: undefined,
result: parts.result,
dispose(): Promise<void> {
if (disposal !== undefined) return disposal
parts.signal.removeEventListener('abort', parts.onAbort)
parts.requestCancel()
disposal = parts.teardown()
return disposal
},
}
}

View File

@@ -9,8 +9,12 @@ import {
buildChildEnv,
createIsolatedConfigDir,
disposeChildProcess,
NO_START_CAPABILITIES,
settleRunResult,
spawnFailure,
subprocessRunHandle,
} from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm
// failures are not portable and disappear under root, so this is the sanctioned filesystem seam.
@@ -387,3 +391,104 @@ describe('createIsolatedConfigDir', () => {
}
})
})
describe('NO_START_CAPABILITIES', () => {
it('advertises nothing and is frozen (shared by every out-of-process backend)', () => {
expect(NO_START_CAPABILITIES).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false })
expect(Object.isFrozen(NO_START_CAPABILITIES)).toBe(true)
})
})
describe('settleRunResult', () => {
const wiring = () => {
const controller = new AbortController()
const onAbort = vi.fn()
controller.signal.addEventListener('abort', onAbort)
return { controller, onAbort }
}
it('passes a successful attempt through and removes the abort listener', async () => {
const { controller, onAbort } = wiring()
const result = await settleRunResult({
attempt: async () => ({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }),
collectOutput: () => [],
cancelled: () => false,
signal: controller.signal,
onAbort,
})
expect(result.stopReason).toBe('completed')
controller.abort()
// The listener was removed at settlement, so the abort never reaches it.
expect(onAbort).not.toHaveBeenCalled()
})
it('reads an in-flight rejection as aborted when cancellation already settled', async () => {
const { controller, onAbort } = wiring()
const result = await settleRunResult({
attempt: async () => { throw new Error('pipe torn mid-cancel') },
collectOutput: () => [{ type: 'text', text: 'partial' }],
cancelled: () => true,
signal: controller.signal,
onAbort,
})
expect(result).toEqual({ output: [{ type: 'text', text: 'partial' }], stopReason: 'aborted' })
})
it('flattens a failure through a contained onError sink', async () => {
const { controller, onAbort } = wiring()
const seen: string[] = []
const result = await settleRunResult({
attempt: async () => { throw new Error('transport died') },
collectOutput: () => [],
cancelled: () => false,
onError: (error, stopReason) => {
seen.push(`${stopReason}:${error.message}`)
throw new Error('sink failure must be contained')
},
signal: controller.signal,
onAbort,
})
expect(result.stopReason).toBe('error')
expect(seen).toEqual(['error:transport died'])
})
it('flattens a failure without a sink', async () => {
const { controller, onAbort } = wiring()
const result = await settleRunResult({
attempt: async () => { throw new Error('no sink configured') },
collectOutput: () => [],
cancelled: () => false,
signal: controller.signal,
onAbort,
})
expect(result.stopReason).toBe('error')
})
})
describe('subprocessRunHandle', () => {
it('publishes an idempotent dispose that cancels locally and awaits teardown', async () => {
const controller = new AbortController()
const onAbort = vi.fn()
controller.signal.addEventListener('abort', onAbort)
const requestCancel = vi.fn()
const teardown = vi.fn(() => Promise.resolve())
const run = subprocessRunHandle({
id: SessionId('run-1'),
result: Promise.resolve({ output: [], stopReason: 'completed' }),
signal: controller.signal,
onAbort,
requestCancel,
teardown,
})
expect(run.localAgent).toBeUndefined()
expect(String(run.id)).toBe('run-1')
const disposal = run.dispose()
expect(run.dispose()).toBe(disposal)
await disposal
expect(requestCancel).toHaveBeenCalledTimes(1)
expect(teardown).toHaveBeenCalledTimes(1)
controller.abort()
// dispose removed the abort listener before cancelling.
expect(onAbort).not.toHaveBeenCalled()
})
})

View File

@@ -8,6 +8,12 @@
"src"
],
"references": [
{
"path": "../../llm/llm"
},
{
"path": "../subagent"
},
{
"path": "../../support/invariants"
}