From 776d666246d5ded20405802767419eb6f3dab07c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:52:41 +0800 Subject: [PATCH 1/2] refactor(subprocess): the dispose ladder moves to its one consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SubprocessHandle loses dispose(graces) and SubprocessDisposeGraces: the stdin-EOF→SIGTERM→SIGKILL sequence is teardown POLICY encoding one consumer's cooperation shape, not process vocabulary — the seam keeps kill/terminate/waitForExit, and waitForExit(signal?) is the quiescence probe a consumer ladder needs. dsh-subagent-acp owns disposeAcpChild() over those public verbs (tier tests move into its suite; a never-exits stub pins the fail-loud path); dsh-subprocess-local sheds the ladder, its deadline import, and the dsh-timeout dependency. Every future backend now owes four verbs and no teardown policy. New bilingual ladder-ownership Agent Note records the decision; catalogs regenerated. --- ...26-subprocess-consumer-migration.i18n.yaml | 4 +- ...026-07-26-subprocess-consumer-migration.md | 6 +- ...-07-26-subprocess-consumer-migration.zh.md | 6 +- ...07-27-dispose-ladder-to-consumer.i18n.yaml | 6 + .../2026-07-27-dispose-ladder-to-consumer.md | 23 ++++ ...026-07-27-dispose-ladder-to-consumer.zh.md | 23 ++++ docs/cordis-catalog/services.md | 4 +- .../core-data-structures/subprocess.i18n.yaml | 4 +- docs/core-data-structures/subprocess.md | 36 +----- docs/core-data-structures/subprocess.zh.md | 36 +----- .../cordis/tool-cordis/src/api-catalog.ts | 6 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 45 +++++++- .../subagent-acp/tests/subagent-acp.spec.ts | 68 ++++++++++- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/package.json | 2 - .../subprocess/subprocess-local/src/index.ts | 4 +- .../subprocess/subprocess-local/src/spawn.ts | 46 +------- .../subprocess-local/tests/spawn.spec.ts | 107 ++++-------------- .../subprocess/subprocess-local/tsconfig.json | 3 - .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 4 +- packages/subprocess/subprocess/README.zh.md | 4 +- packages/subprocess/subprocess/src/index.ts | 15 ++- packages/subprocess/subprocess/src/types.ts | 33 ------ .../subprocess/tests/service.spec.ts | 4 +- pnpm-lock.yaml | 3 - scripts/type-equiv.manifest.json | 5 - 32 files changed, 223 insertions(+), 294 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md create mode 100644 .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 4326462cbf..43c859efe2 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.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 -2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 41bdf04bc03517cf9fe10a61a565e442ecee5520 +2026-07-26-subprocess-consumer-migration.md: 41b2dd12c18cda91937569319d57ff65a804bd55 +2026-07-26-subprocess-consumer-migration.zh.md: d371573548863e404686e317ab763cda5a0d2995 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md index 805f27ba2e..41b2dd12c1 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -14,10 +14,10 @@ The seam's vocabulary is now Node-shaped, and every spawner that can ride the se - **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch). - **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome. -- **Tree-scoped termination, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. +- **Tree-scoped termination, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. (The stdin-EOF-first dispose ladder initially absorbed from `subagent-subprocess` later moved back out to its one consumer — see the [ladder-ownership Agent Note](2026-07-27-dispose-ladder-to-consumer.md).) - **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well. -Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is `handle.dispose` with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). +Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` (the plugins inject `'subprocess'`); the acp/lsp test fixtures gained the row. @@ -33,6 +33,6 @@ Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` ## Consequences -Bought: one implementation of tree signalling, escalation, the dispose ladder, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. +Bought: one implementation of tree signalling, escalation, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. Cost: the seam is wider — three stdio modes and four termination verbs instead of one of each — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 41bdf04bc0..d371573548 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -14,10 +14,10 @@ Status: implemented - **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 -- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 +- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界)。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。(最初从 `subagent-subprocess` 吸收的以 stdin EOF 打头的 dispose 阶梯,后来又移回其唯一消费方——见[阶梯归属 Agent Note](2026-07-27-dispose-ladder-to-consumer.md)。) - **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 -各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 +各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 挂载 lsp-local 或 subagent-acp 的组合如今都加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 @@ -33,6 +33,6 @@ Status: implemented ## 后果 -换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 +换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml new file mode 100644 index 0000000000..bd5964f1a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 +2026-07-27-dispose-ladder-to-consumer.zh.md: b6849ad393737f2fef06e2007991583b12a04d7a diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md new file mode 100644 index 0000000000..97b551ff50 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -0,0 +1,23 @@ +# Agent Note: The dispose ladder belongs to its consumer, not the subprocess seam + +Status: implemented + +English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) + +## Problem + +`SubprocessHandle.dispose(graces)` and `SubprocessDisposeGraces` put a full teardown *policy* — stdin-EOF wait, then SIGTERM, then SIGKILL, each tier bounded by a caller-supplied window — on a seam whose other verbs are single mechanisms. Only one consumer ever called it (the ACP subagent backend); bash rides `terminate()` and service teardown, and the LSP host runs its own protocol-first shutdown. Every future backend nonetheless had to implement the ladder to satisfy the interface, and the implementation carried a `dsh-timeout` dependency solely for the ladder's tier bounds. + +## Decision + +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface. + +## Alternatives considered + +**Keep the ladder on the handle as a convenience.** Rejected: a seam method every implementation must provide is not a convenience, it is contract surface — and this one encodes one consumer's cooperation shape (stdin-EOF-first) as if it were process vocabulary. The seam's own README already had to caveat that children quiescing on other signals need "their own tier-1", which is the admission that the ladder is policy. + +**Move the ladder to a shared helper package.** Rejected: one consumer. A second out-of-process backend with the same stdin-EOF cooperation shape can lift `disposeAcpChild` to shared code when it exists; extracting now would recreate `dsh-subagent-subprocess`, the single-purpose library this stack just deleted. + +## Consequences + +Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md new file mode 100644 index 0000000000..b6849ad393 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -0,0 +1,23 @@ +# Agent Note: dispose 阶梯归其消费方所有,而非 subprocess seam + +Status: implemented + +[English](2026-07-27-dispose-ladder-to-consumer.md) | 中文 + +## 问题 + +`SubprocessHandle.dispose(graces)` 与 `SubprocessDisposeGraces` 把一整套拆卸*策略*——等待 stdin EOF、再 SIGTERM、再 SIGKILL,每一层由调用方提供的时间窗约束——放在了一个其余动词均为单一机制的 seam 上。它始终只有一个调用方(ACP subagent 后端);bash 走 `terminate()` 与服务拆卸,LSP 主机运行自己的协议优先关闭流程。然而每个未来后端都必须实现该阶梯才能满足接口,实现包也仅为阶梯的层级时限背上了 `dsh-timeout` 依赖。 + +## 决策 + +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 + +## 曾考虑的替代方案 + +**把阶梯作为便利方法留在句柄上。**否决:一个每个实现都必须提供的 seam 方法不是便利,而是契约表面——而这一个把某一消费方的配合形状(stdin EOF 打头)当作进程词汇来编码。seam 自己的 README 早已不得不加注「依赖其他信号停稳的子进程需要自己的第一阶」,这本身就是承认该阶梯是策略。 + +**把阶梯移到共享辅助包。**否决:只有一个消费方。当第二个具有相同 stdin EOF 配合形状的进程外后端出现时,可以再把 `disposeAcpChild` 提升为共享代码;现在抽取只会重造 `dsh-subagent-subprocess`——这组堆叠变更刚刚删掉的那个单一用途库。 + +## 后果 + +买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0b43c245a..abfa64a1af 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1567,7 +1567,7 @@ Implementations must honor these semantics: - spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.kill signals without escalation, SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and SubprocessHandle.dispose runs the cooperative EOF-first ladder — all tree-scoped on every platform. +- SubprocessHandle.kill signals without escalation, and SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — both tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. - Disposal of the service terminates all still-running managed processes and awaits their exit. ```ts cordis-catalog @@ -1582,7 +1582,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:112`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:111`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 0527cf34a3..1e942083be 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.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 -subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 -subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 +subprocess.md: 810f74b6512c846e138670aa6c3ad1c8be577366 +subprocess.zh.md: 8e6aca436a2a7a14ec4ab1da9b9d4f848918a9dc diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index cdd4507c7d..810f74b651 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and tree-scoped termination -A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, and `waitForExit()` observes the whole tree — enough for a consumer to build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). ```ts type-equiv /** @@ -177,15 +177,6 @@ interface SubprocessHandle { * @returns `true` when the tree exited, `false` when the signal aborted first. */ waitForExit(signal?: AbortSignal): Promise - /** - * Tear the child down to quiescence, resolving only after exit: close stdin - * (when this handle owns a piped one) and allow cooperative flush for - * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced - * tree termination with a final bounded `graceMs` wait. - * @param graces - the ladder's two windows, from the consumer's Config. - * @throws when the child still has not exited `graceMs` after the forced tier. - */ - dispose(graces: SubprocessDisposeGraces): Promise } ``` @@ -233,31 +224,6 @@ interface SubprocessCollectedOutputs { } ``` -```ts type-equiv -/** - * The two grace periods of the cooperative dispose ladder - * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, - * validated Config fields, so teardown timing is deployment-tunable and this - * seam hardcodes nothing. - */ -interface SubprocessDisposeGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own descendants — before - * escalation to platform termination. Usually WIDER than - * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven - * teardown may itself wait on a signal-trapping grandchild plus a final - * flush. - */ - eofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` - * and again after `SIGKILL`; Windows applies it after the forced tree - * termination. - */ - graceMs: number -} -``` ## Outcomes carry exit facts only diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 78325c3255..8e6aca436a 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与以进程树为范围的终止 -spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树——这足以让消费方构建自己的拆卸阶梯(ACP 后端以 stdin EOF 打头的 `disposeAcpChild` 即是模板)。 ```ts type-equiv /** @@ -177,15 +177,6 @@ interface SubprocessHandle { * @returns `true` when the tree exited, `false` when the signal aborted first. */ waitForExit(signal?: AbortSignal): Promise - /** - * Tear the child down to quiescence, resolving only after exit: close stdin - * (when this handle owns a piped one) and allow cooperative flush for - * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced - * tree termination with a final bounded `graceMs` wait. - * @param graces - the ladder's two windows, from the consumer's Config. - * @throws when the child still has not exited `graceMs` after the forced tier. - */ - dispose(graces: SubprocessDisposeGraces): Promise } ``` @@ -233,31 +224,6 @@ interface SubprocessCollectedOutputs { } ``` -```ts type-equiv -/** - * The two grace periods of the cooperative dispose ladder - * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, - * validated Config fields, so teardown timing is deployment-tunable and this - * seam hardcodes nothing. - */ -interface SubprocessDisposeGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own descendants — before - * escalation to platform termination. Usually WIDER than - * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven - * teardown may itself wait on a signal-trapping grandchild plus a final - * flush. - */ - eofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` - * and again after `SIGKILL`; Windows applies it after the forced tree - * termination. - */ - graceMs: number -} -``` ## 结果只承载退出事实 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index cf879df592..57d71fef7a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2221,13 +2221,9 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubprocessCollectedOutputs', declaration: 'export interface SubprocessCollectedOutputs {\n readonly stdout?: SubprocessOutputReader;\n readonly stderr?: SubprocessOutputReader;\n}', }, - { - name: 'SubprocessDisposeGraces', - declaration: 'export interface SubprocessDisposeGraces {\n eofGraceMs: number;\n graceMs: number;\n}', - }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n}', }, { name: 'SubprocessOutcome', diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 61f41f5665..a6b976c516 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.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 -README.md: 0c21e179bd5ca405057b682786c41c8447a78317 -README.zh.md: b349443cfcda80caa9b06cfa1cf80f6e780a98be +README.md: e573e22a02a301e18e341ea13c967718f6a7f625 +README.zh.md: 0522706fe0f0e3263c0c229812871ea17ebe8143 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 0c21e179bd..e573e22a02 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. After forced termination, every platform waits at most `disposeGraceMs` for exit and rejects on a signal error or missing exit. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index b349443cfc..0522706fe0 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose 请求了取消,则以 `aborted` 兑现。 -`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,关闭 stdin,并等待 `disposeEofGraceMs`。随后 POSIX 先升级到 SIGTERM,等待 `disposeGraceMs` 后再使用 SIGKILL;Windows 会直接强制终止,因为 Node 会把两个信号都映射到 `TerminateProcess`。强制终止后,各平台最多再等待 `disposeGraceMs` 以确认退出;若信号出错或未退出,则拒绝。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 07358f230c..34438f4f2a 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -91,6 +91,46 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ +async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, ms) + try { + return await child.waitForExit(controller.signal) + } finally { + clearTimeout(timer) + } +} + +/** + * Cooperative teardown ladder for an out-of-process agent, over the seam's + * public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's + * window to flush persistence and reap its own descendants), then the + * terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded + * confirmation wait. + * @param child - the spawned ACP child's handle. + * @param eofGraceMs - tier-1 window after stdin EOF. + * @param graceMs - confirmation window after the escalation's SIGKILL. + * @throws when the tree still has not exited `graceMs` after forced termination. + */ +export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise { + // A spawn failure has no process to tear down; observe the rejection so + // disposal in a finally block cannot surface it as unhandled. + if (child.pid <= 0) { + await child.done.catch(() => {}) + return + } + child.stdin?.end() + if (await treeExitsWithin(child, eofGraceMs)) return + // terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace + // (this plugin passes disposeGraceMs there), so the bound covers both the + // escalation window and an equal confirmation window after the SIGKILL. + child.terminate() + if (!(await treeExitsWithin(child, graceMs * 2))) { + throw new Error('ACP child process tree did not exit within its dispose windows') + } +} + /** * Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. * @param reason - the terminal reason from the child's `session/prompt` response. @@ -197,10 +237,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined - const disposeProcess = (): Promise => (processDisposal ??= child.dispose({ - eofGraceMs: spec.disposeEofGraceMs, - graceMs: spec.disposeGraceMs, - })) + const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs)) // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 7c10463000..0ab5916de9 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' -import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' +import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' @@ -136,6 +136,72 @@ describe('child env layering (through the subprocess seam)', () => { }) }) +describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => { + const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({ + argv: ['bash', '-c', command], + cwd: process.cwd(), + stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 200, + }) + + it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { + const child = bash('read -r line; exit 0') + await disposeAcpChild(child, 5_000, 200) + const outcome = await child.done + expect(outcome.exitCode).toBe(0) + expect(outcome.signal).toBeNull() + }) + + it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => { + const child = bash('sleep 60') + await disposeAcpChild(child, 100, 5_000) + const outcome = await child.done + expect(outcome.signal).toBe('SIGTERM') + }) + + it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => { + const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore') + // Wait for the trap to arm so SIGTERM cannot race the default handler. + while (!child.collected.stdout!.readFrom(0).text.includes('armed')) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + await disposeAcpChild(child, 50, 2_000) + const outcome = await child.done + expect(outcome.signal).toBe('SIGKILL') + }) + + it('throws when the tree survives even the escalation window', async () => { + // A handle whose tree never exits (waitForExit only ever aborts): the + // ladder must fail loud instead of resolving over survivors. Built as a + // stub because the ladder composes only public verbs. + const never: Parameters[0] = { + pid: 1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + kill: () => {}, + terminate: () => {}, + waitForExit: (signal?: AbortSignal) => new Promise((resolve) => { + signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + } + await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) + }) + + it('observes a spawn-level rejection and returns without a process to reap', async () => { + const child = spawnSubprocess({ + argv: ['bash', '-c', 'true'], + cwd: '/nonexistent-dir-dsh-acp-ladder-test', + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 200, + }) + await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined() + await expect(child.done).rejects.toThrow() + }) +}) + describe('cwd resolution', () => { it('falls back to the parent session cwd for the child process AND its ACP session', async () => { // realpath: on macOS `tmpdir()` sits behind a symlink (/var → /private/var), diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2e8c1fdae4..f34c84e520 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 -README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 -README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 +README.md: 16d83032ebf271a3e3e091962cad6dc9b689f072 +README.zh.md: e9a1284a81269741909631a6fbac8af3549800a3 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 08cc2ce7d9..16d83032eb 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README ## Behavior (and where it came from) -- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index da230ba37d..e9a1284a81 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为(以及设计来源) -- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 - **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 871b4cfac6..72ff50c422 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -29,13 +29,11 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index d71db5622a..eea1f1e739 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -16,8 +16,8 @@ import type { SpawnInternals } from './spawn.ts' /** * Local subprocess service: detached process trees, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill - * files), credential-scrubbed environment, tree-scoped signalling with - * SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. + * files), credential-scrubbed environment, and tree-scoped signalling with + * SIGTERM→grace→SIGKILL escalation. */ export class LocalSubprocessService extends SubprocessService { /** Live handles retained only so disposal can terminate and join them. */ diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8716f50b0c..bc716391e9 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,10 +1,9 @@ /** * Process plumbing for the local subprocess service: detached process-tree * spawn with per-stream stdio dispositions, tail-keep collection with spill - * files, tree-scoped signalling (POSIX groups; Windows taskkill), the - * SIGTERM→SIGKILL escalation, and the cooperative EOF-first dispose ladder. - * This layer reacts to an abort signal; callers own deadlines and classify - * causes. + * files, tree-scoped signalling (POSIX groups; Windows taskkill), and the + * SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers + * own deadlines, teardown ladders, and cause classification. * @module dsh-subprocess-local/spawn */ @@ -15,13 +14,11 @@ import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' -import { deadline } from '@deepseek-ai/dsh-timeout' import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { CollectedOutput, DshEnvironment, SubprocessCollect, - SubprocessDisposeGraces, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, @@ -63,9 +60,6 @@ export interface SpawnInternals { platform?: NodeJS.Platform } -/** Timeout code marking a dispose-ladder tier bound (vs an external abort). */ -const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER' - /** * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an * awaited teardown must keep the event loop alive until the tree really @@ -469,39 +463,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter return true } - /** - * Wait, bounded, for whole-tree exit — the dispose ladder's quiescence test. - * Tree liveness, not direct-child settlement: a TERM-trapping helper that - * outlives the leader must hold the ladder on its tier until it exits. - */ - const treeExitsWithin = async (ms: number): Promise => { - using bound = deadline(undefined, ms, DISPOSE_TIER_TIMEOUT) - return await waitForExit(bound.signal) - } - - let disposal: Promise | undefined - const dispose = (graces: SubprocessDisposeGraces): Promise => (disposal ??= (async () => { - // A spawn failure has no process to tear down; observe the rejection so - // disposal in a finally block cannot surface it as unhandled. - if (pid <= 0) { - await done.catch(() => {}) - return - } - // 1. Close a piped stdin and allow cooperative teardown and flush. - if (stdinMode === 'pipe') child.stdin?.end() - if (await treeExitsWithin(graces.eofGraceMs)) return - // 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates. - if (platform !== 'win32') { - kill('SIGTERM') - if (await treeExitsWithin(graces.graceMs)) return - } - // 3. Force-kill the tree and await a bounded exit edge. - kill('SIGKILL') - if (!(await treeExitsWithin(graces.graceMs))) { - throw new Error(`child process tree did not exit within ${graces.graceMs}ms after forced termination`) - } - })()) - return { pid, /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ @@ -517,6 +478,5 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter kill, terminate, waitForExit, - dispose, } } diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4b3e16fd5a..7936217b04 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -506,40 +506,6 @@ describe('stdio dispositions', () => { }) }) -describe('dispose ladder', () => { - it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { - const running = spawnSubprocess({ - ...spec('read -r line; exit 0'), - stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, - }) - await running.dispose({ eofGraceMs: 5_000, graceMs: 200 }) - const outcome = await running.done - expect(outcome.exitCode).toBe(0) - expect(outcome.signal).toBeNull() - }) - - it('tier 2: an EOF-deaf child dies by SIGTERM', async () => { - const running = spawnSubprocess({ - ...spec('sleep 60'), - stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, - }) - await running.dispose({ eofGraceMs: 100, graceMs: 5_000 }) - const outcome = await running.done - expect(outcome.signal).toBe('SIGTERM') - }) - - it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => { - const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60')) - await waitForStdout(running, 'armed\n') - const first = running.dispose({ eofGraceMs: 50, graceMs: 200 }) - const second = running.dispose({ eofGraceMs: 50, graceMs: 200 }) - expect(second).toBe(first) - await first - const outcome = await running.done - expect(outcome.signal).toBe('SIGKILL') - }) -}) - describe('windows tree semantics (injected platform)', () => { it('kill and terminate route through taskkill by root pid', async () => { const killed: number[] = [] @@ -590,7 +556,7 @@ describe('waitForExit', () => { }) }) -describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => { +describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from // the collected pipes, then exits: the helper holds the GROUP alive while @@ -609,18 +575,21 @@ describe('tree-survivor escalation (terminate/dispose reach helpers the leader l await waitGone(helper) }) - it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => { - const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`) + it('a bounded waitForExit reports false while a survivor lives, true after escalation', async () => { + const pidFile = join(spillDir, `survivor-wait-${Date.now()}.pid`) const running = spawnSubprocess(spec( `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`, { graceMs: 200 }, )) const helper = await waitForPidFile(pidFile) await running.done - expect(() => process.kill(helper, 0)).not.toThrow() - - await running.dispose({ eofGraceMs: 100, graceMs: 300 }) - // The ladder only returns once the WHOLE tree is gone. + // A consumer-owned teardown tier bounds its wait and reads the verdict. + const bound = new AbortController() + const timer = setTimeout(() => { bound.abort() }, 100) + await expect(running.waitForExit(bound.signal)).resolves.toBe(false) + clearTimeout(timer) + running.terminate() + await expect(running.waitForExit()).resolves.toBe(true) expect(() => process.kill(helper, 0)).toThrow() }) @@ -653,11 +622,10 @@ describe('coverage seams', () => { expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() }) - it('dispose on a spawn-failed handle observes the rejection and returns', async () => { + it('a spawn-failed handle rejects done while waitForExit reports gone', async () => { const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) - const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 }) await expect(running.done).rejects.toThrow() - await expect(disposal).resolves.toBeUndefined() + await expect(running.waitForExit()).resolves.toBe(true) }) it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => { @@ -692,25 +660,10 @@ describe('coverage seams', () => { await expect(running.waitForExit()).resolves.toBe(true) }) - it('dispose() on an already-exited tree returns without delivering a signal', async () => { - const running = spawnSubprocess(spec('true')) - await running.done - await running.waitForExit() - const spy = vi.spyOn(process, 'kill') - try { - await running.dispose({ eofGraceMs: 50, graceMs: 50 }) - const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) - expect(delivered).toEqual([]) - } finally { - spy.mockRestore() - } - }) - - it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => { + it('a batch-stdin handle exposes no stdin surface', async () => { const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' })) expect(running.stdin).toBeUndefined() await running.done - await running.dispose({ eofGraceMs: 50, graceMs: 50 }) expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n') }) }) @@ -739,33 +692,15 @@ describe('coverage seams 2', () => { await expect(running.waitForExit()).resolves.toBe(true) }) - it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => { - const kills: number[] = [] - const running = spawnSubprocess({ - ...spec('sleep 60'), - stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, - }, { - spillDir, - platform: 'win32', - taskkill: (pid) => { - kills.push(pid) - try { - process.kill(pid, 'SIGKILL') - } catch { - // Already gone. - } - }, - }) - await running.dispose({ eofGraceMs: 50, graceMs: 5_000 }) - // Exactly one forced tree termination: no POSIX SIGTERM tier ran. - expect(kills).toEqual([running.pid]) - }) - - it('dispose throws when even SIGKILL produces no exit within the grace', async () => { - // An inert taskkill simulates a tree that never reports exit. + it('an inert win32 taskkill leaves the tree alive for a bounded wait to report', async () => { + // An inert taskkill simulates a tree that never reports exit: terminate() + // delivers nothing, so a bounded consumer wait must come back false. const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} }) - await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 })) - .rejects.toThrow(/did not exit within 40ms after forced termination/) + running.terminate() + const bound = new AbortController() + const timer = setTimeout(() => { bound.abort() }, 60) + await expect(running.waitForExit(bound.signal)).resolves.toBe(false) + clearTimeout(timer) // Real cleanup: the injected platform spawned without detachment, so the // child is a plain (group-less) POSIX process — kill it directly. process.kill(running.pid, 'SIGKILL') diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 5272a4f78d..5a8dea211b 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../subprocess" }, - { - "path": "../../util/timeout" - }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 31f0fc4e84..a48a16cb26 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 -README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 -README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 +README.md: 271e9b912077bf5ec18d96666c216562cb87cc6e +README.zh.md: 692108ffe8848f823b3ab3fa487a2bf0fbc8fa44 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 2cb7a5ebce..271e9b9120 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -9,7 +9,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). +- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. @@ -26,4 +26,4 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced. -- **The dispose ladder assumes stdin-EOF cooperation** — a child that quiesces on a different signal (SIGHUP conventions, control sockets) needs its own tier-1 before the generic ladder fits. +- **Teardown ladders are consumer-owned** — the seam ships signalling verbs and the tree-liveness wait, not a canned quiesce sequence; each out-of-process consumer encodes its child's cooperation shape itself (the ACP backend's stdin-EOF-first ladder is the in-repo template). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index a3211834e0..692108ffe8 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -9,7 +9,7 @@ - `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 @@ -26,4 +26,4 @@ ## 已知限制与暂缓事项 - **node-pty 与由 SDK 管理的 spawn 只共享凭据清除**:PTY 后端的终端 fork 与 MCP SDK 自己的 stdio 传输层无法把 spawn 路由到这道 seam(fork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源。 -- **dispose 阶梯假定子进程配合 stdin EOF**:依赖其他信号(SIGHUP 惯例、控制 socket)才能完全停稳的子进程,需要自己的第一阶,通用阶梯才适用。 +- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合形状(ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index e4cfaae901..8001e1655f 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -2,10 +2,9 @@ * The subprocess seam (`ctx.subprocess`): spawn fully-specified commands into * managed process trees with Node-shaped stdio dispositions — raw pipes for * protocol streams, inherit for diagnostics, bounded spill-backed collection - * for batch output — plus tree-scoped signalling and a cooperative dispose - * ladder. Command defaulting, shell semantics, deadlines, framing, and - * presentation belong to consumers; the bash executor seam is the owning - * template. The local implementation lives in + * for batch output — plus tree-scoped signalling. Command defaulting, shell + * semantics, deadlines, teardown ladders, framing, and presentation belong to + * consumers; the bash executor seam is the owning template. The local implementation lives in * `@deepseek-ai/dsh-subprocess-local`. * @module @deepseek-ai/dsh-subprocess */ @@ -22,7 +21,6 @@ export type { DshEnvironmentKey, SubprocessCollect, SubprocessCollectedOutputs, - SubprocessDisposeGraces, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, @@ -102,10 +100,11 @@ declare module 'cordis' { * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped * streams are handed to the caller raw and never buffered here. - * - {@link SubprocessHandle.kill} signals without escalation, + * - {@link SubprocessHandle.kill} signals without escalation, and * {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates - * SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the - * cooperative EOF-first ladder — all tree-scoped on every platform. + * SIGTERM→grace→SIGKILL — both tree-scoped on every platform. + * {@link SubprocessHandle.waitForExit} observes whole-tree liveness, so a + * consumer-owned teardown ladder can hold each tier on real quiescence. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. */ diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 4c667919c7..b2e0740d01 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -160,30 +160,6 @@ export interface SubprocessCollectedOutputs { readonly stderr?: SubprocessOutputReader } -/** - * The two grace periods of the cooperative dispose ladder - * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, - * validated Config fields, so teardown timing is deployment-tunable and this - * seam hardcodes nothing. - */ -export interface SubprocessDisposeGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own descendants — before - * escalation to platform termination. Usually WIDER than - * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven - * teardown may itself wait on a signal-trapping grandchild plus a final - * flush. - */ - eofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` - * and again after `SIGKILL`; Windows applies it after the forced tree - * termination. - */ - graceMs: number -} - /** * A live child process rooted in its own process tree. Collected output * remains readable after exit; piped streams belong to the caller. @@ -226,13 +202,4 @@ export interface SubprocessHandle { * @returns `true` when the tree exited, `false` when the signal aborted first. */ waitForExit(signal?: AbortSignal): Promise - /** - * Tear the child down to quiescence, resolving only after exit: close stdin - * (when this handle owns a piped one) and allow cooperative flush for - * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced - * tree termination with a final bounded `graceMs` wait. - * @param graces - the ladder's two windows, from the consumer's Config. - * @throws when the child still has not exited `graceMs` after the forced tier. - */ - dispose(graces: SubprocessDisposeGraces): Promise } diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 9fb68f403f..612d349786 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { scrubbedParentEnv, splitEnvChannels, SubprocessService } from '@deepseek-ai/dsh-subprocess' -import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** * Minimal concrete service: a hand-built handle. The seam is spawn-only — @@ -24,7 +24,6 @@ class StubSubprocessService extends SubprocessService { kill: () => {}, terminate: () => {}, waitForExit: () => Promise.resolve(true), - dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(), } } } @@ -44,7 +43,6 @@ describe('SubprocessService seam', () => { handle.kill() handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) - await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined() const outcome = await handle.done expect(outcome.exitCode).toBe(0) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af57e80494..d9f00ed502 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3851,9 +3851,6 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 811ebd40c9..53d32758e0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1303,11 +1303,6 @@ "doc": "docs/core-data-structures/subprocess.md", "symbol": "SubprocessCollectedOutputs", "source": "packages/subprocess/subprocess/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subprocess.md", - "symbol": "SubprocessDisposeGraces", - "source": "packages/subprocess/subprocess/src/types.ts" } ] } From f81fcccd9376ca06de0430fb16992c8c832a0845 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:41:04 +0800 Subject: [PATCH 2/2] refactor(subprocess): keep terminate() as the seam's only termination verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete kill(signal?) from SubprocessHandle: consumers stop a process only through terminate()'s tree-scoped SIGTERM→graceMs→SIGKILL escalation (idempotent, also driven by the spec's abort signal, a no-op once the tree is gone). The single-signal verb had exactly one consumer family — lsp-local — and what it bought there was a private re-implementation of the same escalation. The internal kill closure stays in spawn.ts as the dispose ladder's tier primitive; terminate() now routes through it too. lsp-local collapses onto the seam's escalation: - LspConnection replaces its terminate()/kill() pair with one terminate() that delegates to handle.terminate(). Behavior change: the framing-failure path terminates instead of instant SIGKILL, so a misbehaving server now gets SIGTERM plus the killGraceMs window to flush before SIGKILL. - ConnectionSpec.pipeDrainGraceMs becomes killGraceMs: one grace, the spawn spec's graceMs, drives both the escalation window and post-exit pipe draining (the provider already passed killGraceMs for it). - LspInstance.forceTerminate() drops its hand-rolled bounded first wait (LSP_KILL_GRACE) and escalateProcessTree (deleted with its export and unit test): the seam's escalation already commits to SIGKILL after killGraceMs, so only the unbounded quiescence awaits stay load-bearing. Tests: kill()-shaped spawn specs become terminate()-shaped or fold into the terminate() suites (group-wide delivery; the settled no-op case was already pinned by 'terminate() after the tree died'); tree-survivor coverage is intact. A stderr-'inherit' disposition test completes the stdout/stderr symmetry so the scoped subprocess+lsp coverage gate stands alone instead of leaning on subagent-acp's cross-package runs. Docs: SubprocessHandle type-equiv block, seam/impl/group READMEs, and the consumer-migration Agent Note lose the kill(signal?) vocabulary (zh pairs re-recorded); cordis api/services catalogs regenerated. --- ...26-subprocess-consumer-migration.i18n.yaml | 4 +- ...026-07-26-subprocess-consumer-migration.md | 4 +- ...-07-26-subprocess-consumer-migration.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- .../core-data-structures/subprocess.i18n.yaml | 4 +- docs/core-data-structures/subprocess.md | 14 ++---- docs/core-data-structures/subprocess.zh.md | 14 ++---- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/lsp/lsp-local/src/connection.ts | 24 ++++------ packages/lsp/lsp-local/src/index.ts | 2 - packages/lsp/lsp-local/src/instance.ts | 26 +++------- .../lsp/lsp-local/tests/connection.spec.ts | 12 ++--- packages/lsp/lsp-local/tests/instance.spec.ts | 13 +---- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/spawn.ts | 24 +++++----- .../subprocess-local/tests/spawn.spec.ts | 47 +++++++------------ .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 8 ++-- packages/subprocess/subprocess/src/types.ts | 12 ++--- .../subprocess/tests/service.spec.ts | 2 - 27 files changed, 87 insertions(+), 155 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 4326462cbf..7bfb3b6a74 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.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 -2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 41bdf04bc03517cf9fe10a61a565e442ecee5520 +2026-07-26-subprocess-consumer-migration.md: 9353e515e4d700e59bc771b5e38648594e58466e +2026-07-26-subprocess-consumer-migration.zh.md: fa7d8dfdbda6ddbf30e4799d130a71e1a476ab42 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md index 805f27ba2e..9353e515e4 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -14,7 +14,7 @@ The seam's vocabulary is now Node-shaped, and every spawner that can ride the se - **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch). - **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome. -- **Tree-scoped termination, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. +- **Tree-scoped termination behind one verb**: `terminate()` owns the SIGTERM→grace→SIGKILL escalation (serves the spec's abort signal too, and is a no-op once the tree is gone) — the handle exposes no single-signal `kill(signal?)`, so a consumer cannot skip the grace window; `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. - **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well. Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is `handle.dispose` with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). @@ -35,4 +35,4 @@ Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` Bought: one implementation of tree signalling, escalation, the dispose ladder, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. -Cost: the seam is wider — three stdio modes and four termination verbs instead of one of each — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. +Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 41bdf04bc0..fa7d8dfdbd 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -14,7 +14,7 @@ Status: implemented - **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 -- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 +- **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 - **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 @@ -35,4 +35,4 @@ Status: implemented 换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 -代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0b43c245a..4bba20e17c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1567,7 +1567,7 @@ Implementations must honor these semantics: - spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.kill signals without escalation, SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and SubprocessHandle.dispose runs the cooperative EOF-first ladder — all tree-scoped on every platform. +- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — and SubprocessHandle.dispose runs the cooperative EOF-first ladder; both tree-scoped on every platform. - Disposal of the service terminates all still-running managed processes and awaits their exit. ```ts cordis-catalog diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 0527cf34a3..55304600c3 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.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 -subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 -subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 +subprocess.md: b6c316b079177f052302c0b456d300bc54d9ccb6 +subprocess.zh.md: 421ab335b02dfbd20eabd121b96e290eb12d172c diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index cdd4507c7d..b6c316b079 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and tree-scoped termination -A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. ```ts type-equiv /** @@ -157,17 +157,11 @@ interface SubprocessHandle { readonly collected: SubprocessCollectedOutputs /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** - * Send one signal to the process tree, Node-style — no escalation, no - * timers. A no-op after the outcome has settled (the pid may be reused). - * @param signal - the signal to deliver (default `SIGTERM`; Windows - * force-terminates the tree for any value). - */ - kill(signal?: NodeJS.Signals): void /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately). Idempotent; also triggered by the - * spec's abort signal. + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. */ terminate(): void /** diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 78325c3255..421ab335b0 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与以进程树为范围的终止 -spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 ```ts type-equiv /** @@ -157,17 +157,11 @@ interface SubprocessHandle { readonly collected: SubprocessCollectedOutputs /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** - * Send one signal to the process tree, Node-style — no escalation, no - * timers. A no-op after the outcome has settled (the pid may be reused). - * @param signal - the signal to deliver (default `SIGTERM`; Windows - * force-terminates the tree for any value). - */ - kill(signal?: NodeJS.Signals): void /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately). Idempotent; also triggered by the - * spec's abort signal. + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. */ terminate(): void /** diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index cf879df592..d78f77f796 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2227,7 +2227,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', }, { name: 'SubprocessOutcome', diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 74f68288e6..62101a403b 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -30,11 +30,11 @@ export interface ConnectionSpec { /** Largest stderr tail retained for diagnostics. */ readonly maxStderrBytes: number /** - * Bound (ms) for draining pipes a surviving helper still holds after the - * server exits; the instance passes its kill grace so exit observation is - * never slower than the escalation it feeds. + * The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of + * {@link LspConnection.terminate}'s escalation, and the bound for draining + * pipes a surviving helper still holds after the server exits. */ - readonly pipeDrainGraceMs: number + readonly killGraceMs: number /** Static answer to every `workspace/configuration` item. */ readonly configuration: unknown } @@ -98,7 +98,7 @@ export class LspConnection { stdout: 'pipe', stderr: { maxBytes: spec.maxStderrBytes }, }, - graceMs: spec.pipeDrainGraceMs, + graceMs: spec.killGraceMs, // spec.env mixes the scrubbed base with explicit config entries; a // configured DSH_* fact takes the managed channel the seam reserves. ...splitEnvChannels(spec.env), @@ -210,14 +210,9 @@ export class LspConnection { return this.nextId } - /** Request termination of the server's process tree (SIGTERM, no escalation). */ + /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */ terminate(): void { - this.handle.kill('SIGTERM') - } - - /** Force termination of the server's process tree. */ - kill(): void { - this.handle.kill('SIGKILL') + this.handle.terminate() } /** @@ -235,9 +230,10 @@ export class LspConnection { messages = this.decoder.push(chunk) } catch (error) { // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and - // SIGKILL the whole group so helper processes don't outlive the leader. + // terminate the whole group so helper processes don't outlive the leader (SIGTERM first, then + // the kill grace's SIGKILL — a misbehaving server still gets its bounded flush window). this.fail(asError(error)) - this.handle.kill('SIGKILL') + this.handle.terminate() return } for (const message of messages) this.dispatch(message) diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index b9699706aa..d926ae9428 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -285,8 +285,6 @@ class LocalLspProvider implements LspProvider { initializationOptions: this.config.initializationOptions, maxMessageBytes: this.config.maxMessageBytes, maxStderrBytes: this.config.maxStderrBytes, - // Exit observation must never be slower than the escalation it feeds. - pipeDrainGraceMs: this.config.killGraceMs, shutdownTimeoutMs: this.config.shutdownTimeoutMs, killGraceMs: this.config.killGraceMs, } diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 0718bb6b82..c1f78eaa38 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -35,17 +35,6 @@ export interface InstanceSpec extends ConnectionSpec { readonly initializationOptions: unknown /** Graceful `shutdown`/`exit` budget before escalation (ms). */ readonly shutdownTimeoutMs: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ - readonly killGraceMs: number -} - -/** - * Force-kill a process tree only when graceful termination did not make it exit. - * @param treeExited - whether the tree exited within its grace period. - * @param forceKill - forceful process-tree termination primitive. - */ -export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void { - if (!treeExited) forceKill() } /** @@ -311,17 +300,14 @@ export class LspInstance { await abortable(this.connection.closed, signal) } - /** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */ + /** + * Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL), + * then await leader and helper exit. The awaits are unbounded on purpose: + * the seam's escalation already committed to SIGKILL, so quiescence — not + * another timer — is the postcondition disposal owes its callers. + */ private async forceTerminate(): Promise { this.connection.terminate() - const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') - let treeExited: boolean - try { - treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal) - } finally { - graceDeadline[Symbol.dispose]() - } - escalateProcessTree(treeExited, this.connection.kill.bind(this.connection)) await Promise.all([ this.connection.closed, this.connection.waitForProcessTreeExit(), diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index c717e3c886..c07fa77909 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -14,7 +14,7 @@ let open: LspConnection[] = [] afterEach(async () => { for (const conn of open) { - conn.kill() + conn.terminate() await conn.closed } open = [] @@ -33,7 +33,7 @@ function connect( env: { ...scrubbedParentEnv(), ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - pipeDrainGraceMs: 3_000, + killGraceMs: 3_000, configuration: { setting: 42 }, }, spawnSubprocess, (method, params) => { seen?.push({ method, params }) @@ -66,10 +66,10 @@ describe('LspConnection', () => { await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) }) - it('treats signaling an already-closed child as a teardown race', async () => { + it('treats terminating an already-closed child as a teardown race', async () => { const conn = connectScript('') await conn.closed - expect(() => { conn.kill() }).not.toThrow() + expect(() => { conn.terminate() }).not.toThrow() }) it('answers a server workspace/configuration request from static config', async () => { @@ -152,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec env: scrubbedParentEnv(), maxMessageBytes: 16_000_000, maxStderrBytes, - pipeDrainGraceMs: 3_000, + killGraceMs: 3_000, configuration: null, }, spawnSubprocess, () => Promise.resolve(null), writer) open.push(conn) @@ -168,7 +168,7 @@ describe('LspConnection edge behavior', () => { env: {}, maxMessageBytes: 1000, maxStderrBytes: 1000, - pipeDrainGraceMs: 3_000, + killGraceMs: 3_000, configuration: null, }, spawnSubprocess, () => Promise.resolve(null)) open.push(conn) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 7d0ca74d0c..75b9e541fc 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,7 +6,6 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' -import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' @@ -45,7 +44,6 @@ function makeInstance( initializationOptions: { init: true }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - pipeDrainGraceMs: 200, shutdownTimeoutMs: 200, killGraceMs: 200, ...overrides, @@ -75,7 +73,6 @@ function scriptInstance(script: string, overrides: Partial = {}): initializationOptions: null, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - pipeDrainGraceMs: 150, shutdownTimeoutMs: 150, killGraceMs: 150, ...overrides, @@ -258,14 +255,6 @@ describe('LspInstance query and abort', () => { }) describe('LspInstance disposal', () => { - it('escalates only when the process tree survives its grace period', () => { - const forceKill = vi.fn() - escalateProcessTree(false, forceKill) - expect(forceKill).toHaveBeenCalledOnce() - escalateProcessTree(true, forceKill) - expect(forceKill).toHaveBeenCalledOnce() - }) - it('lets a server finish protocol exit before signal escalation', async () => { const marker = join(root, 'graceful-exit.log') const instance = makeInstance({ diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 5224774c27..f536302eea 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.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 -README.md: 657855aff67230ee22b8137ae3aabc76aff8f860 -README.zh.md: 5281a0d6eddb38974d1225220bab08880224f14b +README.md: 64e4740c7ac2706e45bb3517891504bf31a6109b +README.zh.md: e30b6c7f51ed0dffba15a6d1dff632e4ff4c6402 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 657855aff6..64e4740c7a 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -6,7 +6,7 @@ The shared home for spawning managed child-process trees: fully-specified spawn | Package | ctx key | Role | |---|---|---| -| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, kill/terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | +| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 5281a0d6ed..e30b6c7f51 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -6,7 +6,7 @@ spawn 受管子进程树的共用归属位置:完全显式的 spawn spec,其 | 包(package) | ctx 键 | 角色 | |---|---|---| -| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、kill/terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose | 服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2e8c1fdae4..d7ad897392 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 -README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 -README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 +README.md: b16d5e9a7eabb39db549b9fd6452e8fecee73022 +README.zh.md: 81b3f9ec8341a73732e2cf342bb0f9fe5c290357 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 08cc2ce7d9..b16d5e9a7e 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README ## Behavior (and where it came from) -- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index da230ba37d..81b3f9ec83 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为(以及设计来源) -- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止动词)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束),进程树消亡后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 - **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8716f50b0c..1e069bbb90 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -392,11 +392,12 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } } - const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => { - // Guard on TREE liveness, not outcome settlement: a TERM-trapping helper - // can outlive the settled direct child and must stay signalable, while a - // fully-dead tree (possible pid reuse) must not be re-signalled from a - // caller's finally block. + // The dispose ladder's tier primitive (not on the handle — terminate() is + // the only consumer-facing termination verb). Guards on TREE liveness, not + // outcome settlement: a TERM-trapping helper can outlive the settled direct + // child and must stay signalable, while a fully-dead tree (possible pid + // reuse) must not be re-signalled by a later tier. + const kill = (sig: NodeJS.Signals): void => { if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } @@ -404,15 +405,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const terminate = (): void => { if (graceTimer !== undefined) return // escalation already in flight if (!treeAlive()) return - signalTree(platform, pid, 'SIGTERM', child, taskkill) + kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying // does not mean the tree died — so settle does not clear this timer, and - // it re-probes tree liveness before force-killing. It stays ref'd: the - // pending SIGKILL is a commitment, and a parent exiting before it fires - // would orphan a trapped survivor. Self-bounds at graceMs. - graceTimer = setTimeout(() => { - if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill) - }, spec.graceMs) + // kill() re-probes tree liveness before force-killing. It stays ref'd: + // the pending SIGKILL is a commitment, and a parent exiting before it + // fires would orphan a trapped survivor. Self-bounds at graceMs. + graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } // The caller owns timeout classification; this layer only reacts to abort. @@ -514,7 +513,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter ...stderrCollector !== undefined ? { stderr: stderrCollector } : {}, }, done, - kill, terminate, waitForExit, dispose, diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4b3e16fd5a..96d9fed7ac 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -165,26 +165,15 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe('SIGKILL') }) - it('kill() sends one signal Node-style, without escalation', async () => { - const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60', { graceMs: 100 })) - await waitForStdout(running, 'armed\n') - running.kill() // trapped SIGTERM, no SIGKILL follow-up - await new Promise(resolve => setTimeout(resolve, 400)) - expect(running.collected.stdout).toBeDefined() - running.kill('SIGKILL') // explicit signal choice, still no timers - const result = await running.done - expect(result.signal).toBe('SIGKILL') - }) - - it('kills the whole process group (grandchildren die too)', async () => { - // The subshell writes the sleep's pid then waits on it; killing the + it('terminates the whole process group (grandchildren die too)', async () => { + // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) expect(grandchild).toBeGreaterThan(0) - running.kill() + running.terminate() const result = await running.done expect(result.signal).toBe('SIGTERM') await waitGone(grandchild) @@ -452,22 +441,6 @@ describe('killGroup', () => { expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) - it('handle.kill() after the tree died delivers no termination signal', async () => { - // Cleanup code commonly kills handles in a finally; once the tree is gone - // the pid may be reused, so a late kill must deliver nothing (the - // liveness PROBE — signal 0 — is the only process.kill allowed). - const running = spawnSubprocess(spec('true')) - await running.done - await running.waitForExit() - const spy = vi.spyOn(process, 'kill') - try { - running.kill() - const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) - expect(delivered).toEqual([]) - } finally { - spy.mockRestore() - } - }) }) describe('stdio dispositions', () => { @@ -541,7 +514,7 @@ describe('dispose ladder', () => { }) describe('windows tree semantics (injected platform)', () => { - it('kill and terminate route through taskkill by root pid', async () => { + it('terminate routes through taskkill by root pid', async () => { const killed: number[] = [] const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { spillDir, @@ -672,6 +645,18 @@ describe('coverage seams', () => { expect(running.collected.stderr!.readFrom(0).text).toBe('err\n') }) + it("an 'inherit' stderr with collected stdout wires only the requested collector", async () => { + const running = spawnSubprocess({ + ...spec('echo out; echo to-parent >&2'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'inherit' }, + }) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(running.stderr).toBeUndefined() + expect(running.collected.stderr).toBeUndefined() + expect(running.collected.stdout!.readFrom(0).text).toBe('out\n') + }) + it('terminate() after the tree died delivers no termination signal', async () => { const running = spawnSubprocess(spec('true')) await running.done diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 31f0fc4e84..26a157c442 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 -README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 -README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 +README.md: d760be118e5eaf41d038a5854a8e129cdc41c349 +README.zh.md: a174724e51ce537b3b7121bf6e1fa442d182fe1d diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 2cb7a5ebce..d760be118e 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -9,7 +9,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). +- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index a3211834e0..a174724e51 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -9,7 +9,7 @@ - `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index e4cfaae901..d69b08c05f 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -102,10 +102,10 @@ declare module 'cordis' { * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped * streams are handed to the caller raw and never buffered here. - * - {@link SubprocessHandle.kill} signals without escalation, - * {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates - * SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the - * cooperative EOF-first ladder — all tree-scoped on every platform. + * - {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates + * SIGTERM→grace→SIGKILL — the only termination verb — and + * {@link SubprocessHandle.dispose} runs the cooperative EOF-first ladder; + * both tree-scoped on every platform. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. */ diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 4c667919c7..1c831c2805 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -206,17 +206,11 @@ export interface SubprocessHandle { readonly collected: SubprocessCollectedOutputs /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** - * Send one signal to the process tree, Node-style — no escalation, no - * timers. A no-op after the outcome has settled (the pid may be reused). - * @param signal - the signal to deliver (default `SIGTERM`; Windows - * force-terminates the tree for any value). - */ - kill(signal?: NodeJS.Signals): void /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately). Idempotent; also triggered by the - * spec's abort signal. + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. */ terminate(): void /** diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 9fb68f403f..ec8718dfa9 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -21,7 +21,6 @@ class StubSubprocessService extends SubprocessService { stderr: undefined, collected, done: Promise.resolve({ exitCode: 0, signal: null }), - kill: () => {}, terminate: () => {}, waitForExit: () => Promise.resolve(true), dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(), @@ -41,7 +40,6 @@ describe('SubprocessService seam', () => { }) expect(handle.pid).toBe(1) expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) - handle.kill() handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined()