ソースを参照

refactor(subprocess): keep terminate() as the seam's only termination verb

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.
Tianyi Cui 1 ヶ月 前
コミット
f81fcccd93
27 ファイル変更87 行追加155 行削除
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md
  4. 1 1
      docs/cordis-catalog/services.md
  5. 2 2
      docs/core-data-structures/subprocess.i18n.yaml
  6. 4 10
      docs/core-data-structures/subprocess.md
  7. 4 10
      docs/core-data-structures/subprocess.zh.md
  8. 1 1
      packages/cordis/tool-cordis/src/api-catalog.ts
  9. 10 14
      packages/lsp/lsp-local/src/connection.ts
  10. 0 2
      packages/lsp/lsp-local/src/index.ts
  11. 6 20
      packages/lsp/lsp-local/src/instance.ts
  12. 6 6
      packages/lsp/lsp-local/tests/connection.spec.ts
  13. 1 12
      packages/lsp/lsp-local/tests/instance.spec.ts
  14. 2 2
      packages/subprocess/README.i18n.yaml
  15. 1 1
      packages/subprocess/README.md
  16. 1 1
      packages/subprocess/README.zh.md
  17. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  18. 1 1
      packages/subprocess/subprocess-local/README.md
  19. 1 1
      packages/subprocess/subprocess-local/README.zh.md
  20. 11 13
      packages/subprocess/subprocess-local/src/spawn.ts
  21. 16 31
      packages/subprocess/subprocess-local/tests/spawn.spec.ts
  22. 2 2
      packages/subprocess/subprocess/README.i18n.yaml
  23. 1 1
      packages/subprocess/subprocess/README.md
  24. 1 1
      packages/subprocess/subprocess/README.zh.md
  25. 4 4
      packages/subprocess/subprocess/src/index.ts
  26. 3 9
      packages/subprocess/subprocess/src/types.ts
  27. 0 2
      packages/subprocess/subprocess/tests/service.spec.ts

+ 2 - 2
.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

+ 2 - 2
.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.

+ 2 - 2
.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 因所有权归属留在该服务之外,以凭据清除作为共享底线。

+ 1 - 1
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

+ 2 - 2
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

+ 4 - 10
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<SubprocessOutcome>
-  /**
-   * 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
   /**

+ 4 - 10
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<SubprocessOutcome>
-  /**
-   * 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
   /**

+ 1 - 1
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<SubprocessOutcome>;\n    kill(signal?: NodeJS.Signals): void;\n    terminate(): void;\n    waitForExit(signal?: AbortSignal): Promise<boolean>;\n    dispose(graces: SubprocessDisposeGraces): Promise<void>;\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<SubprocessOutcome>;\n    terminate(): void;\n    waitForExit(signal?: AbortSignal): Promise<boolean>;\n    dispose(graces: SubprocessDisposeGraces): Promise<void>;\n}',
   },
   {
     name: 'SubprocessOutcome',

+ 10 - 14
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)

+ 0 - 2
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,
     }

+ 6 - 20
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<void> {
     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(),

+ 6 - 6
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)

+ 1 - 12
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<InstanceSpec> = {}):
     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({

+ 2 - 2
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

+ 1 - 1
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.

+ 1 - 1
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 运行器)以及塑造它的每一项默认值。

+ 2 - 2
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

+ 1 - 1
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 <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 <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.

+ 1 - 1
packages/subprocess/subprocess-local/README.zh.md

@@ -6,7 +6,7 @@
 
 ## 行为(以及设计来源)
 
-- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID <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 <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 的后台读取路径)与完整流重读可以共存,结算前后皆然。

+ 11 - 13
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,

+ 16 - 31
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

+ 2 - 2
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

+ 1 - 1
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.
 

+ 1 - 1
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(资源释放)会终止所有仍在运行的受管进程并等待其退出。
 

+ 4 - 4
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.
  */

+ 3 - 9
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<SubprocessOutcome>
-  /**
-   * 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
   /**

+ 0 - 2
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()