瀏覽代碼

fix(subprocess): close remaining native containment findings

pku-xht 2 周之前
父節點
當前提交
ac1a5891c2
共有 72 個文件被更改,包括 573 次插入329 次删除
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  2. 10 8
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  3. 10 8
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  4. 2 2
      docs/config-catalog.i18n.yaml
  5. 2 2
      docs/config-catalog.md
  6. 2 2
      docs/config-catalog.zh.md
  7. 2 2
      docs/subsystems/shell.i18n.yaml
  8. 3 3
      docs/subsystems/shell.md
  9. 3 3
      docs/subsystems/shell.zh.md
  10. 2 2
      docs/subsystems/subprocess.i18n.yaml
  11. 2 1
      docs/subsystems/subprocess.md
  12. 2 1
      docs/subsystems/subprocess.zh.md
  13. 2 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  14. 2 2
      packages/lsp/lsp-stdio/README.i18n.yaml
  15. 1 1
      packages/lsp/lsp-stdio/README.md
  16. 1 1
      packages/lsp/lsp-stdio/README.zh.md
  17. 8 9
      packages/lsp/lsp-stdio/src/connection.ts
  18. 5 6
      packages/lsp/lsp-stdio/src/instance.ts
  19. 1 1
      packages/lsp/lsp-stdio/tests/instance.spec.ts
  20. 2 2
      packages/shell/bash-local/README.i18n.yaml
  21. 3 3
      packages/shell/bash-local/README.md
  22. 3 3
      packages/shell/bash-local/README.zh.md
  23. 5 5
      packages/shell/bash-local/src/index.ts
  24. 1 1
      packages/shell/bash-local/tests/executor.spec.ts
  25. 2 2
      packages/shell/pwsh-local/README.i18n.yaml
  26. 2 2
      packages/shell/pwsh-local/README.md
  27. 2 2
      packages/shell/pwsh-local/README.zh.md
  28. 1 1
      packages/shell/pwsh-local/src/index.ts
  29. 1 1
      packages/shell/pwsh-local/tests/executor.spec.ts
  30. 2 2
      packages/shell/shell/README.i18n.yaml
  31. 1 1
      packages/shell/shell/README.md
  32. 1 1
      packages/shell/shell/README.zh.md
  33. 1 1
      packages/shell/shell/src/types.ts
  34. 2 2
      packages/subagent/subagent-acp/README.i18n.yaml
  35. 2 2
      packages/subagent/subagent-acp/README.md
  36. 2 2
      packages/subagent/subagent-acp/README.zh.md
  37. 10 10
      packages/subagent/subagent-acp/src/run.ts
  38. 2 2
      packages/subagent/subagent-claude-code/README.i18n.yaml
  39. 2 2
      packages/subagent/subagent-claude-code/README.md
  40. 2 2
      packages/subagent/subagent-claude-code/README.zh.md
  41. 1 1
      packages/subagent/subagent-claude-code/src/index.ts
  42. 1 1
      packages/subagent/subagent-claude-code/src/invariant.ts
  43. 4 4
      packages/subagent/subagent-claude-code/src/process.ts
  44. 5 5
      packages/subagent/subagent-claude-code/src/run.ts
  45. 3 3
      packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts
  46. 2 2
      packages/subagent/subagent-codex/README.i18n.yaml
  47. 1 1
      packages/subagent/subagent-codex/README.md
  48. 1 1
      packages/subagent/subagent-codex/README.zh.md
  49. 1 1
      packages/subagent/subagent-codex/src/index.ts
  50. 1 1
      packages/subagent/subagent-codex/src/invariant.ts
  51. 6 6
      packages/subagent/subagent-codex/src/run.ts
  52. 3 3
      packages/subagent/subagent-codex/tests/subagent-codex.spec.ts
  53. 1 1
      packages/subagent/subagent/src/out-of-process.ts
  54. 17 9
      packages/subprocess/subprocess-local/src/index.ts
  55. 106 22
      packages/subprocess/subprocess-local/src/linux-scope.ts
  56. 6 23
      packages/subprocess/subprocess-local/src/runner-protocol.ts
  57. 22 29
      packages/subprocess/subprocess-local/src/spawn-runner.ts
  58. 3 1
      packages/subprocess/subprocess-local/src/spawn.ts
  59. 26 18
      packages/subprocess/subprocess-local/src/windows-job.ts
  60. 105 8
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  61. 32 4
      packages/subprocess/subprocess-local/tests/local.spec.ts
  62. 1 5
      packages/subprocess/subprocess-local/tests/native-containment.spec.ts
  63. 1 3
      packages/subprocess/subprocess-local/tests/native-windows.spec.ts
  64. 6 2
      packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts
  65. 40 31
      packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts
  66. 10 19
      packages/subprocess/subprocess-local/tests/spawn.spec.ts
  67. 41 14
      packages/subprocess/subprocess-local/tests/windows-job.spec.ts
  68. 2 1
      packages/subprocess/subprocess/src/index.ts
  69. 1 1
      packages/subprocess/subprocess/src/types.ts
  70. 9 1
      packages/subprocess/win32-process/src/process.ts
  71. 3 1
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts
  72. 1 1
      snapshots/session/cordis-inspect-jsdoc/session.jsonl

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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 .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 378a109b7d9b16593eda4ae06ab45cb27a6319c0
-2026-08-28-subprocess-native-containment.zh.md: d0bdf71eb8602896adab853bd7d3c2a969caad21
+2026-08-28-subprocess-native-containment.md: 84ddc8dd533123a90555b813f7a2b5d95b3a00a1
+2026-08-28-subprocess-native-containment.zh.md: fd7bbd362b7d4ef1cefb0c379728dcfda8366a67

+ 10 - 8
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md

@@ -18,17 +18,17 @@ An ordinary `SubprocessHandle` has no PID or public startup state. `.done` repor
 
 ### Linux scope and one-shot bootstrap
 
-Every eligible Linux ordinary or PTY spawn rechecks the exact runner entry, the libc `execve` and `fcntl` bindings, the readable user manager, and literal-argv transient-scope support. A positive result is not cached. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback.
+The first eligible Linux ordinary or PTY call in one runtime deeply checks the exact runner entry, the libc `execve` and `fcntl` bindings, the readable user manager, and literal-argv transient-scope support. Failed deep probes are retried, while the first success is cached. Each later eligible call still performs a lightweight user-manager reachability probe before target execution. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback.
 
 The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor.
 
-Request consumption or a manager observation of the unit establishes scope ownership. Unit absence before either fact remains unresolved; direct-child exit with an unconsumed request is establishment failure. The parent checks this unresolved interval every 50 milliseconds; after establishment, active-state queries back off exponentially to the existing 5-second systemctl bound. An inactive, failed, or collected-away unit proves the range empty. Unknown states and unreadable manager results reject `waitForExit()` instead of claiming quiescence. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
+Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with the startup failure while range observation records that the scope never existed and resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
 
 The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants.
 
 ### Windows runner and Job
 
-The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer maps fds 4 through 6 to OS handles through Node's exported `uv_get_osfhandle()`, rejects invalid results, temporarily enables inheritance on those handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC.
+The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and waits for Node's runner `spawn` event before sending exactly one start request. A pre-spawn runner error preserves that direct launch failure while proving that no Job range existed, so the empty-range wait succeeds; post-spawn infrastructure failure remains uncertain and rejects range settlement. Node IPC otherwise carries idempotent terminate control and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer maps fds 4 through 6 to OS handles through Node's exported `uv_get_osfhandle()`, rejects null plus the unsigned `UV_INVALID_OS_FILE_HANDLE` and `UV_INVALID_FILE_DESCRIPTOR` sentinels exposed by Koffi, temporarily enables inheritance on valid handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC.
 
 The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`.
 
@@ -38,7 +38,7 @@ The parent permanently latches a validated numeric `target-exit` as soon as it a
 
 Source launches execute the package runner entry through the TypeScript source launcher, built launches resolve the `@deepseek-ai/dsh-subprocess-local/runner` export, and the Python SDK single-file executable enters through `@deepseek-ai/dsh`'s packaging-owned `runtime-bootstrap.js`. That bootstrap imports the public CLI when the private selector is absent; otherwise it removes the selector and dispatches to the same subprocess runner core. The public `dsh` argument parser has no hidden runner mode, and packaging ships no second Node executable.
 
-The selector is a per-spawn locator or sentinel, not a credential or persistent format. Linux uses one strict request plus one optional strict startup-error file. Windows uses one IPC channel with closed `start` and `terminate` requests and exactly three result branches: `target-exit` with a numeric `exitCode`, `error` with bounded Node-shaped fields, and payload-free `start-cancelled`; the parent derives `signal: null`. A cancellation reason never crosses the wire, so the parent preserves the first local reason exactly, including `null` or `undefined`. Missing, extra, mistyped, or unknown fields fail closed. Target environments may contain the selector name, including Windows case variants, because the provider transmits target state separately and restores it only after private selection is consumed.
+The selector is a per-spawn locator or sentinel, not a credential or persistent format. Linux uses one strict request plus one optional strict startup-error file. Windows uses one IPC channel with closed `start` and `terminate` requests and exactly two result branches: `target-exit` with a numeric `exitCode`, and `error` with required `name` and `message` plus only optional `code`, `syscall`, and `path`; the parent derives `signal: null`. Pre-commit cancellation also uses `error` with the private `DSH_SUBPROCESS_START_CANCELLED` code. The cancellation reason never crosses the wire, so the parent maps that code back to the first local reason exactly, including `null` or `undefined`. Missing, extra, mistyped, or unknown fields fail closed. Target environments may contain the selector name, including Windows case variants, because the provider transmits target state separately and restores it only after private selection is consumed.
 
 ### Fallback and cleanup
 
@@ -54,8 +54,8 @@ This note owns the current native-containment mechanism. It partially updates th
 
 ## Verification
 
-- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, the three scope-establishment states, prompt pre-establishment polling with bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
-- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and invalid-result rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
+- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including an exited launcher with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
+- Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, private coded start cancellation with raw local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, start delivery after runner spawn, empty-range settlement after pre-spawn failure, `EPERM`/`-4048` access-denied mapping, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
 - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics.
 - Native Windows tests prove suspended creation, Job assignment before resume, inherited stdio, default descendant inheritance, direct result, termination, active-process zero, abnormal/disconnected runner cleanup, kill-on-close, and synchronous host-exit termination. Source, built, and Python packaged smokes enter the same runner core.
 - Public seam types, local and E2B providers, LSP and subagent consumers, shell fixtures, READMEs, the Cordis catalog, and the keyless subprocess API snapshot contain no ordinary PID; terminal PID remains.
@@ -72,10 +72,12 @@ This note owns the current native-containment mechanism. It partially updates th
 
 **Parse a hidden runner argument in the public CLI or ship another Node executable.** Rejected because either choice expands the public application grammar or distribution surface. A packaging-only bootstrap keeps one physical executable and two private logical entries.
 
-**Cache successful native probes or recover a failed native launch by replaying the command.** Rejected because user-manager, entry, and Job availability can change between spawns, while replay can execute a command twice after an ambiguous failure.
+**Cache the complete Linux native decision without another reachability check.** Rejected because user-manager availability can change between spawns. The runtime caches only the first successful deep bootstrap/scope probe and lightly rechecks manager reachability for every later eligible call.
+
+**Recover a failed native launch by replaying the command.** Rejected because an ambiguous failure may occur after target execution and replay can therefore execute the command twice.
 
 ## Consequences
 
-Supported Linux ordinary and PTY launches and Windows ordinary launches retain descendants through process-group escape and direct-parent exit, while direct target results remain independent from range quiescence. The cost is a per-spawn Linux scope/request or Windows runner/IPC/Job lifecycle, plus explicit failure when the selected owner cannot prove settlement.
+Supported Linux ordinary and PTY launches and Windows ordinary launches retain descendants through process-group escape and direct-parent exit, while direct target results remain independent from range quiescence. The cost is a per-spawn Linux manager check and scope/request or Windows runner/IPC/Job lifecycle, plus explicit failure when the selected owner cannot prove settlement.
 
 Fallback hosts continue to run commands but carry a visible weaker guarantee. Windows ConPTY, macOS native containment, active breakaway descendants, old or absent user-systemd environments, target replay, persistent runner recovery, and termination paths where JavaScript cannot execute remain outside this decision.

+ 10 - 8
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md

@@ -18,17 +18,17 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只
 
 ### Linux scope 与 one-shot bootstrap
 
-每次符合条件的 Linux 普通或 PTY spawn 都会重新检查准确 runner 入口、libc `execve` 与 `fcntl` bindings、可读的 user manager 与保留 literal argv 的 transient-scope 支持。正向结果不缓存。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。
+同一 runtime 第一次符合条件的 Linux 普通或 PTY 调用会深入检查准确 runner 入口、libc `execve` 与 `fcntl` bindings、可读的 user manager 与保留 literal argv 的 transient-scope 支持。失败的深度 probe 会重试,第一次成功则会缓存。之后每次符合条件的调用仍会在 target 执行前轻量检查 user manager 是否可达。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。
 
 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。
 
-request 被消费或 manager 已观察到 unit 都能建立 scope ownership。在这两项事实出现前,unit absence 仍是未决状态;direct child 在 request 尚未消费时退出表示建立失败。parent 每 50 毫秒检查一次这段未决区间;建立后,active-state 查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。inactive、failed 或已经被 collect 卸载的 unit 可以证明 range 为空。未知状态与不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
+request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,而 range observation 会记录 scope 从未存在,并成功结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
 
 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。
 
 ### Windows runner 与 Job
 
-Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层通过 Node 导出的 `uv_get_osfhandle()` 把 fd 4 至 fd 6 映射为 OS handle,拒绝无效结果,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6;它绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。
+Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并等待 Node 的 runner `spawn` 事件后才发送恰好一条 start request。runner 在 spawn 前报错时,direct launch failure 会原样保留,同时证明 Job range 从未存在,因此 empty-range wait 成功;spawn 后的 infrastructure failure 仍是不确定状态,会使 range settlement reject。除此之外,Node IPC 还承载幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层通过 Node 导出的 `uv_get_osfhandle()` 把 fd 4 至 fd 6 映射为 OS handle,拒绝 null 以及 Koffi 暴露的 unsigned `UV_INVALID_OS_FILE_HANDLE` 与 `UV_INVALID_FILE_DESCRIPTOR` sentinel,临时启用有效 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6;它绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。
 
 runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。
 
@@ -38,7 +38,7 @@ parent 会在收到经过校验、只含数字的 `target-exit` 时立即永久
 
 source 启动通过 TypeScript source launcher 执行包内 runner 入口,built 启动解析 `@deepseek-ai/dsh-subprocess-local/runner` export,Python SDK 单文件可执行程序则从 `@deepseek-ai/dsh` 由打包层拥有的 `runtime-bootstrap.js` 进入。私有 selector 不存在时,该 bootstrap 导入公共 CLI;否则会删除 selector,并分派到同一 subprocess runner core。公共 `dsh` 参数解析器没有隐藏 runner mode,打包也不提供第二个 Node 可执行程序。
 
-selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linux 使用一个严格 request 与一个可选严格 startup-error 文件。Windows 使用一条 IPC channel,承载闭集的 `start` 与 `terminate` request,以及恰好三个 result 分支:只含数字 `exitCode` 的 `target-exit`、携带有界 Node-shaped 字段的 `error`,以及无载荷的 `start-cancelled`;parent 会派生 `signal: null`。取消 reason 不跨 wire 传递,因此 parent 会原样保留第一个本地 reason,包括 `null` 或 `undefined`。缺失、额外、类型错误或未知字段都会 fail closed。target 环境可以包含 selector 名称及其 Windows 大小写变体,因为 provider 会单独传递 target 状态,并且只在私有选择值消费后才恢复该状态。
+selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linux 使用一个严格 request 与一个可选严格 startup-error 文件。Windows 使用一条 IPC channel,承载闭集的 `start` 与 `terminate` request,以及恰好两个 result 分支:只含数字 `exitCode` 的 `target-exit`,以及必含 `name`、`message` 且只允许可选 `code`、`syscall`、`path` 的 `error`;parent 会派生 `signal: null`。提交前取消同样使用 `error`,并携带私有 `DSH_SUBPROCESS_START_CANCELLED` code。取消 reason 不跨 wire 传递,因此 parent 会把该 code 原样映射回第一个本地 reason,包括 `null` 或 `undefined`。缺失、额外、类型错误或未知字段都会 fail closed。target 环境可以包含 selector 名称及其 Windows 大小写变体,因为 provider 会单独传递 target 状态,并且只在私有选择值消费后才恢复该状态。
 
 ### Fallback 与 cleanup
 
@@ -54,8 +54,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 ## Verification
 
-- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态、建立前快速轮询与建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
-- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与无效结果拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
+- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 launcher 退出且 request 未消费)、`LoadState`/`ActiveState` 解析、`reloading`、terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
+- Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、带私有 code 的 start cancellation 与原样本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、access denied 到 `EPERM`/`-4048` 的映射、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。
 - native Windows 测试证明 suspended creation、resume 前 Job assignment、继承 stdio、默认后代继承、direct result、termination、active-process zero、异常/disconnected runner cleanup、kill-on-close 与同步 host-exit termination。source、built 与 Python packaged 冒烟测试进入同一 runner core。
 - 公共 seam 类型、local 与 E2B provider、LSP 与 subagent 消费方、shell fixture、README、Cordis catalog 与 keyless subprocess API snapshot 都不包含普通 PID;terminal PID 保留。
@@ -72,10 +72,12 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 **在公共 CLI 中解析隐藏 runner 参数,或发布另一个 Node 可执行程序。**不予采用,因为前者扩张公共应用语法,后者扩张分发面。packaging-only bootstrap 保留一个物理可执行程序与两个私有逻辑入口。
 
-**缓存成功的 native probe,或在 native launch 失败后重放命令。**不予采用,因为 user-manager、入口与 Job availability 可以在两次 spawn 之间变化,而一次含糊 failure 之后的 replay 可能执行命令两次。
+**缓存完整的 Linux native 选择结果且不再检查可达性。**不予采用,因为 user-manager availability 可以在两次 spawn 之间变化。runtime 只缓存第一次成功的 bootstrap/scope 深度 probe,并在之后每次符合条件的调用中轻量复查 manager reachability。
+
+**在 native launch 失败后重放命令。**不予采用,因为含糊 failure 可能发生在 target 已经执行之后,重放因此可能把命令执行两次。
 
 ## Consequences
 
-受支持的 Linux 普通与 PTY 启动、Windows 普通启动会在后代逃离进程组或 direct parent 退出后继续拥有它们,同时 direct target result 与 range 完全停稳保持独立。代价是每次 spawn 都需要一个 Linux scope/request 或 Windows runner/IPC/Job 生命周期,而且所选 owner 无法证明 settlement 时会显式失败。
+受支持的 Linux 普通与 PTY 启动、Windows 普通启动会在后代逃离进程组或 direct parent 退出后继续拥有它们,同时 direct target result 与 range 完全停稳保持独立。代价是每次 spawn 都需要一次 Linux manager 检查与 scope/request,或一套 Windows runner/IPC/Job 生命周期,而且所选 owner 无法证明 settlement 时会显式失败。
 
 fallback 宿主继续运行命令,但携带可见的较弱保证。Windows ConPTY、macOS native containment、active breakaway 后代、旧版或缺失的 user-systemd 环境、target replay、持久 runner recovery,以及 JavaScript 无法执行的终止路径均不属于本决策。

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 8669904b4470cd2b7db09922e475d3aecbbc1dfb
-config-catalog.zh.md: dd19ac6375034a74f01312c7237e7c8cfa0452e3
+config-catalog.md: 37076c866dfa9e2bcc1968a90fe63b7cac182154
+config-catalog.zh.md: 511a9d49c8b1315a40b6bb3fb8d2cba41a3c9c2c

+ 2 - 2
docs/config-catalog.md

@@ -2276,7 +2276,7 @@ export interface Config {
    * `bypassPermissions` explicitly skips permission checks.
    */
   permissionMode?: ClaudeCodePermissionMode
-  /** Grace in milliseconds for Claude Code process-tree termination. */
+  /** Grace in milliseconds between Claude Code managed-range termination tiers. */
   disposeGraceMs?: number
 }
 
@@ -2306,7 +2306,7 @@ export interface Config {
   env?: Record<string, string>
   /** Native non-interactive permission mode fixed for this Provider instance. */
   permissionMode?: CodexPermissionMode
-  /** Grace in milliseconds for app-server process-tree termination. */
+  /** Grace in milliseconds between app-server managed-range termination tiers. */
   disposeGraceMs?: number
 }
 

+ 2 - 2
docs/config-catalog.zh.md

@@ -2278,7 +2278,7 @@ export interface Config {
    * `bypassPermissions` explicitly skips permission checks.
    */
   permissionMode?: ClaudeCodePermissionMode
-  /** Grace in milliseconds for Claude Code process-tree termination. */
+  /** Grace in milliseconds between Claude Code managed-range termination tiers. */
   disposeGraceMs?: number
 }
 
@@ -2308,7 +2308,7 @@ export interface Config {
   env?: Record<string, string>
   /** Native non-interactive permission mode fixed for this Provider instance. */
   permissionMode?: CodexPermissionMode
-  /** Grace in milliseconds for app-server process-tree termination. */
+  /** Grace in milliseconds between app-server managed-range termination tiers. */
   disposeGraceMs?: number
 }
 

+ 2 - 2
docs/subsystems/shell.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/shell.md
-shell.md: ff83ae6d6e1b13e53e5f1112b2d713d3212a132e
-shell.zh.md: 60beba0ef37129a5bd3f86922f4beeef68e94010
+shell.md: 1bf0fc68461ba0ab3a5fb9712b348ccd8ba06de6
+shell.zh.md: dce096ac9b317d284fd6afb01ddd82dae6b7e213

+ 3 - 3
docs/subsystems/shell.md

@@ -2,7 +2,7 @@
 
 English | [中文](shell.zh.md)
 
-The bash execution seam is split across a Service Definition ([dsh-shell](../../packages/shell/shell), `ctx.shell`), Service Providers ([dsh-bash-local](../../packages/shell/bash-local) and [dsh-bash-sandbox](../../packages/shell/bash-sandbox)), and Consumer ([dsh-tool-bash](../../packages/shell/tool-bash), the `bash` schema). Generic background-job ids, ownership, and controls live in [jobs.md](jobs.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [subprocess seam](subprocess.md).
+The bash execution seam is split across a Service Definition ([dsh-shell](../../packages/shell/shell), `ctx.shell`), Service Providers ([dsh-bash-local](../../packages/shell/bash-local) and [dsh-bash-sandbox](../../packages/shell/bash-sandbox)), and Consumer ([dsh-tool-bash](../../packages/shell/tool-bash), the `bash` schema). Generic background-job ids, ownership, and controls live in [jobs.md](jobs.md); this seam returns a task-free process handle. Managed-range mechanics live behind the [subprocess seam](subprocess.md).
 
 Source: [`packages/shell/shell/src/types.ts`](../../packages/shell/shell/src/types.ts)
 
@@ -196,7 +196,7 @@ interface ShellProcess {
    */
   readOutput(): ShellProcessRead
   /**
-   * Kill the process group. Returns false when it had already finished
+   * Terminate the provider-managed range. Returns false when it had already finished
    * (no-op); idempotent.
    */
   kill(): boolean
@@ -221,7 +221,7 @@ interface ShellProcessRead {
 
 ## The service
 
-`ShellExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic job runtime](jobs.md). `dsh-shell` owns the shell tools' shared exit-status contract: the exported `parseExitStatus`/`ParsedExitStatus` inverts the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append, and both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill.
+`ShellExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; managed-range termination, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic job runtime](jobs.md). `dsh-shell` owns the shell tools' shared exit-status contract: the exported `parseExitStatus`/`ParsedExitStatus` inverts the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append, and both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 3 - 3
docs/subsystems/shell.zh.md

@@ -2,7 +2,7 @@
 
 [English](shell.md) | 中文
 
-bash 执行 seam 分为 Service Definition([dsh-shell](../../packages/shell/shell),`ctx.shell`)、Service Provider([dsh-bash-local](../../packages/shell/bash-local) 与 [dsh-bash-sandbox](../../packages/shell/bash-sandbox))和 Consumer([dsh-tool-bash](../../packages/shell/tool-bash),即 `bash` schema)。通用后台任务的 job id、所有权与控制位于 [jobs.md](jobs.zh.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制封装在[子进程 seam](subprocess.zh.md)之后。
+bash 执行 seam 分为 Service Definition([dsh-shell](../../packages/shell/shell),`ctx.shell`)、Service Provider([dsh-bash-local](../../packages/shell/bash-local) 与 [dsh-bash-sandbox](../../packages/shell/bash-sandbox))和 Consumer([dsh-tool-bash](../../packages/shell/tool-bash),即 `bash` schema)。通用后台任务的 job id、所有权与控制位于 [jobs.md](jobs.zh.md);本 seam 返回一个不含任务概念的进程句柄。managed-range 机制封装在[子进程 seam](subprocess.zh.md)之后。
 
 源码:[`packages/shell/shell/src/types.ts`](../../packages/shell/shell/src/types.ts)
 
@@ -196,7 +196,7 @@ interface ShellProcess {
    */
   readOutput(): ShellProcessRead
   /**
-   * Kill the process group. Returns false when it had already finished
+   * Terminate the provider-managed range. Returns false when it had already finished
    * (no-op); idempotent.
    */
   kill(): boolean
@@ -221,7 +221,7 @@ interface ShellProcessRead {
 
 ## 服务
 
-`ShellExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[子进程服务](subprocess.zh.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](jobs.zh.md)。`dsh-shell` 拥有 shell 工具共享的退出状态约定:导出的 `parseExitStatus`/`ParsedExitStatus` 是 `dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 所追加的 `[exit code: N]` / `[killed by signal: X]` 标记的逆解析,两个工具的 `presentResult` 都用它把渲染文本拆分为 terminal 卡的输出正文与退出状态 pill。
+`ShellExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;managed-range 终止、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[子进程服务](subprocess.zh.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](jobs.zh.md)。`dsh-shell` 拥有 shell 工具共享的退出状态约定:导出的 `parseExitStatus`/`ParsedExitStatus` 是 `dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 所追加的 `[exit code: N]` / `[killed by signal: X]` 标记的逆解析,两个工具的 `presentResult` 都用它把渲染文本拆分为 terminal 卡的输出正文与退出状态 pill。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 2 - 2
docs/subsystems/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 docs/subsystems/subprocess.md
-subprocess.md: 0e2adacbc5dfa4c4fa6ac922a47a81388fd75535
-subprocess.zh.md: 87e88344a7e027ae02557b0aa973f9f2ae7bacf0
+subprocess.md: 782623760e70648a108ac51252b610e27bffbe45
+subprocess.zh.md: 7b8e590a843899b5f00a309a913e1dcb152c5c1b

+ 2 - 1
docs/subsystems/subprocess.md

@@ -305,13 +305,14 @@ abstract resolveExecutable( command: string, env?: Readonly<Record<string, strin
  * applies no defaults.
  * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
  * @returns the live process handle (streams/readers, signalling, outcome promise).
+ * @throws synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.
  */
 abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
 
 /**
  * Allocate a real terminal and start one owned process session. This is the
  * only non-pipe process primitive: implementations own terminal byte I/O,
- * foreground groups, signals, and complete session-tree cleanup.
+ * foreground groups, signals, and whole-session quiescence.
  * @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.
  * @returns the live terminal handle after allocation succeeds.
  */

+ 2 - 1
docs/subsystems/subprocess.zh.md

@@ -305,13 +305,14 @@ abstract resolveExecutable( command: string, env?: Readonly<Record<string, strin
  * applies no defaults.
  * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
  * @returns the live process handle (streams/readers, signalling, outcome promise).
+ * @throws synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.
  */
 abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
 
 /**
  * Allocate a real terminal and start one owned process session. This is the
  * only non-pipe process primitive: implementations own terminal byte I/O,
- * foreground groups, signals, and complete session-tree cleanup.
+ * foreground groups, signals, and whole-session quiescence.
  * @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.
  * @returns the live terminal handle after allocation succeeds.
  */

+ 2 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2311,10 +2311,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         description: 'Start one managed child process from a fully-specified spec; this seam applies no defaults.',
         parameters: [{ name: 'spec', description: 'argv, directory, stdio dispositions, grace, cancellation, and environment.' }],
         returns: 'the live process handle (streams/readers, signalling, outcome promise).',
+        throws: ['synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.'],
       },
       {
         signature: 'abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>',
-        description: 'Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and complete session-tree cleanup.',
+        description: 'Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.',
         parameters: [{ name: 'spec', description: 'fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.' }],
         returns: 'the live terminal handle after allocation succeeds.',
       },

+ 2 - 2
packages/lsp/lsp-stdio/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 packages/lsp/lsp-stdio/README.md
-README.md: 7acb5a591f3cf50a02d01e96f9b4a26ff3bf693b
-README.zh.md: 27020c1babbcff8acde890db5df11eaede45301a
+README.md: 8ce70d3bc4703b2926a5d6a1c4efe43cfc71f62e
+README.zh.md: 88bfbb34d3b9d31686565dcfeda7b08d4544281d

+ 1 - 1
packages/lsp/lsp-stdio/README.md

@@ -91,7 +91,7 @@ This section explains the design decisions behind the provider and where the cod
 - **Read before spawn.** The source is resolved, contained, and byte-bounded inside the workspace queue before any process is created, so a queued query sees current bytes when its turn starts and an invalid source cannot leave an idle process pooled.
 - **One pooled process per canonical workspace.** Instances are single-flighted per `(server id, canonical workspace target)`; a transport failure retries the read-only query once on a fresh process after awaiting disposal.
 - **Per-workspace serialization.** One abortable queue per workspace serializes source-read/open/query/close lifecycles; distinct workspaces run in parallel, and a cancellation that fails to stop a server terminates only that instance.
-- **Bounded teardown.** Graceful `shutdown`/`exit` escalates through tree termination (process-group signaling on POSIX, `taskkill /T /F` on Windows); quiescence is confirmed by awaiting process-tree exit, not by the kill outcome.
+- **Bounded teardown.** Graceful `shutdown`/`exit` escalates through the subprocess provider's managed-range termination procedure; quiescence is confirmed by awaiting that whole range, not by the termination request's outcome.
 - **Execution-world pairing.** Servers launch through `ctx.subprocess` with `processId: null` (another machine or PID namespace must not monitor the harness), sources read through `ctx.fs`, and no `fs/observed` event is emitted — only the LSP result is model-visible.
 
 ### Source map

+ 1 - 1
packages/lsp/lsp-stdio/README.zh.md

@@ -91,7 +91,7 @@ kind: "package-reference"
 - **先读后启动。** 源文件在工作区队列内先完成解析、包含关系检查与字节限制,然后才创建任何进程,因此排队查询只会在轮到自身时读取当前字节,无效源文件也不会留下空闲的池化进程。
 - **每个规范工作区一个池化进程。** 实例按 `(server id, canonical workspace target)` 进行 single-flight;传输故障会在等待释放完成后于新进程上重试一次该只读查询。
 - **逐工作区串行化。** 每个工作区一条可中止队列,串行执行源读取/打开/查询/关闭生命周期;不同工作区并行运行,无法停止服务器的取消只会终止该实例。
-- **有边界的释放。** 优雅 `shutdown`/`exit` 升级为进程树终止(POSIX 进程组信号,Windows `taskkill /T /F`);是否完全停稳由等待进程树退出确认,而非由终止操作自身的结果确认。
+- **有边界的释放。** 优雅 `shutdown`/`exit` 会升级到 subprocess 提供方的 managed-range 终止流程;是否完全停稳由等待整个 range 确认,而非由终止请求自身的结果确认。
 - **执行世界配对。** 服务器通过 `ctx.subprocess` 启动,`processId: null`(另一台机器或 PID namespace 不得监视 harness);源文件通过 `ctx.fs` 读取;不发出 `fs/observed` 事件——只有 LSP 结果对模型可见。
 
 ### 源码地图

+ 8 - 9
packages/lsp/lsp-stdio/src/connection.ts

@@ -4,8 +4,8 @@
  * server→client requests: it answers `workspace/configuration` from static
  * config, and rejects `workspace/applyEdit` (this host never applies edits or
  * runs commands). It caps stderr, surfaces framing/decoder failures as a
- * fatal close, and exposes tree-scoped termination through the handle so the
- * instance owns teardown; group/tree mechanics live in the subprocess
+ * fatal close, and exposes managed-range termination through the handle so the
+ * instance owns teardown; platform mechanics live in the subprocess
  * Service Provider.
  * @module @deepseek-ai/dsh-lsp-stdio/connection
  */
@@ -88,7 +88,7 @@ export class LspConnection {
     this.decoder = new MessageDecoder(spec.maxMessageBytes)
     // stdin/stdout are piped protocol streams this endpoint frames itself;
     // stderr is a collected diagnostic tail (no spill — the bounded tail IS
-    // the contract). The seam owns detachment and tree-scoped signalling.
+    // the contract). The seam owns managed-range signalling and observation.
     this.handle = spawner({
       argv: [spec.command, ...spec.args],
       cwd: spec.cwd,
@@ -204,17 +204,17 @@ export class LspConnection {
     return this.nextId
   }
 
-  /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */
+  /** Terminate the server's provider-managed range (idempotent). */
   terminate(): void {
     this.handle.terminate()
   }
 
   /**
-   * Wait until the owned process tree has exited.
+   * Wait until the owned managed range is empty.
    * @param signal - optional bound for the wait.
-   * @returns `true` when the tree exited, or `false` when the signal aborted first.
+   * @returns `true` when the range is empty, or `false` when the signal aborted first.
    */
-  async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
+  async waitForManagedRangeExit(signal?: AbortSignal): Promise<boolean> {
     return await this.handle.waitForExit(signal)
   }
 
@@ -224,8 +224,7 @@ export class LspConnection {
       messages = this.decoder.push(chunk)
     } catch (error) {
       // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
-      // 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).
+      // terminate the managed range so helper processes do not outlive the leader.
       this.fail(asError(error))
       this.handle.terminate()
       return

+ 5 - 6
packages/lsp/lsp-stdio/src/instance.ts

@@ -292,7 +292,7 @@ export class LspInstance {
     try {
       await this.gracefulShutdown(shutdownDeadline.signal)
     } catch {
-      // Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative.
+      // Graceful shutdown failed or timed out; managed-range cleanup below remains authoritative.
     } finally {
       shutdownDeadline[Symbol.dispose]()
     }
@@ -307,16 +307,15 @@ export class LspInstance {
   }
 
   /**
-   * 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.
+   * Terminate the provider-managed range, then await the direct server result
+   * and whole-range quiescence. The awaits are unbounded on purpose because
+   * quiescence, not another timer, is the postcondition disposal owes callers.
    */
   private async forceTerminate(): Promise<void> {
     this.connection.terminate()
     await Promise.all([
       this.connection.closed,
-      this.connection.waitForProcessTreeExit(),
+      this.connection.waitForManagedRangeExit(),
     ])
   }
 }

+ 1 - 1
packages/lsp/lsp-stdio/tests/instance.spec.ts

@@ -311,7 +311,7 @@ describe('LspInstance disposal', () => {
     await expect(instance.dispose()).resolves.toBeUndefined()
   })
 
-  it('awaits a surviving process-tree helper on every concurrent dispose', async () => {
+  it('awaits a surviving managed-range helper on every concurrent dispose', async () => {
     const marker = join(root, 'helper.pid')
     const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
     const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'

+ 2 - 2
packages/shell/bash-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 packages/shell/bash-local/README.md
-README.md: 388374217ed14ab2c26c3f7d50ab03a5ca57b234
-README.zh.md: f0ef6b257943effaa265eaa81b682d2fd9ecd476
+README.md: 13dba5a7686512aa6f0646013b5d014e546d8006
+README.zh.md: 756cf1b67b435309e88e780431375991606cf25d

+ 3 - 3
packages/shell/bash-local/README.md

@@ -61,7 +61,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### Background processes
 
-Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` stops the process group; `done` settles when the process closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
+Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
 
 <a id="adjusting-budgets-at-runtime"></a>
 ### Adjusting budgets at runtime
@@ -80,7 +80,7 @@ This section explains the design of the executor and points at the code that rea
 
 ### Design concept
 
-The executor is a Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything bash-shaped — command defaulting and caps, deadline fusion and cause classification, the model-friendly terminal environment, and the background read merge — while process-group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) belong to the subprocess service. Every call spawns a fresh non-login `bash -c` with no rc files, so commands are deterministic and shell state never leaks between calls.
+The executor is a Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything bash-shaped — command defaulting and caps, deadline fusion and cause classification, the model-friendly terminal environment, and the background read merge — while managed-range mechanics (bounded spill-backed output, credential scrub, termination escalation, quiescence, and disposal) belong to the subprocess service. Every call spawns a fresh non-login `bash -c` with no rc files, so commands are deterministic and shell state never leaks between calls.
 
 ### Source map
 
@@ -114,7 +114,7 @@ Read these pages when the executor contract is not enough. They move from the se
 - [bash-sandbox](../bash-sandbox/README.md) — the confining executor to compose instead when commands need the sandbox capability.
 - [tool-bash](../tool-bash/README.md) — the model-facing `bash` tool over this executor.
 - [Bash executor subsystem](../../../docs/subsystems/shell.md) — request/spec vocabulary, results, and the service contract in full.
-- [subprocess-local](../../subprocess/subprocess-local/README.md) — the process-group mechanics behind this executor.
+- [subprocess-local](../../subprocess/subprocess-local/README.md) — the managed-range mechanics behind this executor.
 
 -----
 

+ 3 - 3
packages/shell/bash-local/README.zh.md

@@ -61,7 +61,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### 后台进程
 
-调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 停止进程组;`done` 在进程关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
+调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止提供方管理的 range;`done` 在直接命令关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
 
 <a id="adjusting-budgets-at-runtime"></a>
 ### 运行时调整预算
@@ -80,7 +80,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### 设计概念
 
-本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 Service Provider:它负责所有 bash 层职责——命令默认化与上限、deadline 融合与原因分类、面向模型的终端环境,以及后台读取合并——而进程组机制(有界 spill 输出、凭据清除、终止升级、dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非登录 `bash -c`,不读取 rc 文件,因此命令是确定性的,shell 状态绝不会在调用之间泄漏。
+本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 Service Provider:它负责所有 bash 层职责——命令默认化与上限、deadline 融合与原因分类、面向模型的终端环境,以及后台读取合并——而 managed-range 机制(有界 spill 输出、凭据清除、终止升级、完全停稳与 dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非登录 `bash -c`,不读取 rc 文件,因此命令是确定性的,shell 状态绝不会在调用之间泄漏。
 
 ### 源码地图
 
@@ -114,7 +114,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 - [bash-sandbox](../bash-sandbox/README.zh.md) —— 需要沙箱能力时替换组合的受限执行器。
 - [tool-bash](../tool-bash/README.zh.md) —— 基于本执行器的面向模型 `bash` 工具。
 - [Bash 执行器子系统](../../../docs/subsystems/shell.zh.md) —— 请求/spec 词汇、结果与完整的服务约定。
-- [subprocess-local](../../subprocess/subprocess-local/README.zh.md) —— 本执行器背后的进程组机制。
+- [subprocess-local](../../subprocess/subprocess-local/README.zh.md) —— 本执行器背后的 managed-range 机制。
 
 -----
 

+ 5 - 5
packages/shell/bash-local/src/index.ts

@@ -1,6 +1,6 @@
 /**
  * Local Service Provider for the bash capability seam over the subprocess
- * capability seam. Public commands run as `bash -c` in a managed process group spawned
+ * capability seam. Public commands run as `bash -c` in a provider-managed range
  * through `ctx.subprocess`; subclasses may reuse the same mechanics with an
  * explicit argv. This executor owns command defaulting, deadlines and cause
  * classification, the model-friendly terminal environment, and the model-facing
@@ -93,9 +93,9 @@ export function assertServiceableBashConfig(config: Config): void {
 }
 
 /**
- * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
- * process-group SIGTERM→SIGKILL escalation are the subprocess service's
- * mechanics; this executor supplies their configured budgets per spawn, so a
+ * Local bash executor over `ctx.subprocess`. Bounded output, spill files,
+ * managed-range SIGTERM→SIGKILL escalation, and quiescence are the subprocess
+ * service's mechanics; this executor supplies their configured budgets per spawn, so a
  * still-running background process stays managed (killed and joined at
  * composition teardown) even across an executor reload.
  */
@@ -247,7 +247,7 @@ export class LocalBashExecutor extends ShellExecutor {
 
   /**
    * Start an explicit argv with the background lifecycle, environment, output,
-   * cancellation, and process-tree ownership semantics of this executor.
+   * cancellation, and managed-range ownership semantics of this executor.
    * Subclasses use this after replacing the public command's shell argv at an
    * execution boundary.
    * @param spec - resolved execution settings and caller-owned command metadata.

+ 1 - 1
packages/shell/bash-local/tests/executor.spec.ts

@@ -239,7 +239,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
     expect(read.delta).toContain('[stderr]')
   })
 
-  it('kill() terminates the process group: true once, false after settlement', async () => {
+  it('kill() requests managed-range termination: true once, false after settlement', async () => {
     const { bash } = await setup()
     const proc = bash.start(bash.resolve({ command: 'sleep 60' }))
     expect(proc.kill()).toBe(true)

+ 2 - 2
packages/shell/pwsh-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 packages/shell/pwsh-local/README.md
-README.md: 275a5a0bcda3ddc663b989f778f6cd69d19470b9
-README.zh.md: 93f81526e8b0911f331254324eafce19cdd78c42
+README.md: 92d10d08da5210fb01e9ae742d3e804c81eaab2c
+README.zh.md: a964818e3b04dd062166ddb58bfff12fb2a6e508

+ 2 - 2
packages/shell/pwsh-local/README.md

@@ -66,7 +66,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### Background processes
 
-Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` stops the process tree; `done` settles when the process closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
+Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
 
 <a id="adjusting-budgets-at-runtime"></a>
 ### Adjusting budgets at runtime
@@ -85,7 +85,7 @@ This section explains the design of the executor and points at the code that rea
 
 ### Design concept
 
-The executor is the PowerShell Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything pwsh-shaped — executable resolution, command defaulting and caps, deadline fusion and cause classification, UTF-8 output pinning, the model-friendly terminal environment, and the background read merge — while process-tree mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) belong to the subprocess service. Every call spawns a fresh non-interactive `pwsh -Command` with `-NoLogo -NoProfile -NonInteractive`, so commands are deterministic and profile state never leaks between calls.
+The executor is the PowerShell Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything pwsh-shaped — executable resolution, command defaulting and caps, deadline fusion and cause classification, UTF-8 output pinning, the model-friendly terminal environment, and the background read merge — while managed-range mechanics (bounded spill-backed output, credential scrub, termination escalation, quiescence, and disposal) belong to the subprocess service. Every call spawns a fresh non-interactive `pwsh -Command` with `-NoLogo -NoProfile -NonInteractive`, so commands are deterministic and profile state never leaks between calls.
 
 ### Source map
 

+ 2 - 2
packages/shell/pwsh-local/README.zh.md

@@ -66,7 +66,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### 后台进程
 
-调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 停止进程树;`done` 在进程关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
+调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止由提供方管理的 range;`done` 在 direct command 关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
 
 <a id="adjusting-budgets-at-runtime"></a>
 ### 运行时调整预算
@@ -85,7 +85,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
 
 ### 设计概念
 
-本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 PowerShell Service Provider:它负责所有 pwsh 层职责——可执行文件解析、命令默认化与上限、deadline 融合与原因分类、UTF-8 输出固定、面向模型的终端环境,以及后台读取合并——而进程树机制(有界 spill 输出、凭据清除、终止升级、dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非交互 `pwsh -Command`,并带 `-NoLogo -NoProfile -NonInteractive`,因此命令是确定性的,profile 状态绝不会在调用之间泄漏。
+本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 PowerShell Service Provider:它负责所有 pwsh 层职责——可执行文件解析、命令默认化与上限、deadline 融合与原因分类、UTF-8 输出固定、面向模型的终端环境,以及后台读取合并——而 managed-range 机制(有界 spill 输出、凭据清除、终止升级、完全停稳与 dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非交互 `pwsh -Command`,并带 `-NoLogo -NoProfile -NonInteractive`,因此命令是确定性的,profile 状态绝不会在调用之间泄漏。
 
 ### 源码地图
 

+ 1 - 1
packages/shell/pwsh-local/src/index.ts

@@ -122,7 +122,7 @@ export function assertServiceablePwshConfig(config: Config): void {
 
 /**
  * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
- * files, and process-tree termination are the subprocess service's mechanics;
+ * files, and managed-range termination are the subprocess service's mechanics;
  * this executor supplies their configured budgets per spawn.
  */
 export class PwshLocalExecutor extends ShellExecutor {

+ 1 - 1
packages/shell/pwsh-local/tests/executor.spec.ts

@@ -425,7 +425,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
     expect(lf(read.delta)).toContain('[stderr]')
   })
 
-  it('kill() terminates the process tree: true once, false after settlement', async () => {
+  it('kill() requests managed-range termination: true once, false after settlement', async () => {
     const { bash } = await setup()
     const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
     expect(proc.kill()).toBe(true)

+ 2 - 2
packages/shell/shell/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 packages/shell/shell/README.md
-README.md: 0c1cb0872b32be55f5d5b617091b8acc39d64b2b
-README.zh.md: 3e2fe2a7786a0195cd13c59f8118e2a63147ed40
+README.md: 475ca13ab40d56ce260934dedbd18130c1d260fb
+README.zh.md: 66b39b985103dc84237ca9af5127fd2c9448b59a

+ 1 - 1
packages/shell/shell/README.md

@@ -38,7 +38,7 @@ console.log(result.exitCode, result.stdout.text)
 
 ### Background processes
 
-Call `start` with a resolved spec to launch a background process; it returns a handle immediately and no timeout applies. Read output incrementally with `readOutput()` — consecutive reads never repeat output, and lossy reads point at full-stream spill files. Kill the process group with `kill()` (returns `false` once it has finished) and await `done` for settlement. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, where the tool layer registers the handle.
+Call `start` with a resolved spec to launch a background process; it returns a handle immediately and no timeout applies. Read output incrementally with `readOutput()` — consecutive reads never repeat output, and lossy reads point at full-stream spill files. Terminate the provider-managed range with `kill()` (returns `false` once the direct command has finished) and await `done` for direct-command settlement. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, where the tool layer registers the handle.
 
 ### Requests and resolved specs
 

+ 1 - 1
packages/shell/shell/README.zh.md

@@ -38,7 +38,7 @@ console.log(result.exitCode, result.stdout.text)
 
 ### 后台进程
 
-用已解析的 spec 调用 `start` 即可启动后台进程;它会立即返回句柄,且不应用任何超时。用 `readOutput()` 增量读取输出——连续读取绝不会重复交付,有损读取会指向完整流的 spill 文件。用 `kill()` 终止进程组(进程结束后返回 `false`),并等待 `done` 结算。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
+用已解析的 spec 调用 `start` 即可启动后台进程;它会立即返回句柄,且不应用任何超时。用 `readOutput()` 增量读取输出——连续读取绝不会重复交付,有损读取会指向完整流的 spill 文件。用 `kill()` 终止提供方管理的 range(直接命令结束后返回 `false`),并等待 `done` 完成直接命令结算。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
 
 ### 请求与已解析 spec
 

+ 1 - 1
packages/shell/shell/src/types.ts

@@ -179,7 +179,7 @@ export interface ShellProcess {
    */
   readOutput(): ShellProcessRead
   /**
-   * Kill the process group. Returns false when it had already finished
+   * Terminate the provider-managed range. Returns false when it had already finished
    * (no-op); idempotent.
    */
   kill(): boolean

+ 2 - 2
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 packages/subagent/subagent-acp/README.md
-README.md: 185a06a4003386a18414998e9cbaa314b9599921
-README.zh.md: 1594677f2ecdacd4ca389337fb29bd01ff18a108
+README.md: 957d38f38ffc6ffc6804e052c3c97b947dfe41cd
+README.zh.md: 5ceea8e1402cc1a5eb8dc8edc58d5940679f3575

+ 2 - 2
packages/subagent/subagent-acp/README.md

@@ -67,7 +67,7 @@ A successful run returns the child's final streamed assistant text as the result
 
 ### Failure and recovery
 
-A spawn, initialization, or new-session failure rejects before publication, ordinarily after the child process is reaped. If cleanup also fails, the rejection preserves ordered safe startup and teardown facts without claiming whole-tree quiescence. Non-cancellation errors expose only fixed provider, stage, and category facts; the original failure stays on the internal cause chain and in Host diagnostics. After publication, a prompt, transport, or early-process failure resolves as `error` with a safe diagnostic, while local cancellation resolves as `aborted` without failure detail.
+A spawn, initialization, or new-session failure rejects before publication, ordinarily after the managed range is proven quiescent. If cleanup also fails, the rejection preserves ordered safe startup and teardown facts without claiming whole-range quiescence. Non-cancellation errors expose only fixed provider, stage, and category facts; the original failure stays on the internal cause chain and in Host diagnostics. After publication, a prompt, transport, or early-process failure resolves as `error` with a safe diagnostic, while local cancellation resolves as `aborted` without failure detail.
 
 ### Safe diagnostics
 
@@ -91,7 +91,7 @@ This section explains how the backend drives a child over ACP and where the obse
 
 ### Start and ownership flow
 
-A start resolves the child's working directory (the configured `cwd` override, else the parent session's cwd), spawns the command through the subprocess seam, performs the ACP `initialize` and `newSession` handshake, and only then publishes the run. Fulfillment means a remote session is ready and ownership has transferred to the caller. Disposal is idempotent: it closes stdin and waits a configured grace for cooperative quiescence, then escalates through SIGTERM to SIGKILL and awaits whole-tree exit. Cleanup failures remain observable as ordered safe facts and never claim quiescence.
+A start resolves the child's working directory (the configured `cwd` override, else the parent session's cwd), spawns the command through the subprocess seam, performs the ACP `initialize` and `newSession` handshake, and only then publishes the run. Fulfillment means a remote session is ready and ownership has transferred to the caller. Disposal is idempotent: it closes stdin and waits a configured grace for cooperative quiescence, then escalates through SIGTERM to SIGKILL and awaits whole-range exit. Cleanup failures remain observable as ordered safe facts and never claim quiescence.
 
 ### Stop-reason mapping
 

+ 2 - 2
packages/subagent/subagent-acp/README.zh.md

@@ -67,7 +67,7 @@ DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `D
 
 ### 失败与恢复
 
-spawn、初始化或新建会话失败会在发布前拒绝,通常先等待子进程被回收。如果清理也失败,拒绝会保留有序、安全的启动与拆卸事实,但不会声称整棵进程树已经停稳。非取消错误只暴露固定的提供方、阶段与类别事实;原始失败保留在内部 cause 链与 Host 诊断中。发布后,提示词、传输或进程提前退出会以携带安全诊断的 `error` 结算;本地取消则以不带失败详情的 `aborted` 结算。
+spawn、初始化或新建会话失败会在发布前拒绝,通常先证明 managed range 已经完全停稳。如果清理也失败,拒绝会保留有序、安全的启动与拆卸事实,但不会声称整个 range 已经停稳。非取消错误只暴露固定的提供方、阶段与类别事实;原始失败保留在内部 cause 链与 Host 诊断中。发布后,提示词、传输或进程提前退出会以携带安全诊断的 `error` 结算;本地取消则以不带失败详情的 `aborted` 结算。
 
 ### 安全诊断
 
@@ -91,7 +91,7 @@ spawn、初始化或新建会话失败会在发布前拒绝,通常先等待子
 
 ### 启动与所有权流程
 
-一次启动先解析子 agent 的工作目录(配置的 `cwd` 覆盖值,否则取父会话 cwd),经子进程 seam spawn 命令,完成 ACP `initialize` 与 `newSession` 握手,然后才发布运行。兑现意味着远程会话已就绪、所有权已转移给调用方。dispose(资源释放)是幂等的:先关闭 stdin 并按配置的宽限等待协作式完全停稳,再经 SIGTERM 升级到 SIGKILL,并等待整棵进程树退出。清理失败会作为有序的安全事实保持可观察,且绝不声称已经完全停稳。
+一次启动先解析子 agent 的工作目录(配置的 `cwd` 覆盖值,否则取父会话 cwd),经子进程 seam spawn 命令,完成 ACP `initialize` 与 `newSession` 握手,然后才发布运行。兑现意味着远程会话已就绪、所有权已转移给调用方。dispose(资源释放)是幂等的:先关闭 stdin 并按配置的宽限等待协作式完全停稳,再经 SIGTERM 升级到 SIGKILL,并等待整个 managed range 退出。清理失败会作为有序的安全事实保持可观察,且绝不声称已经完全停稳。
 
 ### 停止原因映射
 

+ 10 - 10
packages/subagent/subagent-acp/src/run.ts

@@ -67,7 +67,7 @@ export interface AcpRunSpec {
   disposeGraceMs: number
   /**
    * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the
-   * child rides the shared scrub, tree-scoped teardown, and service-owned
+   * child rides the shared scrub, managed-range teardown, and service-owned
    * lifetime instead of a package-local child_process path.
    */
   spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
@@ -170,8 +170,8 @@ function permissionRequestKind(kind: ToolKind | null | undefined): ToolKind | 'u
     : 'unknown'
 }
 
-/** 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<boolean> {
+/** Bounded managed-range exit wait: observes the handle's range until it is empty or `ms` elapses. */
+async function rangeExitsWithin(child: SubprocessHandle, ms: number): Promise<boolean> {
   const controller = new AbortController()
   const timer = setTimeout(() => { controller.abort() }, ms)
   try {
@@ -183,10 +183,10 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boo
 
 /**
  * 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
+ * public verbs; resolves only at whole-range quiescence: stdin EOF (the child's
  * window to flush persistence and reap its own descendants), then the
  * terminate() escalation (SIGTERM → spec grace → SIGKILL) and its
- * whole-tree exit proof.
+ * whole-range exit proof.
  * @param child - the spawned ACP child's handle.
  * @param eofGraceMs - tier-1 window after stdin EOF.
  */
@@ -195,7 +195,7 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe
   child.stdin?.end()
   let exited = false
   try {
-    exited = await treeExitsWithin(child, eofGraceMs)
+    exited = await rangeExitsWithin(child, eofGraceMs)
   } catch (error: unknown) {
     failures.push(toError(error))
   }
@@ -325,10 +325,10 @@ function terminalFailure(
 /**
  * Start and publish one ACP child after initialization and session creation.
  * Child failures resolve through the run result. Startup rejects with fixed
- * safe facts after provider-owned cleanup; successful cleanup proves process
- * reap. Cleanup failure preserves startup plus teardown facts for an ordinary
+ * safe facts after provider-owned cleanup; successful cleanup proves managed
+ * range quiescence. Cleanup failure preserves startup plus teardown facts for an ordinary
  * failure, or teardown alone after cancellation, without claiming quiescence.
- * Disposal cancels, kills, and reaps the child.
+ * Disposal cancels, terminates, and settles the child's managed range.
  * @param request - the start request; its signal is the cancellation channel.
  * @param spec - the resolved spawn spec: command/args/cwd, env, permission
  * policy, dispose graces, and the optional error sink.
@@ -508,7 +508,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
   } catch (error: unknown) {
     request.signal.removeEventListener('abort', onAbort)
     const cancelledBeforeCleanup = flags.cancelled
-    // A child closing its protocol stream can precede whole-tree exit
+    // A child closing its protocol stream can precede whole-range exit
     // observation. Local cancellation does not need the discarded startup
     // classification; other failures use the configured process grace.
     const observedOutcome = !cancelledBeforeCleanup && !(error instanceof AcpRunFailure)

+ 2 - 2
packages/subagent/subagent-claude-code/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 packages/subagent/subagent-claude-code/README.md
-README.md: 9fcce15b45a77a7a3b7dd4e6299609f8ef5ee484
-README.zh.md: 5066c2553f7bd7f71fd87c7ee5acb800b7398b59
+README.md: 9218c31aec506764cd002b682d2555876227d42d
+README.zh.md: 30021dc36c57844e43241829fdeb18228e36387c

+ 2 - 2
packages/subagent/subagent-claude-code/README.md

@@ -47,7 +47,7 @@ Removing the package withdraws the provider and its private runtime closure on t
 | `model` | native Claude settings | Optional non-empty model name fixed for every run from this provider instance; omission sends no SDK override |
 | `env` | `{}` | Explicit SDK/CLI environment layered over the credential-scrubbed parent environment |
 | `permissionMode` | `dontAsk` | Native non-interactive permission policy fixed for every run from this provider instance |
-| `disposeGraceMs` | `3000` | Grace between the shared process-tree owner's termination tiers |
+| `disposeGraceMs` | `3000` | Grace between the shared managed-range owner's termination tiers |
 
 | `permissionMode` value | Native behavior |
 |---|---|
@@ -109,7 +109,7 @@ This section explains how the provider drives a real Claude Code CLI and where t
 |---|---|
 | [`src/index.ts`](src/index.ts) | Plugin entry: config schema, provider registration |
 | [`src/run.ts`](src/run.ts) | The SDK query lifecycle, result acceptance, and permission handling |
-| [`src/process.ts`](src/process.ts) | Process-tree termination escalation on disposal |
+| [`src/process.ts`](src/process.ts) | Managed-range termination escalation on disposal |
 | [`cordis.patch.yml`](cordis.patch.yml) | The Profile patch layer that registers the dormant provider |
 
 ### Run flow

+ 2 - 2
packages/subagent/subagent-claude-code/README.zh.md

@@ -47,7 +47,7 @@ dsh --profile <name>
 | `model` | Claude 原生设置 | 为本提供方实例的每次运行固定的可选非空模型名称;省略时不发送 SDK 覆盖 |
 | `env` | `{}` | 叠加在已清理凭据的父环境之上的显式 SDK/CLI 环境 |
 | `permissionMode` | `dontAsk` | 为本提供方实例的每次运行固定的原生非交互权限策略 |
-| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限 |
+| `disposeGraceMs` | `3000` | 共享 managed-range owner 各终止层级之间的宽限 |
 
 | `permissionMode` 值 | 原生行为 |
 |---|---|
@@ -109,7 +109,7 @@ dsh --profile <name>
 |---|---|
 | [`src/index.ts`](src/index.ts) | 插件入口:config schema、提供方注册 |
 | [`src/run.ts`](src/run.ts) | SDK query 生命周期、结果接受与权限处理 |
-| [`src/process.ts`](src/process.ts) | dispose 时的进程树逐级终止 |
+| [`src/process.ts`](src/process.ts) | dispose 时的 managed-range 逐级终止 |
 | [`cordis.patch.yml`](cordis.patch.yml) | 注册休眠提供方的 Profile patch 层 |
 
 ### 运行流程

+ 1 - 1
packages/subagent/subagent-claude-code/src/index.ts

@@ -52,7 +52,7 @@ export interface Config {
    * `bypassPermissions` explicitly skips permission checks.
    */
   permissionMode?: ClaudeCodePermissionMode
-  /** Grace in milliseconds for Claude Code process-tree termination. */
+  /** Grace in milliseconds between Claude Code managed-range termination tiers. */
   disposeGraceMs?: number
 }
 

+ 1 - 1
packages/subagent/subagent-claude-code/src/invariant.ts

@@ -17,7 +17,7 @@ export const inject = ['invariants']
 
 /**
  * No runtime invariant: lifecycle pairing belongs to the shared subagent
- * service and process-tree ownership belongs to the subprocess service.
+ * service and managed-range ownership belongs to the subprocess service.
  */
 const install: InvariantInstaller = () => {}
 

+ 4 - 4
packages/subagent/subagent-claude-code/src/process.ts

@@ -40,7 +40,7 @@ export function sdkEnvironmentOverlay(
 /**
  * Translate one official SDK spawn request to the shared process owner.
  * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK.
- * @param graceMs - process-tree termination grace.
+ * @param graceMs - managed-range termination grace.
  * @returns the fully explicit shared subprocess request.
  */
 export function claudeSpawnSpec(
@@ -73,7 +73,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess {
 
   /**
    * Project a managed process with piped stdin and stdout.
-   * @param child - shared handle that remains the process-tree authority.
+   * @param child - shared handle that remains the managed-range authority.
    */
   constructor(private readonly child: SubprocessHandle) {
     this.stdin = child.stdin as NonNullable<SubprocessHandle['stdin']>
@@ -93,7 +93,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess {
     )
   }
 
-  /** Whether the SDK has requested managed tree termination. */
+  /** Whether the SDK has requested managed-range termination. */
   get killed(): boolean {
     return this.killRequested
   }
@@ -114,7 +114,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess {
   }
 
   /**
-   * Route the SDK's termination request to the tree-scoped process owner.
+   * Route the SDK's termination request to the managed-range process owner.
    * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder.
    * @returns false only after exit or a previous termination request.
    */

+ 5 - 5
packages/subagent/subagent-claude-code/src/run.ts

@@ -1,7 +1,7 @@
 /**
  * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its
  * real CLI process under the shared subprocess owner, map only strict SDK
- * success to completion, and dispose to whole-tree quiescence.
+ * success to completion, and dispose to whole-range quiescence.
  *
  * @module @deepseek-ai/dsh-subagent-claude-code/run
  */
@@ -156,7 +156,7 @@ export interface ClaudeCodeRunSpec {
   readonly permissionMode: ClaudeCodePermissionMode
   /** Explicit deployment/test environment layered after shared scrubbing. */
   readonly env: Record<string, string>
-  /** Subprocess termination grace passed to the shared process-tree owner. */
+  /** Subprocess termination grace passed to the shared managed-range owner. */
   readonly disposeGraceMs: number
   /** Shared subprocess service spawn operation. */
   readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
@@ -261,10 +261,10 @@ export async function consumeClaudeQuery(
 }
 
 /**
- * Close the official query, terminate the managed process tree, and wait for
- * the subprocess owner to prove it is gone.
+ * Close the official query, terminate the managed range, and wait for the
+ * subprocess owner to prove it is quiescent.
  * @param query - official SDK query, when creation reached that point.
- * @param child - shared-service handle that owns the CLI process tree, including
+ * @param child - shared-service handle that owns the CLI managed range, including
  * a published handle whose direct result later rejects.
  */
 export async function disposeClaudeCodeChild(

+ 3 - 3
packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts

@@ -1723,7 +1723,7 @@ describe('query and process disposal', () => {
       .toBe('SECRET_TOKEN close failure')
   })
 
-  it('does not finish disposal before the managed tree exits', async () => {
+  it('does not finish disposal before the managed range is empty', async () => {
     const child = fakeChild({ exitOnTerminate: false })
     let disposed = false
     const disposal = disposeClaudeCodeChild(
@@ -1739,7 +1739,7 @@ describe('query and process disposal', () => {
     expect(disposed).toBe(true)
   })
 
-  it('reports close and tree-wait failures without skipping cleanup', async () => {
+  it('reports close and range-wait failures without skipping cleanup', async () => {
     const waitFailure = fakeChild({
       waitForExitError: new Error('wait boom'),
     })
@@ -1766,7 +1766,7 @@ describe('query and process disposal', () => {
     expect(waitFailure.terminate).toHaveBeenCalledOnce()
   })
 
-  it('reports a tree-wait failure without waiting for a pending direct outcome', async () => {
+  it('reports a range-wait failure without waiting for a pending direct outcome', async () => {
     const waitFailure = new Error('managed range observation failed')
     const child = fakeChild({
       exitOnTerminate: false,

+ 2 - 2
packages/subagent/subagent-codex/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 packages/subagent/subagent-codex/README.md
-README.md: db1250987333f6431f9a05f5e440e9d47a1d36f2
-README.zh.md: 4142c3ad54abc19ff3bd86a1b7fb6708eadfe9bf
+README.md: f8582b94e6451a15cd97b7b7447da17c36912007
+README.zh.md: f9206f54037444191f3b85334f162d9ad824b92c

+ 1 - 1
packages/subagent/subagent-codex/README.md

@@ -47,7 +47,7 @@ Removing the package withdraws the provider and its private runtime closure on t
 | `model` | native Codex settings | Optional non-empty model name fixed for every thread from this provider instance; omission sends no app-server override |
 | `env` | `{}` | Explicit child environment layered over the credential-scrubbed parent environment |
 | `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this provider instance |
-| `disposeGraceMs` | `3000` | Grace between the shared process-tree owner's termination tiers |
+| `disposeGraceMs` | `3000` | Grace between the shared managed-range owner's termination tiers |
 
 | `permissionMode` value | `thread/start` fields | Native behavior |
 |---|---|---|

+ 1 - 1
packages/subagent/subagent-codex/README.zh.md

@@ -47,7 +47,7 @@ dsh --profile <name>
 | `model` | Codex 原生设置 | 为本提供方实例的每个线程固定的可选非空模型名称;省略时不发送 app-server 覆盖 |
 | `env` | `{}` | 叠加在已清理凭据的父环境之上的显式子进程环境 |
 | `permissionMode` | `never` | 为本提供方实例的每个线程固定的原生非交互审批与沙箱模式 |
-| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限 |
+| `disposeGraceMs` | `3000` | 共享 managed-range owner 各终止层级之间的宽限 |
 
 | `permissionMode` 值 | `thread/start` 字段 | 原生行为 |
 |---|---|---|

+ 1 - 1
packages/subagent/subagent-codex/src/index.ts

@@ -45,7 +45,7 @@ export interface Config {
   env?: Record<string, string>
   /** Native non-interactive permission mode fixed for this Provider instance. */
   permissionMode?: CodexPermissionMode
-  /** Grace in milliseconds for app-server process-tree termination. */
+  /** Grace in milliseconds between app-server managed-range termination tiers. */
   disposeGraceMs?: number
 }
 

+ 1 - 1
packages/subagent/subagent-codex/src/invariant.ts

@@ -16,7 +16,7 @@ export const inject = ['invariants']
 
 /**
  * No runtime invariant: lifecycle pairing belongs to the shared subagent
- * service and process-tree ownership belongs to the subprocess service.
+ * service and managed-range ownership belongs to the subprocess service.
  */
 const install: InvariantInstaller = () => {}
 

+ 6 - 6
packages/subagent/subagent-codex/src/run.ts

@@ -1,7 +1,7 @@
 /**
  * One-shot Codex child lifecycle: spawn the real app-server through the
  * subprocess seam, publish only after initialization and ephemeral thread
- * creation, flatten post-publication failures, and dispose to whole-tree
+ * creation, flatten post-publication failures, and dispose to whole-range
  * quiescence.
  *
  * @module @deepseek-ai/dsh-subagent-codex/run
@@ -146,7 +146,7 @@ export interface CodexRunSpec {
   readonly permissionMode: CodexPermissionMode
   /** Explicit deployment/test environment layered after the shared scrub. */
   readonly env: Record<string, string>
-  /** Subprocess termination grace passed to the shared process-tree owner. */
+  /** Subprocess termination grace passed to the shared managed-range owner. */
   readonly disposeGraceMs: number
   /** Shared subprocess service spawn operation. */
   readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
@@ -182,10 +182,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] {
 }
 
 /**
- * Close the private wire, terminate the managed process tree, and wait for the
- * subprocess owner to prove it is gone.
+ * Close the private wire, terminate the managed range, and wait for the
+ * subprocess owner to prove it is quiescent.
  * @param wire - private app-server protocol connection.
- * @param child - shared-service handle that owns the process tree.
+ * @param child - shared-service handle that owns the managed range.
  */
 export async function disposeCodexChild(
   wire: CodexAppServerWire,
@@ -201,7 +201,7 @@ export async function disposeCodexChild(
   try {
     child.stdin?.end()
   } catch {
-    // A concurrently closed stdin does not change tree ownership below.
+    // A concurrently closed stdin does not change range ownership below.
   }
   child.terminate()
   try {

+ 3 - 3
packages/subagent/subagent-codex/tests/subagent-codex.spec.ts

@@ -2246,7 +2246,7 @@ describe('run lifecycle and quiescence', () => {
 })
 
 describe('disposeCodexChild', () => {
-  it('closes stdin, terminates, and waits for the managed tree', async () => {
+  it('closes stdin, terminates, and waits for the managed range', async () => {
     const child = fakeChild()
     const wire = defaultWire(child)
     const end = vi.spyOn(child.toChild, 'end')
@@ -2257,7 +2257,7 @@ describe('disposeCodexChild', () => {
     expect(child.waitForExit).toHaveBeenCalledWith()
   })
 
-  it('does not finish disposal before the managed tree exits', async () => {
+  it('does not finish disposal before the managed range is empty', async () => {
     const child = fakeChild({ exitOnTerminate: false })
     const wire = defaultWire(child)
     let disposed = false
@@ -2292,7 +2292,7 @@ describe('disposeCodexChild', () => {
     expect(child.waitForExit).toHaveBeenCalledOnce()
   })
 
-  it('reports tree-wait failure with safe teardown facts', async () => {
+  it('reports range-wait failure with safe teardown facts', async () => {
     const child = fakeChild({
       waitForExitError: new Error('SECRET_TOKEN wait failure'),
     })

+ 1 - 1
packages/subagent/subagent/src/out-of-process.ts

@@ -5,7 +5,7 @@
  * working-directory resolution (config override, else the delegating parent
  * session's workspace), the never-reject result settlement, and the standard
  * run-handle publication. Backends compose these with their own wire drivers;
- * the process machinery itself (spawn, env scrub, tree-scoped teardown)
+ * the process machinery itself (spawn, env scrub, managed-range teardown)
  * belongs to the `dsh-subprocess` seam.
  *
  * @module @deepseek-ai/dsh-subagent/out-of-process

+ 17 - 9
packages/subprocess/subprocess-local/src/index.ts

@@ -32,6 +32,7 @@ import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'
 import {
   launchLinuxScope,
   prepareLinuxTerminalScope,
+  probeLinuxManager,
   probeLinuxNative,
 } from './linux-scope.ts'
 import { launchWindowsJob, probeWindowsJob } from './windows-job.ts'
@@ -56,6 +57,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
   internals: SpawnInternals = {}
   /** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
   private fallbackWarningIssued = false
+  /** Positive-only cache for the expensive Linux bootstrap and scope probe. */
+  private linuxDeepProbePassed = false
   /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
   terminalInspector: ProcessInspector | undefined
 
@@ -173,7 +176,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
       handle = bindManagedProcess(spec, launch, binding)
     }
     this.live.add(handle)
-    // Release ownership only once the whole TREE is gone, not at direct-child
+    // Release ownership only once the whole managed range is gone, not at direct-child
     // settlement — a TERM-trapping helper that outlives the leader must stay
     // owned so teardown can still escalate it. For the common no-survivor
     // case waitForExit resolves immediately after settlement.
@@ -189,7 +192,14 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
     const platform = this.internals.platform ?? process.platform
     let fallbackReason: string | undefined
     if (platform === 'linux') {
-      const available = this.internals.linuxNativeAvailable?.() ?? probeLinuxNative()
+      const deepProbe = this.internals.linuxNativeAvailable ?? probeLinuxNative
+      const managerProbe = this.internals.linuxManagerAvailable
+        ?? this.internals.linuxNativeAvailable
+        ?? probeLinuxManager
+      const available = this.linuxDeepProbePassed
+        ? managerProbe()
+        : deepProbe()
+      if (available) this.linuxDeepProbePassed = true
       if (available) return 'linux-scope'
       fallbackReason = 'the current user-systemd scope or private bootstrap is unavailable'
     }
@@ -210,13 +220,11 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
     this.fallbackWarningIssued = true
     const reason = selectedReason ?? (platform === 'darwin'
       ? 'macOS has no supported persistent process-range owner'
-      : platform === 'linux'
-        ? 'a modern readable user-systemd scope is unavailable'
-        : platform === 'win32'
-          ? kind === 'terminal'
-            ? 'Windows ConPTY remains outside Job containment'
-            : 'the Win32 Job runner is unavailable'
-          : `platform ${platform} has no native managed range`)
+      : platform === 'win32'
+        ? kind === 'terminal'
+          ? 'Windows ConPTY remains outside Job containment'
+          : 'the Win32 Job runner is unavailable'
+        : `platform ${platform} has no native managed range`)
     this.ctx.logger.warn(
       `subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`,
     )

+ 106 - 22
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -52,7 +52,13 @@ const SYSTEMCTL_TIMEOUT_MS = 5_000
 const SCOPE_INITIAL_POLL_INTERVAL_MS = 50
 const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu
 
-function systemctlEnv(): NodeJS.ProcessEnv {
+function managerEnvironment(): NodeJS.ProcessEnv {
+  const environment = childEnv({ LC_ALL: 'C' })
+  delete environment.SYSTEMD_LOG_TARGET
+  return environment
+}
+
+function quietSystemdEnvironment(): NodeJS.ProcessEnv {
   return childEnv({ LC_ALL: 'C', SYSTEMD_LOG_TARGET: 'null' })
 }
 
@@ -60,7 +66,7 @@ function querySystemctl(command: string, args: readonly string[]): Promise<Syste
   return new Promise((resolveResult) => {
     execFile(command, [...args], {
       encoding: 'utf8',
-      env: systemctlEnv(),
+      env: managerEnvironment(),
       timeout: SYSTEMCTL_TIMEOUT_MS,
     }, (error, stdout, stderr) => {
       const code = error === null ? 0 : (error as Error & { code?: string | number }).code
@@ -115,7 +121,22 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
     `${unitBase}.scope`,
     '--property=ActiveState',
     '--value',
-  ], { env: systemctlEnv(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
+  ], { env: quietSystemdEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
+  return result.error === undefined && result.status === 0
+}
+
+/**
+ * Confirm that the current user manager remains reachable after a positive deep probe.
+ * @param internals - optional systemctl seam used by tests.
+ * @returns whether one lightweight manager query succeeds.
+ */
+export function probeLinuxManager(internals: LinuxScopeInternals = {}): boolean {
+  const result = (internals.spawnSync ?? spawnSync)(internals.systemctl ?? 'systemctl', [
+    '--user',
+    'show',
+    '--property=Version',
+    '--value',
+  ], { env: managerEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
   return result.error === undefined && result.status === 0
 }
 
@@ -135,10 +156,12 @@ interface DirectRange {
 }
 
 class SystemdScopeOwner implements BoundProcessOwner {
-  private established = false
+  private establishment: 'pending' | 'established' | 'never-created' = 'pending'
   private stopped = false
   private observation: Promise<void> | undefined
   private killFailure: Error | undefined
+  private wakeGeneration = 0
+  private wakeWaiter: { generation: number; resolve: () => void } | undefined
 
   constructor(
     private readonly unit: string,
@@ -152,8 +175,8 @@ class SystemdScopeOwner implements BoundProcessOwner {
 
   signal(signal: 'SIGTERM' | 'SIGKILL'): void {
     if (this.stopped) return
-    if (!this.established && !existsSync(this.files.requestPath)) this.established = true
-    const directFallbackRequired = !this.established
+    this.observeRequestConsumption()
+    const directFallbackRequired = this.establishment === 'pending'
     if (directFallbackRequired && this.direct.running()) this.direct.signal(signal)
     const result = this.runSync(this.systemctl, [
       '--user',
@@ -161,7 +184,8 @@ class SystemdScopeOwner implements BoundProcessOwner {
       '--kill-whom=all',
       `--signal=${signal}`,
       this.unit,
-    ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS })
+    ], { encoding: 'utf8', env: managerEnvironment(), timeout: SYSTEMCTL_TIMEOUT_MS })
+    this.wakeObservation()
     if (result.error === undefined && result.status === 0) {
       if (signal === 'SIGKILL') this.killFailure = undefined
       return
@@ -189,28 +213,73 @@ class SystemdScopeOwner implements BoundProcessOwner {
         '--kill-whom=all',
         '--signal=SIGKILL',
         this.unit,
-      ], { env: systemctlEnv(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
+      ], { env: managerEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
     } catch {
       // Host exit cannot report one range; the runtime continues with the rest.
     }
   }
 
+  private observeRequestConsumption(): void {
+    if (this.establishment === 'pending' && !existsSync(this.files.requestPath)) {
+      this.establishment = 'established'
+    }
+  }
+
+  private absentUnit(): boolean {
+    this.observeRequestConsumption()
+    if (this.establishment === 'established') return false
+    if (!this.direct.running() && existsSync(this.files.requestPath)) {
+      this.establishment = 'never-created'
+      return false
+    }
+    if (this.killFailure !== undefined) throw this.killFailure
+    return true
+  }
+
+  private parseUnitState(stdout: string): { loadState: string; activeState: string } {
+    const values = new Map<string, string>()
+    for (const line of stdout.split(/\r?\n/u)) {
+      if (line === '') continue
+      const separator = line.indexOf('=')
+      if (separator <= 0) {
+        throw new Error(`systemctl returned malformed state for ${this.unit}: ${JSON.stringify(stdout.trim())}`)
+      }
+      const name = line.slice(0, separator)
+      if (values.has(name)) {
+        throw new Error(`systemctl returned duplicate ${name} for ${this.unit}`)
+      }
+      values.set(name, line.slice(separator + 1))
+    }
+    const loadState = values.get('LoadState')
+    const activeState = values.get('ActiveState')
+    if (values.size !== 2 || loadState === undefined || activeState === undefined) {
+      throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`)
+    }
+    return { loadState, activeState }
+  }
+
   private async rangeActive(): Promise<boolean> {
-    if (!existsSync(this.files.requestPath)) this.established = true
+    this.observeRequestConsumption()
     const result = await this.query(this.systemctl, [
       '--user',
       'show',
       this.unit,
+      '--property=LoadState',
       '--property=ActiveState',
-      '--value',
     ])
     const output = `${result.stdout}\n${result.stderr}`
     if (result.status === 0) {
-      this.established = true
-      const state = result.stdout.trim()
-      if (state === 'inactive' || state === 'failed') return false
-      if (state !== 'active' && state !== 'activating' && state !== 'deactivating') {
-        throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(state)}`)
+      const { loadState, activeState } = this.parseUnitState(result.stdout)
+      if (loadState === 'not-found' && activeState === 'inactive') return this.absentUnit()
+      if (loadState !== 'loaded') {
+        throw new Error(
+          `systemctl returned unknown state for ${this.unit}: ${JSON.stringify({ loadState, activeState })}`,
+        )
+      }
+      this.establishment = 'established'
+      if (activeState === 'inactive' || activeState === 'failed') return false
+      if (!['active', 'activating', 'reloading', 'deactivating'].includes(activeState)) {
+        throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`)
       }
       if (this.killFailure !== undefined) throw this.killFailure
       return true
@@ -219,23 +288,38 @@ class SystemdScopeOwner implements BoundProcessOwner {
       if (result.error !== undefined) throw result.error
       throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`)
     }
-    if (this.established) return false
-    if (!this.direct.running() && existsSync(this.files.requestPath)) {
-      throw new Error(`subprocess scope ${this.unit} ended before consuming its launch request`)
+    return this.absentUnit()
+  }
+
+  private wakeObservation(): void {
+    this.wakeGeneration += 1
+    this.wakeWaiter?.resolve()
+    this.wakeWaiter = undefined
+  }
+
+  private async waitForPoll(delayMs: number, generation: number): Promise<void> {
+    if (generation !== this.wakeGeneration) return
+    const wake = Promise.withResolvers<void>()
+    const waiter = { generation, resolve: wake.resolve }
+    this.wakeWaiter = waiter
+    try {
+      await Promise.race([this.sleep(delayMs), wake.promise])
+    } finally {
+      if (this.wakeWaiter === waiter) this.wakeWaiter = undefined
     }
-    if (this.killFailure !== undefined) throw this.killFailure
-    return true
   }
 
   async waitForExit(): Promise<void> {
     if (this.stopped) return
     this.observation ??= (async () => {
       let pollIntervalMs = SCOPE_INITIAL_POLL_INTERVAL_MS
+      let generation = this.wakeGeneration
       while (await this.rangeActive()) {
-        await this.sleep(pollIntervalMs)
+        await this.waitForPoll(pollIntervalMs, generation)
+        generation = this.wakeGeneration
         // Keep establishment responsive, then reduce systemctl process churn
         // while systemd remains the authoritative owner of an active range.
-        if (this.established) {
+        if (this.establishment === 'established') {
           pollIntervalMs = Math.min(pollIntervalMs * 2, SYSTEMCTL_TIMEOUT_MS)
         }
       }

+ 6 - 23
packages/subprocess/subprocess-local/src/runner-protocol.ts

@@ -26,14 +26,14 @@ export interface LinuxLaunchRequest {
 export interface SerializedRunnerError {
   name: string
   message: string
-  stack?: string
   code?: string
-  errno?: number
   syscall?: string
   path?: string
-  spawnargs?: string[]
 }
 
+/** Private error code used to map pre-commit Windows cancellation to the parent-local reason. */
+export const WINDOWS_START_CANCELLED_CODE = 'DSH_SUBPROCESS_START_CANCELLED' as const
+
 /** A Linux pre-exec failure published atomically beside its consumed request. */
 export type LinuxStartupError =
   { type: 'error'; error: SerializedRunnerError }
@@ -54,7 +54,6 @@ export interface WindowsTerminateRequest {
 export type WindowsRunnerResult =
   | { type: 'target-exit'; exitCode: number }
   | { type: 'error'; error: SerializedRunnerError }
-  | { type: 'start-cancelled' }
 
 /** Private paths owned by one Linux ordinary or PTY spawn. */
 export interface LinuxLaunchFiles {
@@ -81,17 +80,13 @@ function isSerializedRunnerError(value: unknown): value is SerializedRunnerError
   if (!isRecord(value) || !hasExactKeys(
     value,
     ['name', 'message'],
-    ['stack', 'code', 'errno', 'syscall', 'path', 'spawnargs'],
+    ['code', 'syscall', 'path'],
   )) return false
   return typeof value.name === 'string'
     && typeof value.message === 'string'
-    && (value.stack === undefined || typeof value.stack === 'string')
     && (value.code === undefined || typeof value.code === 'string')
-    && (value.errno === undefined || typeof value.errno === 'number')
     && (value.syscall === undefined || typeof value.syscall === 'string')
     && (value.path === undefined || typeof value.path === 'string')
-    && (value.spawnargs === undefined
-      || (Array.isArray(value.spawnargs) && value.spawnargs.every(entry => typeof entry === 'string')))
 }
 
 function parseErrorResult(value: Record<string, unknown>): LinuxStartupError {
@@ -201,7 +196,7 @@ export function isWindowsTerminateRequest(value: unknown): value is WindowsTermi
 }
 
 /**
- * Strictly parse one of the three Windows direct-result branches.
+ * Strictly parse one of the two Windows direct-result branches.
  * @param value - untrusted IPC payload.
  * @returns validated direct-result message.
  */
@@ -209,10 +204,6 @@ export function parseWindowsRunnerResult(value: unknown): WindowsRunnerResult {
   if (!isRecord(value) || typeof value.type !== 'string') {
     throw new Error('subprocess runner emitted an invalid Windows result')
   }
-  if (value.type === 'start-cancelled') {
-    if (!hasExactKeys(value, ['type'])) throw new Error('subprocess runner emitted an invalid start-cancelled result')
-    return { type: 'start-cancelled' }
-  }
   if (value.type === 'error') return parseErrorResult(value)
   if (value.type === 'target-exit') {
     const validExitCode = typeof value.exitCode === 'number'
@@ -236,18 +227,13 @@ export function parseWindowsRunnerResult(value: unknown): WindowsRunnerResult {
  */
 export function serializeRunnerError(error: unknown): SerializedRunnerError {
   const source = error instanceof Error ? error : new Error(String(error))
-  const node = source as NodeJS.ErrnoException & { path?: string; spawnargs?: string[] }
+  const node = source as NodeJS.ErrnoException & { path?: string }
   return {
     name: source.name,
     message: source.message,
-    ...typeof source.stack === 'string' ? { stack: source.stack } : {},
     ...typeof node.code === 'string' ? { code: node.code } : {},
-    ...typeof node.errno === 'number' ? { errno: node.errno } : {},
     ...typeof node.syscall === 'string' ? { syscall: node.syscall } : {},
     ...typeof node.path === 'string' ? { path: node.path } : {},
-    ...Array.isArray(node.spawnargs) && node.spawnargs.every(entry => typeof entry === 'string')
-      ? { spawnargs: [...node.spawnargs] }
-      : {},
   }
 }
 
@@ -259,13 +245,10 @@ export function serializeRunnerError(error: unknown): SerializedRunnerError {
 export function deserializeRunnerError(serialized: SerializedRunnerError): Error {
   const error = new Error(serialized.message)
   error.name = serialized.name
-  if (serialized.stack !== undefined) error.stack = serialized.stack
   return Object.assign(error, {
     ...serialized.code === undefined ? {} : { code: serialized.code },
-    ...serialized.errno === undefined ? {} : { errno: serialized.errno },
     ...serialized.syscall === undefined ? {} : { syscall: serialized.syscall },
     ...serialized.path === undefined ? {} : { path: serialized.path },
-    ...serialized.spawnargs === undefined ? {} : { spawnargs: [...serialized.spawnargs] },
   })
 }
 

+ 22 - 29
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -22,6 +22,7 @@ import {
   linuxLaunchFilesFromLocator,
   parseWindowsStartRequest,
   serializeRunnerError,
+  WINDOWS_START_CANCELLED_CODE,
   writeLinuxStartupError,
 } from './runner-protocol.ts'
 import type {
@@ -92,58 +93,50 @@ const defaultInternals: SpawnRunnerInternals = {
 const NODE_SPAWN_DETAIL_CODES = new Set(['EACCES', 'EAGAIN', 'EMFILE', 'ENFILE', 'ENOENT'])
 
 function nodeSpawnError(
-  source: Pick<SerializedRunnerError, 'stack'>,
   syscall: string,
   code: string,
-  errno: number | undefined,
-  details: Pick<SerializedRunnerError, 'path' | 'spawnargs'>,
+  path?: string,
 ): SerializedRunnerError {
   const message = `${syscall} ${code}`
   return {
     name: 'Error',
     message,
-    ...source.stack === undefined ? {} : {
-      stack: source.stack.replace(/^[^\n]*/, () => `Error: ${message}`),
-    },
     code,
-    ...errno === undefined ? {} : { errno },
     syscall,
-    ...details,
+    ...path === undefined ? {} : { path },
   }
 }
 
 function asSpawnError(
   error: unknown,
   program: string,
-  args: readonly string[],
   internals: Pick<SpawnRunnerInternals, 'uvErrorBindings'>,
 ): SerializedRunnerError {
   const serialized = serializeRunnerError(error)
   if (!(error instanceof Win32Error)) {
     return serialized.code === undefined
       ? serialized
-      : nodeSpawnError(serialized, `spawn ${program}`, serialized.code, serialized.errno, {
-        path: program,
-        spawnargs: [...args],
-      })
+      : nodeSpawnError(`spawn ${program}`, serialized.code, program)
   }
   const uv = internals.uvErrorBindings ?? loadUvErrorBindings()
   const errno = uv.translateSystemError(error.win32Code)
   const code = uv.errorName(errno)
   if (NODE_SPAWN_DETAIL_CODES.has(code)) {
-    return nodeSpawnError(serialized, `spawn ${program}`, code, errno, {
-      path: program,
-      spawnargs: [...args],
-    })
+    return nodeSpawnError(`spawn ${program}`, code, program)
   }
-  return nodeSpawnError(serialized, 'spawn', code, errno, {})
+  return nodeSpawnError('spawn', code)
 }
 
-function windowsPathNotFoundError(program: string, args: readonly string[]): SerializedRunnerError {
-  return nodeSpawnError({}, `spawn ${program}`, 'ENOENT', -4058, {
-    path: program,
-    spawnargs: [...args],
-  })
+function windowsPathNotFoundError(program: string): SerializedRunnerError {
+  return nodeSpawnError(`spawn ${program}`, 'ENOENT', program)
+}
+
+function windowsStartCancelledError(): SerializedRunnerError {
+  return {
+    name: 'Error',
+    message: 'subprocess target start was cancelled',
+    code: WINDOWS_START_CANCELLED_CODE,
+  }
 }
 
 function linuxPathNotFoundError(program: string): NodeJS.ErrnoException {
@@ -177,7 +170,7 @@ function execLinuxTarget(
 ): never {
   const program = argv[0] as string
   if (program.includes('/')) return execLinuxFile(program, argv, request.env, internals)
-  const path = request.env.PATH ?? '/usr/bin:/bin'
+  const path = request.env.PATH ?? '/bin:/usr/bin'
   let permissionFailure: Error | undefined
   for (const directory of path.split(':')) {
     const root = directory.startsWith('/')
@@ -220,7 +213,7 @@ function runLinux(
   } catch (error) {
     writeLinuxStartupError(files, {
       type: 'error',
-      error: asSpawnError(error, argv[0] as string, argv.slice(1), internals),
+      error: asSpawnError(error, argv[0] as string, internals),
     })
     host.exitCode = 127
   }
@@ -302,7 +295,7 @@ class WindowsJobRunner {
 
   private async start(request: WindowsStartRequest): Promise<void> {
     if (this.terminateRequested) {
-      await this.publishTerminalResult({ type: 'start-cancelled' }, 0)
+      await this.publishTerminalResult({ type: 'error', error: windowsStartCancelledError() }, 0)
       return
     }
     await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
@@ -310,7 +303,7 @@ class WindowsJobRunner {
     // IPC may set this field while start() is suspended above.
     // oxlint-disable-next-line typescript/no-unnecessary-condition
     if (this.terminateRequested) {
-      await this.publishTerminalResult({ type: 'start-cancelled' }, 0)
+      await this.publishTerminalResult({ type: 'error', error: windowsStartCancelledError() }, 0)
       return
     }
     try {
@@ -325,7 +318,7 @@ class WindowsJobRunner {
       if (applicationName === undefined) {
         await this.publishTerminalResult({
           type: 'error',
-          error: windowsPathNotFoundError(command as string, args),
+          error: windowsPathNotFoundError(command as string),
         }, 0)
         return
       }
@@ -351,7 +344,7 @@ class WindowsJobRunner {
       if (this.jobHandle === undefined && error instanceof Win32Error && error.api === 'CreateProcessW') {
         await this.publishTerminalResult({
           type: 'error',
-          error: asSpawnError(error, this.argv[0] as string, this.argv.slice(1), this.internals),
+          error: asSpawnError(error, this.argv[0] as string, this.internals),
         }, 0)
         return
       }

+ 3 - 1
packages/subprocess/subprocess-local/src/spawn.ts

@@ -61,6 +61,8 @@ export interface SpawnInternals {
   linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined
   /** Test seam for the per-spawn Linux native prerequisite check. */
   linuxNativeAvailable?: () => boolean
+  /** Test seam for the lightweight Linux user-manager reachability check. */
+  linuxManagerAvailable?: () => boolean
   /** Test seam for the per-spawn Windows native prerequisite check. */
   windowsNativeAvailable?: () => boolean
 }
@@ -342,7 +344,7 @@ export function validateSubprocessSpec(spec: SubprocessSpawnSpec): void {
     throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
   }
   if (spec.signal?.aborted) {
-    throw spec.signal.reason
+    throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
   }
   const [program] = spec.argv
   if (program === undefined || program.length === 0) {

+ 26 - 18
packages/subprocess/subprocess-local/src/windows-job.ts

@@ -13,8 +13,8 @@ import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts
 import {
   deserializeRunnerError,
   parseWindowsRunnerResult,
+  WINDOWS_START_CANCELLED_CODE,
 } from './runner-protocol.ts'
-import type { WindowsStartRequest } from './runner-protocol.ts'
 import {
   runnerEnvironment,
   runnerInvocationAvailable,
@@ -65,7 +65,6 @@ class WindowsJobOwner implements BoundProcessOwner {
   constructor(
     private readonly runner: RunnerProcess,
     private readonly exited: Promise<void>,
-    private readonly directResultSeen: () => boolean,
     private readonly failInfrastructure: (error: unknown) => void,
   ) {
     void this.exited.catch(() => {})
@@ -80,7 +79,7 @@ class WindowsJobOwner implements BoundProcessOwner {
     this.terminationSent = true
     try {
       this.runner.send?.({ type: 'terminate' }, (error) => {
-        if (error === null || this.directResultSeen() || !this.runner.connected) return
+        if (error === null) return
         this.failInfrastructure(error)
         this.terminateForHostExit()
       })
@@ -139,6 +138,8 @@ export function launchWindowsJob(
   const direct = Promise.withResolvers<SubprocessOutcome>()
   const rangeExit = Promise.withResolvers<void>()
   let resultSeen = false
+  let runnerSpawned = false
+  let runnerNeverCreated = false
   const failInfrastructure = (error: unknown): void => {
     direct.reject(error)
     rangeExit.reject(error)
@@ -147,7 +148,6 @@ export function launchWindowsJob(
   const owner = new WindowsJobOwner(
     child,
     rangeExit.promise,
-    () => resultSeen,
     failInfrastructure,
   )
   child.on('message', (value: unknown) => {
@@ -168,16 +168,37 @@ export function launchWindowsJob(
     resultSeen = true
     if (result.type === 'target-exit') {
       direct.resolve({ exitCode: result.exitCode, signal: null })
-    } else if (result.type === 'start-cancelled') {
+    } else if (result.error.code === WINDOWS_START_CANCELLED_CODE) {
       direct.reject(owner.startCancellationReason())
     } else {
       direct.reject(deserializeRunnerError(result.error))
     }
   })
+  child.once('spawn', () => {
+    runnerSpawned = true
+    try {
+      if (child.send === undefined) throw new Error('subprocess-local: Windows runner has no IPC channel')
+      child.send({ type: 'start', cwd: spec.cwd, env: targetEnv }, (error) => {
+        if (error === null) return
+        failInfrastructure(error)
+        owner.terminateForHostExit()
+      })
+    } catch (error) {
+      failInfrastructure(error)
+      owner.terminateForHostExit()
+    }
+  })
   child.once('error', (error) => {
+    if (!runnerSpawned) {
+      runnerNeverCreated = true
+      direct.reject(error)
+      rangeExit.resolve()
+      return
+    }
     failInfrastructure(error)
   })
   child.once('close', (exitCode, signal) => {
+    if (runnerNeverCreated) return
     const clean = exitCode === 0 && signal === null && resultSeen
     if (clean) {
       rangeExit.resolve()
@@ -194,19 +215,6 @@ export function launchWindowsJob(
     failInfrastructure(error)
   })
 
-  const start: WindowsStartRequest = { type: 'start', cwd: spec.cwd, env: targetEnv }
-  try {
-    if (child.send === undefined) throw new Error('subprocess-local: Windows runner has no IPC channel')
-    child.send(start, (error) => {
-      if (error === null) return
-      failInfrastructure(error)
-      owner.terminateForHostExit()
-    })
-  } catch (error) {
-    failInfrastructure(error)
-    owner.terminateForHostExit()
-  }
-
   return {
     stdin: spec.stdio.stdin === 'ignore' ? null : targetStdin,
     stdout: child.stdio[5] as Readable | null,

+ 105 - 8
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -6,6 +6,7 @@ import {
   launchLinuxScope,
   prepareLinuxTerminalScope,
   probeLinuxBootstrap,
+  probeLinuxManager,
   probeLinuxNative,
   probeLinuxScope,
 } from '../src/linux-scope.ts'
@@ -71,7 +72,15 @@ function missingUnit() {
 }
 
 function activeUnit(state = 'active') {
-  return { status: 0, stdout: `${state}\n`, stderr: '' }
+  return { status: 0, stdout: `LoadState=loaded\nActiveState=${state}\n`, stderr: '' }
+}
+
+function unloadedUnit() {
+  return { status: 0, stdout: 'LoadState=not-found\nActiveState=inactive\n', stderr: '' }
+}
+
+function unitState(loadState: string, activeState: string) {
+  return { status: 0, stdout: `LoadState=${loadState}\nActiveState=${activeState}\n`, stderr: '' }
 }
 
 function spec() {
@@ -155,17 +164,40 @@ describe('Linux native capability selection', () => {
   it('uses the default command adapters and runner resolution', () => {
     childProcessMocks.spawnSync.mockReturnValue({ status: 0, error: undefined })
     expect(probeLinuxScope()).toBe(true)
-    expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(1)
+    expect(probeLinuxManager()).toBe(true)
+    expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2)
     expect(probeLinuxBootstrap({ loadLinuxExecve: () => vi.fn() as never })).toBe(true)
     expect(probeLinuxBootstrap({
       runnerInvocation: [process.execPath],
       runnerAvailable: () => true,
     })).toBe(process.platform !== 'win32')
   })
+
+  it('keeps quieting on the transient-scope probe but preserves manager diagnostics', () => {
+    const spawnSync = vi.fn((
+      _command: string,
+      _args: readonly string[],
+      _options: unknown,
+    ) => ({ status: 0, error: undefined }))
+    expect(probeLinuxScope({ spawnSync: spawnSync as never })).toBe(true)
+    expect(probeLinuxManager({ spawnSync: spawnSync as never })).toBe(true)
+    const scopeOptions = spawnSync.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv }
+    const managerOptions = spawnSync.mock.calls[1]?.[2] as { env: NodeJS.ProcessEnv }
+    expect(scopeOptions.env).toMatchObject({ LC_ALL: 'C', SYSTEMD_LOG_TARGET: 'null' })
+    expect(managerOptions.env).toMatchObject({ LC_ALL: 'C' })
+    expect(managerOptions.env).not.toHaveProperty('SYSTEMD_LOG_TARGET')
+
+    expect(probeLinuxManager({
+      spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as never,
+    })).toBe(false)
+    expect(probeLinuxManager({
+      spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never,
+    })).toBe(false)
+  })
 })
 
 describe('Linux scope establishment and quiescence', () => {
-  it('does not mistake pre-establishment unit absence for quiescence and rejects after cancellation', async () => {
+  it('does not mistake pre-establishment unit absence for quiescence and settles an empty range after cancellation', async () => {
     const { child, result, requestPath, spawnSync } = launch(async () => missingUnit())
     const waiting = result.owner.waitForExit()
     result.owner.signal('SIGTERM')
@@ -176,13 +208,13 @@ describe('Linux scope establishment and quiescence', () => {
     const direct = expect(result.direct).rejects.toThrow('before its bootstrap consumed')
     child.exit(null, 'SIGTERM')
     await direct
-    await expect(waiting).rejects.toThrow('ended before consuming its launch request')
+    await expect(waiting).resolves.toBeUndefined()
     expect(existsSync(requestPath)).toBe(true)
     result.owner.cleanup?.()
   })
 
   it('accepts request consumption followed by rapid --collect unload as stopped', async () => {
-    const states = [activeUnit(), missingUnit()]
+    const states = [activeUnit(), unloadedUnit()]
     const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit())
     expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
     const waiting = result.owner.waitForExit()
@@ -233,6 +265,17 @@ describe('Linux scope establishment and quiescence', () => {
     result.owner.cleanup?.()
   })
 
+  it('treats status-zero not-found as pending until the direct launcher proves the range was never created', async () => {
+    const state: { child?: FakeChild } = {}
+    const launched = launch(async () => unloadedUnit(), {
+      sleep: async () => { state.child?.exit(127, null) },
+    })
+    state.child = launched.child
+    await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
+    await expect(launched.result.direct).rejects.toThrow('before its bootstrap consumed')
+    launched.result.owner.cleanup?.()
+  })
+
   it('polls promptly before establishment and backs off established active scopes', async () => {
     const delays: number[] = []
     const states = [
@@ -257,11 +300,45 @@ describe('Linux scope establishment and quiescence', () => {
     launched.result.owner.cleanup?.()
   })
 
-  it('reports child termination before request consumption to both result and wait', async () => {
+  it('keeps reloading scopes active and lets terminate wake a backed-off observation', async () => {
+    const states = [activeUnit('reloading'), activeUnit('inactive')]
+    const sleeping = Promise.withResolvers<undefined>()
+    const sleep = vi.fn(async () => {
+      sleeping.resolve(undefined)
+      await new Promise<void>(() => {})
+    })
+    const launched = launch(async () => states.shift() ?? activeUnit('inactive'), { sleep })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    const waiting = launched.result.owner.waitForExit()
+    await sleeping.promise
+    launched.result.owner.signal('SIGTERM')
+    await expect(waiting).resolves.toBeUndefined()
+    expect(sleep).toHaveBeenCalledExactlyOnceWith(50)
+    expect(launched.spawnSync).toHaveBeenCalledOnce()
+    launched.result.owner.cleanup?.()
+  })
+
+  it('skips the next poll delay when terminate arrives during a manager query', async () => {
+    const firstQuery = Promise.withResolvers<ReturnType<typeof activeUnit>>()
+    const query = vi.fn()
+      .mockImplementationOnce(async () => await firstQuery.promise)
+      .mockResolvedValueOnce(activeUnit('inactive'))
+    const sleep = vi.fn(async () => {})
+    const launched = launch(query, { sleep })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    const waiting = launched.result.owner.waitForExit()
+    launched.result.owner.signal('SIGTERM')
+    firstQuery.resolve(activeUnit())
+    await expect(waiting).resolves.toBeUndefined()
+    expect(sleep).not.toHaveBeenCalled()
+    launched.result.owner.cleanup?.()
+  })
+
+  it('reports child termination before request consumption to the direct result and settles the empty range', async () => {
     const { child, result } = launch(async () => missingUnit())
     child.exit(127, null)
     await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
-    await expect(result.owner.waitForExit()).rejects.toThrow('ended before consuming its launch request')
+    await expect(result.owner.waitForExit()).resolves.toBeUndefined()
     result.owner.cleanup?.()
   })
 
@@ -292,6 +369,10 @@ describe('Linux scope establishment and quiescence', () => {
     await expect(unknown.result.owner.waitForExit()).rejects.toThrow('unknown ActiveState')
     unknown.result.owner.cleanup?.()
 
+    const unknownLoad = launch(async () => unitState('masked', 'inactive'))
+    await expect(unknownLoad.result.owner.waitForExit()).rejects.toThrow('unknown state')
+    unknownLoad.result.owner.cleanup?.()
+
     const killFailed = launch(async () => activeUnit(), {
       spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
     })
@@ -303,7 +384,10 @@ describe('Linux scope establishment and quiescence', () => {
   it('reports command-query failures from the default systemctl adapter', async () => {
     childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
       const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void
-      callback(null, 'inactive\n', '')
+      const options = args[2] as { env: NodeJS.ProcessEnv }
+      expect(options.env).toMatchObject({ LC_ALL: 'C' })
+      expect(options.env).not.toHaveProperty('SYSTEMD_LOG_TARGET')
+      callback(null, 'LoadState=loaded\nActiveState=inactive\n', 'manager diagnostic remains readable')
       return new EventEmitter()
     })
     const stopped = launch(undefined)
@@ -321,6 +405,19 @@ describe('Linux scope establishment and quiescence', () => {
     failed.result.owner.cleanup?.()
   })
 
+  it('rejects malformed, duplicate, incomplete, and extra manager state fields', async () => {
+    for (const [stdout, message] of [
+      ['loaded\nActiveState=active\n', 'malformed state'],
+      ['LoadState=loaded\nLoadState=loaded\nActiveState=active\n', 'duplicate LoadState'],
+      ['LoadState=loaded\n', 'incomplete state'],
+      ['LoadState=loaded\nActiveState=inactive\nOther=value\n', 'incomplete state'],
+    ] as const) {
+      const launched = launch(async () => ({ status: 0, stdout, stderr: '' }))
+      await expect(launched.result.owner.waitForExit()).rejects.toThrow(message)
+      launched.result.owner.cleanup?.()
+    }
+  })
+
   it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => {
     const spawnSync = vi.fn()
       .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })

+ 32 - 4
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -448,6 +448,7 @@ describe('LocalSubprocessRuntime', () => {
       cleanup: vi.fn(),
     }))
     const probeLinuxNative = vi.fn(() => true)
+    const probeLinuxManager = vi.fn(() => true)
     const inspector = {
       foregroundPgid: () => undefined,
       isStdinWaiting: () => false,
@@ -467,6 +468,7 @@ describe('LocalSubprocessRuntime', () => {
     vi.doMock('../src/linux-scope.ts', () => ({
       launchLinuxScope: vi.fn(),
       prepareLinuxTerminalScope,
+      probeLinuxManager,
       probeLinuxNative,
     }))
     let fiber: { dispose(): Promise<void> } | undefined
@@ -545,6 +547,7 @@ describe('LocalSubprocessRuntime', () => {
     vi.doMock('../src/linux-scope.ts', () => ({
       launchLinuxScope: vi.fn(),
       prepareLinuxTerminalScope,
+      probeLinuxManager: () => true,
       probeLinuxNative: () => true,
     }))
     let fiber: { dispose(): Promise<void> } | undefined
@@ -653,7 +656,6 @@ describe('LocalSubprocessRuntime', () => {
   it('reports the platform-specific reason for every fallback mode', async () => {
     for (const [platform, kind, reason, selectedReason] of [
       ['darwin', 'ordinary', 'macOS has no supported persistent process-range owner', undefined],
-      ['linux', 'terminal', 'a modern readable user-systemd scope is unavailable', undefined],
       ['linux', 'ordinary', 'the private Linux subprocess runner is unavailable', 'the private Linux subprocess runner is unavailable'],
       ['win32', 'ordinary', 'the Win32 Job runner is unavailable', undefined],
       ['win32', 'terminal', 'Windows ConPTY remains outside Job containment', undefined],
@@ -677,12 +679,33 @@ describe('LocalSubprocessRuntime', () => {
     }
   })
 
+  it('reports Linux capability failure through the real selector path', async () => {
+    const ctx = new Context()
+    const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    const fiber = await ctx.plugin(LocalSubprocessRuntime)
+    const runtime = ctx.subprocess as LocalSubprocessRuntime
+    runtime.internals = { platform: 'linux', linuxNativeAvailable: () => false }
+    try {
+      const select = (runtime as unknown as {
+        selectContainmentMode(kind: 'ordinary' | 'terminal'): 'linux-scope' | 'windows-job' | 'fallback'
+      }).selectContainmentMode.bind(runtime)
+      expect(select('terminal')).toBe('fallback')
+      expect(warning).toHaveBeenCalledWith(expect.stringContaining(
+        'the current user-systemd scope or private bootstrap is unavailable',
+      ))
+    } finally {
+      warning.mockRestore()
+      await fiber.dispose()
+    }
+  })
+
   it('rechecks native prerequisites for every eligible spawn and prepares storage before launch', async () => {
     const linuxLaunch = { kind: 'linux' }
     const windowsLaunch = { kind: 'windows' }
     const launchLinuxScope = vi.fn(() => linuxLaunch)
     const launchWindowsJob = vi.fn(() => windowsLaunch)
     const probeLinuxNative = vi.fn(() => true)
+    const probeLinuxManager = vi.fn(() => true)
     const probeWindowsJob = vi.fn(() => true)
     const prepareManagedProcessBinding = vi.fn(() => ({ spillDir: '/tmp/dsh-test-spill' }))
     const handles = [true, false, false].map((failFirstWait) => {
@@ -711,6 +734,7 @@ describe('LocalSubprocessRuntime', () => {
     vi.doMock('../src/linux-scope.ts', () => ({
       launchLinuxScope,
       prepareLinuxTerminalScope: vi.fn(),
+      probeLinuxManager,
       probeLinuxNative,
     }))
     vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob, probeWindowsJob }))
@@ -736,7 +760,8 @@ describe('LocalSubprocessRuntime', () => {
       await new Promise(resolve => setImmediate(resolve))
       await linuxRuntime.spawn(spec('true')).done
       await new Promise(resolve => setImmediate(resolve))
-      expect(probeLinuxNative).toHaveBeenCalledTimes(3)
+      expect(probeLinuxNative).toHaveBeenCalledOnce()
+      expect(probeLinuxManager).toHaveBeenCalledTimes(2)
       expect(launchLinuxScope).toHaveBeenCalledTimes(2)
 
       const windowsContext = new Context()
@@ -765,12 +790,13 @@ describe('LocalSubprocessRuntime', () => {
     }
   })
 
-  it('does not cache failed or successful native capability probes', async () => {
+  it('retries failed Linux deep probes, caches the first success, and rechecks the manager', async () => {
     const probeLinuxNative = vi.fn()
       .mockReturnValueOnce(false)
       .mockReturnValueOnce(false)
       .mockReturnValueOnce(false)
       .mockReturnValueOnce(true)
+    const probeLinuxManager = vi.fn()
       .mockReturnValueOnce(false)
       .mockReturnValueOnce(true)
     const probeWindowsJob = vi.fn()
@@ -783,6 +809,7 @@ describe('LocalSubprocessRuntime', () => {
     vi.doMock('../src/linux-scope.ts', () => ({
       launchLinuxScope: vi.fn(),
       prepareLinuxTerminalScope: vi.fn(),
+      probeLinuxManager,
       probeLinuxNative,
     }))
     vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob: vi.fn(), probeWindowsJob }))
@@ -805,7 +832,8 @@ describe('LocalSubprocessRuntime', () => {
       expect(linuxSelect('ordinary')).toBe('linux-scope')
       expect(linuxSelect('ordinary')).toBe('fallback')
       expect(linuxSelect('ordinary')).toBe('linux-scope')
-      expect(probeLinuxNative).toHaveBeenCalledTimes(6)
+      expect(probeLinuxNative).toHaveBeenCalledTimes(4)
+      expect(probeLinuxManager).toHaveBeenCalledTimes(2)
 
       const windowsContext = new Context()
       vi.spyOn(windowsContext.logger, 'warn').mockImplementation(() => {})

+ 1 - 5
packages/subprocess/subprocess-local/tests/native-containment.spec.ts

@@ -26,7 +26,7 @@ function spec(argv: string[], graceMs = 100): SubprocessSpawnSpec {
   }
 }
 
-type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] }
+type SpawnFailure = NodeJS.ErrnoException & { path?: string }
 
 function directSpawnFailure(argv: readonly string[]): Promise<SpawnFailure> {
   return new Promise((resolve, reject) => {
@@ -165,10 +165,8 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => {
       name: expectedMissing.name,
       message: expectedMissing.message,
       code: expectedMissing.code,
-      errno: expectedMissing.errno,
       syscall: expectedMissing.syscall,
       path: expectedMissing.path,
-      spawnargs: expectedMissing.spawnargs,
     })
 
     const deniedPath = join(scratch, `not-executable-${Date.now()}`)
@@ -182,10 +180,8 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => {
       name: expectedDenied.name,
       message: expectedDenied.message,
       code: expectedDenied.code,
-      errno: expectedDenied.errno,
       syscall: expectedDenied.syscall,
       path: expectedDenied.path,
-      spawnargs: expectedDenied.spawnargs,
     })
   })
 

+ 1 - 3
packages/subprocess/subprocess-local/tests/native-windows.spec.ts

@@ -56,18 +56,16 @@ function cleanup(pid: number): void {
   spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
 }
 
-type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] }
+type SpawnFailure = NodeJS.ErrnoException & { path?: string }
 
 function expectedSpawnFailure(error: SpawnFailure): Record<string, unknown> {
   const expected: Record<string, unknown> = {
     name: error.name,
     message: error.message,
     code: error.code,
-    errno: error.errno,
     syscall: error.syscall,
   }
   if (Object.hasOwn(error, 'path')) expected.path = error.path
-  if (Object.hasOwn(error, 'spawnargs')) expected.spawnargs = error.spawnargs
   return expected
 }
 

+ 6 - 2
packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts

@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'
 import type { Buffer } from 'node:buffer'
 import { existsSync } from 'node:fs'
 import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import {
@@ -10,6 +11,7 @@ import {
 } from '../src/runner-protocol.ts'
 import {
   runnerEnvironment,
+  runnerInvocationAvailable,
   SUBPROCESS_RUNNER_ENV,
   targetEnvironment,
 } from '../src/runner-launch.ts'
@@ -19,7 +21,7 @@ import { launchWindowsJob } from '../src/windows-job.ts'
 
 const repoRoot = resolve(import.meta.dirname, '../../../..')
 const sourceRunner = resolve(repoRoot, 'packages/subprocess/subprocess-local/src/bin.ts')
-const builtRunner = resolve(repoRoot, 'packages/subprocess/subprocess-local/lib/runner.js')
+const builtRunner = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/runner'))
 
 function targetEnv(): Record<string, string> {
   return {
@@ -102,7 +104,9 @@ describe('subprocess-local runner artifacts', () => {
   }, 30_000)
 
   it.skipIf(!existsSync(builtRunner))('executes the built ./runner subpath through the same core', async () => {
-    const result = await execute([process.execPath, builtRunner])
+    const invocation: RunnerInvocation = [process.execPath, builtRunner]
+    expect(runnerInvocationAvailable(invocation)).toBe(true)
+    const result = await execute(invocation)
     expect(result).toEqual({
       status: 0,
       stdout: `${process.execPath}|${repoRoot}|target-collision-restored`,

+ 40 - 31
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -30,6 +30,7 @@ import {
   parseWindowsStartRequest,
   readLinuxStartupError,
   serializeRunnerError,
+  WINDOWS_START_CANCELLED_CODE,
   writeLinuxStartupError,
 } from '../src/runner-protocol.ts'
 import {
@@ -148,9 +149,14 @@ describe('closed runner protocol', () => {
       expect(statSync(files.startupErrorPath).mode & 0o777).toBe(0o600)
     }
     const result = readLinuxStartupError(files.startupErrorPath)
-    expect(result).toMatchObject({ type: 'error', error: { code: 'ENOENT', path: 'tool', spawnargs: ['x'] } })
+    expect(result).toEqual({
+      type: 'error',
+      error: {
+        name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', syscall: 'spawn tool', path: 'tool',
+      },
+    })
     expect(deserializeRunnerError(result!.error)).toMatchObject({
-      name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', errno: -2,
+      name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', syscall: 'spawn tool', path: 'tool',
     })
     writeFileSync(join(files.directory, '.startup-error.tmp'), 'incomplete')
     cleanupLinuxLaunchFiles(files)
@@ -194,7 +200,6 @@ describe('closed runner protocol', () => {
     expect(() => parseWindowsStartRequest({ type: 'start', cwd: 'C:\\x', env: {}, extra: 1 })).toThrow()
     expect(isWindowsTerminateRequest({ type: 'terminate' })).toBe(true)
     expect(isWindowsTerminateRequest({ type: 'terminate', reason: 'no' })).toBe(false)
-    expect(parseWindowsRunnerResult({ type: 'start-cancelled' })).toEqual({ type: 'start-cancelled' })
     expect(parseWindowsRunnerResult({ type: 'target-exit', exitCode: 7 })).toEqual({
       type: 'target-exit', exitCode: 7,
     })
@@ -204,6 +209,7 @@ describe('closed runner protocol', () => {
     for (const invalid of [
       null,
       { type: 'unknown' },
+      { type: 'start-cancelled' },
       { type: 'start-cancelled', payload: 1 },
       { type: 'target-exit', exitCode: -1 },
       { type: 'target-exit', exitCode: 0, signal: null },
@@ -328,20 +334,21 @@ describe('runner launch inputs', () => {
     expect(launched.stderr).not.toContain('ERR_MODULE_NOT_FOUND')
   })
 
-  it('bounds non-Error and stackless runner failures', () => {
+  it('serializes only the private protocol diagnostic fields', () => {
     expect(serializeRunnerError('plain failure')).toMatchObject({
       name: 'Error', message: 'plain failure',
     })
-    const stackless = new Error('stackless')
-    Reflect.deleteProperty(stackless, 'stack')
-    expect(serializeRunnerError(stackless)).toEqual({ name: 'Error', message: 'stackless' })
+    const detailed = Object.assign(new Error('detailed'), {
+      code: 'ENOENT', errno: -2, syscall: 'spawn tool', path: 'tool', spawnargs: ['arg'],
+    })
+    expect(serializeRunnerError(detailed)).toEqual({
+      name: 'Error', message: 'detailed', code: 'ENOENT', syscall: 'spawn tool', path: 'tool',
+    })
     const minimal = deserializeRunnerError({ name: 'Error', message: 'minimal' })
     expect(minimal).toMatchObject({ name: 'Error', message: 'minimal' })
     expect(minimal).not.toHaveProperty('code')
-    expect(minimal).not.toHaveProperty('errno')
     expect(minimal).not.toHaveProperty('syscall')
     expect(minimal).not.toHaveProperty('path')
-    expect(minimal).not.toHaveProperty('spawnargs')
   })
 
   it('resolves Windows executables with target-cwd and PATH search semantics', () => {
@@ -445,10 +452,8 @@ describe('Linux one-shot exec bootstrap', () => {
         name: 'Error',
         message: 'spawn tool ENOENT',
         code: 'ENOENT',
-        errno: -2,
         syscall: 'spawn tool',
         path: 'tool',
-        spawnargs: ['literal arg'],
       },
     })
   })
@@ -528,17 +533,15 @@ describe('Linux one-shot exec bootstrap', () => {
       throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES', errno: -13 })
     })
     await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve }))
-    expect(execve.mock.calls.map(call => call[0])).toEqual(['/usr/bin/tool', '/bin/tool'])
+    expect(execve.mock.calls.map(call => call[0])).toEqual(['/bin/tool', '/usr/bin/tool'])
     expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({
       type: 'error',
       error: {
         name: 'Error',
         message: 'spawn tool EACCES',
         code: 'EACCES',
-        errno: -13,
         syscall: 'spawn tool',
         path: 'tool',
-        spawnargs: [],
       },
     })
 
@@ -598,10 +601,8 @@ describe('Windows Job runner protocol owner', () => {
         name: 'Error',
         message: 'spawn tool.exe ENOENT',
         code: 'ENOENT',
-        errno: -4058,
         syscall: 'spawn tool.exe',
         path: 'tool.exe',
-        spawnargs: ['literal arg'],
       },
     }])
     expect(host.exitCode).toBe(0)
@@ -634,18 +635,15 @@ describe('Windows Job runner protocol owner', () => {
           name: 'Error',
           message: `${syscall} ${code}`,
           code,
-          errno,
           syscall,
         },
       }])
       const result = parseWindowsRunnerResult(host.sent[0])
       if (result.type !== 'error') throw new Error('expected runner error')
-      expect(result.error.stack?.split('\n')[0]).toBe(`Error: ${syscall} ${code}`)
       if (enriched) {
-        expect(result.error).toMatchObject({ path: program, spawnargs: ['literal arg'] })
+        expect(result.error).toMatchObject({ path: program })
       } else {
         expect(result.error).not.toHaveProperty('path')
-        expect(result.error).not.toHaveProperty('spawnargs')
       }
     }
   })
@@ -661,17 +659,15 @@ describe('Windows Job runner protocol owner', () => {
       type: 'error',
       error: {
         code: 'ENOENT',
-        errno: process.platform === 'win32' ? -4058 : -2,
         path: 'tool.exe',
-        spawnargs: ['literal arg'],
       },
     }])
   })
 
   it.skipIf(process.platform !== 'win32')('preserves native EMFILE and UNKNOWN translations', async () => {
-    for (const [win32Code, code, errno, enriched] of [
-      [4, 'EMFILE', -4066, true],
-      [999, 'UNKNOWN', -4094, false],
+    for (const [win32Code, code, enriched] of [
+      [4, 'EMFILE', true],
+      [999, 'UNKNOWN', false],
     ] as const) {
       const host = new FakeRunnerHost()
       const native = internals({
@@ -681,15 +677,14 @@ describe('Windows Job runner protocol owner', () => {
       await runWindows(host, native)
       expect(host.sent).toMatchObject([{
         type: 'error',
-        error: { code, errno },
+        error: { code },
       }])
       const result = parseWindowsRunnerResult(host.sent[0])
       if (result.type !== 'error') throw new Error('expected runner error')
       if (enriched) {
-        expect(result.error).toMatchObject({ path: 'tool.exe', spawnargs: ['literal arg'] })
+        expect(result.error).toMatchObject({ path: 'tool.exe' })
       } else {
         expect(result.error).not.toHaveProperty('path')
-        expect(result.error).not.toHaveProperty('spawnargs')
       }
     }
   })
@@ -769,7 +764,7 @@ describe('Windows Job runner protocol owner', () => {
     }
   })
 
-  it('exhausts target-exit, error, and payload-free start-cancelled', async () => {
+  it('exhausts target-exit and strict error results, including start cancellation', async () => {
     const spawnHost = new FakeRunnerHost()
     await runWindows(spawnHost, internals({
       spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', 2) }),
@@ -790,7 +785,14 @@ describe('Windows Job runner protocol owner', () => {
     cancelledHost.emit('message', { type: 'terminate' })
     cancelledHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
     await running
-    expect(cancelledHost.sent).toEqual([{ type: 'start-cancelled' }])
+    expect(cancelledHost.sent).toEqual([{
+      type: 'error',
+      error: {
+        name: 'Error',
+        message: 'subprocess target start was cancelled',
+        code: WINDOWS_START_CANCELLED_CODE,
+      },
+    }])
     expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
   })
 
@@ -801,7 +803,14 @@ describe('Windows Job runner protocol owner', () => {
     host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
     host.emit('message', { type: 'terminate' })
     await running
-    expect(host.sent).toEqual([{ type: 'start-cancelled' }])
+    expect(host.sent).toEqual([{
+      type: 'error',
+      error: {
+        name: 'Error',
+        message: 'subprocess target start was cancelled',
+        code: WINDOWS_START_CANCELLED_CODE,
+      },
+    }])
     expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
   })
 

+ 10 - 19
packages/subprocess/subprocess-local/tests/spawn.spec.ts

@@ -313,18 +313,15 @@ describe('spawnSubprocess', () => {
     expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
   })
 
-  it('throws the raw signal reason when already aborted before spawn', () => {
-    for (const reason of ['too late', null] as const) {
+  it('throws a stable Error when already aborted before spawn', () => {
+    for (const [reason, message] of [
+      ['too late', 'aborted before spawn: too late'],
+      [null, 'aborted before spawn: aborted'],
+    ] as const) {
       const controller = new AbortController()
       controller.abort(reason)
-      let thrown = false
-      try {
-        validateSubprocessSpec(spec('echo hi', { signal: controller.signal }))
-      } catch (error) {
-        thrown = true
-        expect(error).toBe(reason)
-      }
-      expect(thrown).toBe(true)
+      expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) })
+        .toThrow(new Error(message))
     }
   })
 
@@ -1276,21 +1273,15 @@ describe('argv validation', () => {
 })
 
 describe('abort edge cases', () => {
-  it('throws an undefined reason from a reason-less pre-aborted signal unchanged', () => {
+  it('uses a stable fallback for a reason-less pre-aborted signal', () => {
     const bare = {
       aborted: true,
       reason: undefined,
       addEventListener() {},
       removeEventListener() {},
     } as unknown as AbortSignal
-    let thrown = false
-    try {
-      validateSubprocessSpec(spec('echo hi', { signal: bare }))
-    } catch (error) {
-      thrown = true
-      expect(error).toBeUndefined()
-    }
-    expect(thrown).toBe(true)
+    expect(() => { validateSubprocessSpec(spec('echo hi', { signal: bare })) })
+      .toThrow(new Error('aborted before spawn: aborted'))
   })
 
   it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => {

+ 41 - 14
packages/subprocess/subprocess-local/tests/windows-job.spec.ts

@@ -7,6 +7,7 @@ import {
   probeWindowsJob,
 } from '../src/windows-job.ts'
 import { bindManagedProcess } from '../src/spawn.ts'
+import { WINDOWS_START_CANCELLED_CODE } from '../src/runner-protocol.ts'
 
 class FakeChild extends EventEmitter {
   pid: number | undefined = 432
@@ -60,12 +61,14 @@ const spec = {
 function launch(
   child = new FakeChild(),
   request: Parameters<typeof launchWindowsJob>[0] = spec,
+  emitSpawn = true,
 ) {
   const spawn = vi.fn((_command: string, _args: readonly string[], _options: unknown) => child)
   const result = launchWindowsJob(request, { TARGET: 'yes' }, {
     spawn: spawn as never,
     runnerInvocation: ['C:\\node.exe', 'C:\\runner.js'],
   })
+  if (emitSpawn) child.emit('spawn')
   return { child, result, spawn }
 }
 
@@ -92,6 +95,7 @@ describe('Windows Job capability', () => {
 
       const result = isolated.launchWindowsJob(spec, { TARGET: 'yes' })
       expect(spawn).toHaveBeenCalledOnce()
+      child.emit('spawn')
       child.emit('message', { type: 'target-exit', exitCode: 0 })
       child.connected = false
       child.emit('close', 0, null)
@@ -224,7 +228,12 @@ describe('Windows parent runner contract', () => {
     const reason = new Error('caller aborted')
     cancelled.result.owner.signal('SIGTERM', reason)
     expect(cancelled.child.sent.at(-1)).toEqual({ type: 'terminate' })
-    cancelled.child.emit('message', { type: 'start-cancelled' })
+    cancelled.child.emit('message', {
+      type: 'error',
+      error: {
+        name: 'Error', message: 'subprocess target start was cancelled', code: WINDOWS_START_CANCELLED_CODE,
+      },
+    })
     await expect(cancelled.result.direct).rejects.toBe(reason)
     cancelled.child.connected = false
     cancelled.child.emit('close', 0, null)
@@ -233,14 +242,24 @@ describe('Windows parent runner contract', () => {
     const nullCancelled = launch()
     nullCancelled.result.owner.signal('SIGTERM', null)
     nullCancelled.result.owner.signal('SIGKILL', new Error('later reason'))
-    nullCancelled.child.emit('message', { type: 'start-cancelled' })
+    nullCancelled.child.emit('message', {
+      type: 'error',
+      error: {
+        name: 'Error', message: 'subprocess target start was cancelled', code: WINDOWS_START_CANCELLED_CODE,
+      },
+    })
     await expect(nullCancelled.result.direct).rejects.toBeNull()
     nullCancelled.child.connected = false
     nullCancelled.child.emit('close', 0, null)
     await expect(nullCancelled.result.owner.waitForExit()).resolves.toBeUndefined()
 
     const implicit = launch()
-    implicit.child.emit('message', { type: 'start-cancelled' })
+    implicit.child.emit('message', {
+      type: 'error',
+      error: {
+        name: 'Error', message: 'subprocess target start was cancelled', code: WINDOWS_START_CANCELLED_CODE,
+      },
+    })
     await expect(implicit.result.direct).rejects.toThrow('target start was cancelled')
     implicit.child.connected = false
     implicit.child.emit('close', 0, null)
@@ -281,11 +300,18 @@ describe('Windows parent runner contract', () => {
     await expect(duplicate.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
     await expect(duplicate.result.owner.waitForExit()).rejects.toThrow('more than one direct result')
 
-    const errored = launch()
+    const errored = launch(new FakeChild(), spec, false)
     const spawnError = new Error('runner executable missing')
     errored.child.emit('error', spawnError)
     await expect(errored.result.direct).rejects.toBe(spawnError)
-    await expect(errored.result.owner.waitForExit()).rejects.toBe(spawnError)
+    await expect(errored.result.owner.waitForExit()).resolves.toBeUndefined()
+    errored.child.emit('close', 127, null)
+
+    const postSpawnError = launch()
+    const infrastructureError = new Error('runner failed after spawn')
+    postSpawnError.child.emit('error', infrastructureError)
+    await expect(postSpawnError.result.direct).rejects.toBe(infrastructureError)
+    await expect(postSpawnError.result.owner.waitForExit()).rejects.toBe(infrastructureError)
 
     const sendFailedChild = new FakeChild()
     sendFailedChild.sendError = new Error('IPC send failed')
@@ -334,25 +360,26 @@ describe('Windows parent runner contract', () => {
     await expect(error.result.owner.waitForExit()).rejects.toThrow('send threw')
   })
 
-  it('ignores a terminate callback error after a direct result while the runner is connected', async () => {
+  it('preserves a direct result but rejects range settlement when termination delivery later fails', async () => {
     const child = new FakeChild()
     const launched = launch(child)
     const handle = bindManagedProcess(spec, launched.result)
     await Promise.resolve()
     child.deferSendCallbacks = true
-    child.emit('message', {
-      type: 'error', error: { name: 'Error', message: 'target start failed', code: 'ENOENT' },
-    })
-    await expect(handle.done).rejects.toMatchObject({ code: 'ENOENT' })
+    child.emit('message', { type: 'target-exit', exitCode: 7 })
+    child.targetStdout.end()
+    child.targetStderr.end()
+    await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
+
+    launched.result.owner.signal('SIGTERM')
     expect(child.pendingSendCallbacks).toHaveLength(1)
 
     expect(child.connected).toBe(true)
     child.deliverNextSend(new Error('late EPIPE'))
     await Promise.resolve()
-    expect(child.killed).toEqual([])
-    child.connected = false
-    child.emit('close', 0, null)
-    await expect(handle.waitForExit()).resolves.toBe(true)
+    expect(child.killed).toEqual(['SIGKILL'])
+    await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
+    await expect(handle.waitForExit()).rejects.toThrow('late EPIPE')
   })
 
   it('uses synchronous runner termination for host exit and isolates repeated control', () => {

+ 2 - 1
packages/subprocess/subprocess/src/index.ts

@@ -127,13 +127,14 @@ export abstract class SubprocessRuntime extends Service {
    * applies no defaults.
    * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
    * @returns the live process handle (streams/readers, signalling, outcome promise).
+   * @throws synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.
    */
   abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
 
   /**
    * Allocate a real terminal and start one owned process session. This is the
    * only non-pipe process primitive: implementations own terminal byte I/O,
-   * foreground groups, signals, and complete session-tree cleanup.
+   * foreground groups, signals, and whole-session quiescence.
    * @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.
    * @returns the live terminal handle after allocation succeeds.
    */

+ 1 - 1
packages/subprocess/subprocess/src/types.ts

@@ -226,7 +226,7 @@ export interface SubprocessTerminalForeground {
 
 /**
  * One live terminal process and its owned OS session. Terminal allocation,
- * foreground-group inspection/signalling, and session-tree cleanup are one
+ * foreground-group inspection/signalling, and whole-session quiescence are one
  * deep subprocess primitive because none can be reconstructed from ordinary
  * piped stdio without substrate-specific process control.
  */

+ 9 - 1
packages/subprocess/win32-process/src/process.ts

@@ -357,6 +357,10 @@ interface ProcessStandardHandles {
   stderr: NativePtr
 }
 
+// Koffi exposes PVOID as an unsigned 64-bit bigint on supported Windows hosts.
+const UV_INVALID_OS_FILE_HANDLE = 0xffff_ffff_ffff_ffffn
+const UV_INVALID_FILE_DESCRIPTOR = 0xffff_ffff_ffff_fffen
+
 function inheritedStandardHandles(api: Win32ProcessBindings): ProcessStandardHandles {
   const get = (selector: number, label: string): NativePtr => {
     const handle = api.getStdHandle(selector)
@@ -376,7 +380,11 @@ function targetCarrierHandles(
 ): ProcessStandardHandles {
   const get = (fileDescriptor: number, label: string): NativePtr => {
     const handle = api.uvGetOsfhandle(fileDescriptor)
-    if (isNullPtr(handle) || handle === -1n || handle === -2n) {
+    if (
+      isNullPtr(handle)
+      || handle === UV_INVALID_OS_FILE_HANDLE
+      || handle === UV_INVALID_FILE_DESCRIPTOR
+    ) {
       throw new Error(`uv_get_osfhandle returned an invalid handle for target ${label} fd ${String(fileDescriptor)}`)
     }
     return handle

+ 3 - 1
packages/subprocess/win32-process/tests/ordinary-process.spec.ts

@@ -241,6 +241,8 @@ describe('ordinary Job process operations', () => {
       expect(createProcessW).not.toHaveBeenCalled()
     }
 
-    for (const invalid of [null, 0n, -1n, -2n]) expectFailure(invalid as NativePtr | null)
+    for (const invalid of [null, 0n, 0xffff_ffff_ffff_ffffn, 0xffff_ffff_ffff_fffen]) {
+      expectFailure(invalid as NativePtr | null)
+    }
   })
 })

File diff suppressed because it is too large
+ 1 - 1
snapshots/session/cordis-inspect-jsdoc/session.jsonl


Some files were not shown because too many files changed in this diff