Przeglądaj źródła

fix(subprocess): close managed launch gaps

pku-xht 3 tygodni temu
rodzic
commit
e6bc1f2e1d
25 zmienionych plików z 138 dodań i 69 usunięć
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml
  2. 1 1
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md
  3. 1 1
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md
  4. 2 2
      .agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml
  5. 2 2
      .agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md
  6. 2 2
      .agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md
  7. 2 2
      docs/subsystems/subprocess.i18n.yaml
  8. 10 10
      docs/subsystems/subprocess.md
  9. 10 10
      docs/subsystems/subprocess.zh.md
  10. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  11. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  12. 1 1
      packages/subprocess/subprocess-local/README.md
  13. 1 1
      packages/subprocess/subprocess-local/README.zh.md
  14. 15 6
      packages/subprocess/subprocess-local/src/index.ts
  15. 1 1
      packages/subprocess/subprocess-local/src/linux-scope.ts
  16. 14 2
      packages/subprocess/subprocess-local/src/spawn-runner.ts
  17. 14 2
      packages/subprocess/subprocess-local/src/spawn.ts
  18. 16 0
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  19. 8 1
      packages/subprocess/subprocess-local/tests/local.spec.ts
  20. 13 1
      packages/subprocess/subprocess-local/tests/native-windows.spec.ts
  21. 2 2
      packages/subprocess/subprocess/README.i18n.yaml
  22. 2 2
      packages/subprocess/subprocess/README.md
  23. 2 2
      packages/subprocess/subprocess/README.zh.md
  24. 5 4
      packages/subprocess/subprocess/src/index.ts
  25. 9 9
      packages/subprocess/subprocess/src/types.ts

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-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/bug-fix/2026-08-20-subprocess-native-containment.md
-2026-08-20-subprocess-native-containment.md: 45183c2c381da24433bfc20052a603beefd0afc5
-2026-08-20-subprocess-native-containment.zh.md: 4ead6b59502306117203b834b9151fc0fe2018b0
+2026-08-20-subprocess-native-containment.md: 18306a23f7e14ec0e06b7f96005473461dd390da
+2026-08-20-subprocess-native-containment.zh.md: 440c62cfc31402b4dfaba10e99d1ec7f770cc31b

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md

@@ -14,7 +14,7 @@ The local subprocess provider treated a POSIX process group or a Windows direct-
 
 The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, TERM-to-KILL escalation, and host-exit registration. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals.
 
-Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default, while the runner remains until both the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit.
+Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default; after target creation, the runner releases its own standard-handle copies before publishing startup, so pipe EOF follows the target and descendants that actually inherited the stream. The runner remains until the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit.
 
 When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、TERM-to-KILL 升级与 host-exit 注册。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。
 
-Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。
+Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;target 创建后,runner 会在发布启动事实前释放自身持有的标准句柄副本,因此 pipe EOF 取决于 target 与实际继承该流的 descendant。runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。
 
 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。
 

+ 2 - 2
.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.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/testing/2026-07-22-cross-platform-test-fixtures.md
-2026-07-22-cross-platform-test-fixtures.md: 9114481543d6cae1661cbed70868eddb2faa09fc
-2026-07-22-cross-platform-test-fixtures.zh.md: 710ec5887f02b5c3c71d69f16ab726323501ac92
+2026-07-22-cross-platform-test-fixtures.md: f2fdbcac1d19089fcb2bd7f9f02c21a27b96f020
+2026-07-22-cross-platform-test-fixtures.zh.md: c3975c82c05d9b92553b76ba1f7549fab3e94547

+ 2 - 2
.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md

@@ -16,7 +16,7 @@ Tests of platform-neutral behavior construct absolute paths and `file:` URIs wit
 
 Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles.
 
-Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows. Windows suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient.
+Language-server teardown delegates to the subprocess provider's managed range: supported local Linux uses a user-systemd scope and Windows uses a kill-on-close Job, while explicit fallbacks use a negative process-group id or synchronous `taskkill /T /F`. See the [ordinary subprocess native-containment decision](../bug-fix/2026-08-20-subprocess-native-containment.md). Windows fallback suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient.
 
 Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files.
 
@@ -30,4 +30,4 @@ Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on tha
 
 ## Consequences
 
-Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer.
+Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Supported native Windows hosts use Job ownership; fallback Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed. A successful synchronous fallback result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer.

+ 2 - 2
.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。
 
-语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。
+语言服务器的资源清理会委托给 subprocess provider 的 managed range:受支持的本地 Linux 使用 user-systemd scope,Windows 使用 kill-on-close Job;明确的 fallback 才使用负数进程组 ID 或同步 `taskkill /T /F`。参见[普通子进程 native containment 决策](../bug-fix/2026-08-20-subprocess-native-containment.zh.md)。Windows fallback 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。
 
 对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。
 
@@ -30,4 +30,4 @@ Status: implemented
 
 ## 后果
 
-可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,可确保 dispose(资源释放)在有限时间内完成,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,资源释放逻辑仍能观察到该失败。
+可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。受支持的原生 Windows 宿主使用 Job 所有权;fallback Windows 的资源清理则在协议级优雅关停失败后依赖宿主的 `taskkill` 命令。同步 fallback 成功时,可确保 dispose(资源释放)在有限时间内完成,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,资源释放逻辑仍能观察到该失败。

+ 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: bb73f045cda88d3dd46443e9376e4a1991a08a42
-subprocess.zh.md: 063c125eb3270c5a493a4894919ea58c1a3de024
+subprocess.md: 410bf5f25bcb8d15e826d66a0be1e1625cf524ce
+subprocess.zh.md: 16b207db40adf22e4b4e345c566fc29e63c4240e

+ 10 - 10
docs/subsystems/subprocess.md

@@ -135,7 +135,7 @@ A spawn returns a live handle synchronously after any provider-specific setup ne
 
 ```ts type-equiv
 /**
- * A live child process rooted in its own process tree. Collected output
+ * A live direct child and its provider-managed process range. Collected output
  * remains readable after exit; piped streams belong to the caller.
  *
  * Termination and {@link SubprocessHandle.waitForExit} use the same managed
@@ -143,7 +143,7 @@ A spawn returns a live handle synchronously after any provider-specific setup ne
  * weaker platform fallbacks are disclosed by the provider.
  */
 interface SubprocessHandle {
-  /** Process id (tree root); -1 when the spawn itself failed. */
+  /** Direct target process id; -1 when the spawn itself failed. */
   readonly pid: number
   /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */
   readonly stdin: Writable | undefined
@@ -156,17 +156,17 @@ interface SubprocessHandle {
   /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */
   readonly done: Promise<SubprocessOutcome>
   /**
-   * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
-   * (Windows force-terminates immediately) — the seam's only termination
-   * verb. Idempotent, a no-op once the tree is gone (the pid may be reused),
-   * and also triggered by the spec's abort signal.
+   * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the provider-managed
+   * range (Windows force-terminates immediately) — the seam's only termination
+   * verb. Idempotent, a no-op once that range is gone, and also triggered by
+   * the spec's abort signal.
    */
   terminate(): void
   /**
-   * Wait until the process tree has exited — the tree, not just the direct
-   * child, so a still-running helper is observable before teardown returns.
+   * Wait until the same managed range is empty — not just until the direct
+   * child exits, so a still-running helper is observable before teardown returns.
    * @param signal - optional bound for the wait.
-   * @returns `true` when the tree exited, `false` when the signal aborted first.
+   * @returns `true` when the managed range is empty, `false` when the signal aborted first.
    * @throws when the selected provider can no longer observe its managed range.
    */
   waitForExit(signal?: AbortSignal): Promise<boolean>
@@ -284,7 +284,7 @@ Implementations must honor these semantics:
 - Executable paths belong to one execution world shared with the mounted filesystem provider.
 - spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.
 - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.
-- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.
+- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. Supported local Linux and Windows providers use an OS-owned scope or Job; weaker fallbacks use a detached process group or direct-parent tree. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence.
 - Disposal of the service terminates all still-running managed processes and awaits their exit.
 - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.
 

+ 10 - 10
docs/subsystems/subprocess.zh.md

@@ -135,7 +135,7 @@ spawn 会在完成发布 target pid 所需的 provider-specific setup 后同步
 
 ```ts type-equiv
 /**
- * A live child process rooted in its own process tree. Collected output
+ * A live direct child and its provider-managed process range. Collected output
  * remains readable after exit; piped streams belong to the caller.
  *
  * Termination and {@link SubprocessHandle.waitForExit} use the same managed
@@ -143,7 +143,7 @@ spawn 会在完成发布 target pid 所需的 provider-specific setup 后同步
  * weaker platform fallbacks are disclosed by the provider.
  */
 interface SubprocessHandle {
-  /** Process id (tree root); -1 when the spawn itself failed. */
+  /** Direct target process id; -1 when the spawn itself failed. */
   readonly pid: number
   /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */
   readonly stdin: Writable | undefined
@@ -156,17 +156,17 @@ interface SubprocessHandle {
   /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */
   readonly done: Promise<SubprocessOutcome>
   /**
-   * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
-   * (Windows force-terminates immediately) — the seam's only termination
-   * verb. Idempotent, a no-op once the tree is gone (the pid may be reused),
-   * and also triggered by the spec's abort signal.
+   * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the provider-managed
+   * range (Windows force-terminates immediately) — the seam's only termination
+   * verb. Idempotent, a no-op once that range is gone, and also triggered by
+   * the spec's abort signal.
    */
   terminate(): void
   /**
-   * Wait until the process tree has exited — the tree, not just the direct
-   * child, so a still-running helper is observable before teardown returns.
+   * Wait until the same managed range is empty — not just until the direct
+   * child exits, so a still-running helper is observable before teardown returns.
    * @param signal - optional bound for the wait.
-   * @returns `true` when the tree exited, `false` when the signal aborted first.
+   * @returns `true` when the managed range is empty, `false` when the signal aborted first.
    * @throws when the selected provider can no longer observe its managed range.
    */
   waitForExit(signal?: AbortSignal): Promise<boolean>
@@ -284,7 +284,7 @@ Implementations must honor these semantics:
 - Executable paths belong to one execution world shared with the mounted filesystem provider.
 - spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.
 - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.
-- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.
+- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. Supported local Linux and Windows providers use an OS-owned scope or Job; weaker fallbacks use a detached process group or direct-parent tree. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence.
 - Disposal of the service terminates all still-running managed processes and awaits their exit.
 - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.
 

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

@@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'subprocess',
     summary: 'Abstract subprocess service.',
-    description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.',
+    description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider\'s managed range. Supported local Linux and Windows providers use an OS-owned scope or Job; weaker fallbacks use a detached process group or direct-parent tree. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.',
     methods: [
       {
         signature: 'abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>',

+ 2 - 2
packages/subprocess/subprocess-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
-README.md: 8cb0a4a6eae741c8073462ca4f861149e2a82597
-README.zh.md: e9726758761e969aa70feb595753ca37863783fc
+README.md: 4b89aaa6c85c14cb74d54993a013762b8a868bf0
+README.zh.md: 7f939e32627c42443507f0519786c812a426e5a5

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

@@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA
 
 ## Behavior
 
-- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. `terminate()` sends TERM then KILL through that owner, while `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, only collected pipes retain the existing bounded drain grace, and raw/inherited stdio does not delay direct settlement.
+- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. The Windows runner releases its own standard-handle copies before publishing target start, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than the Job observer's lifetime. `terminate()` sends TERM then KILL through the owner, while `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and only collected pipes retain the existing bounded drain grace.
 - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command.
 - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
 - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).

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

@@ -6,7 +6,7 @@
 
 ## 行为
 
-- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。`terminate()` 通过该 owner 发送 TERM 再发送 KILL;`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期,raw/inherited stdio 不会延迟 direct settlement
+- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。Windows runner 会在发布 target start 前释放自身持有的标准句柄副本,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observer 的生命周期。`terminate()` 通过该 owner 发送 TERM 再发送 KILL;`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。
 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。
 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。
 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。

+ 15 - 6
packages/subprocess/subprocess-local/src/index.ts

@@ -21,7 +21,13 @@ import type {
   SubprocessTerminalHandle,
   SubprocessTerminalSpawnSpec,
 } from '@deepseek-ai/dsh-subprocess'
-import { bindManagedProcess, childEnv, spawnSubprocess, validateSubprocessSpec } from './spawn.ts'
+import {
+  bindManagedProcess,
+  childEnv,
+  prepareManagedProcessBinding,
+  spawnSubprocess,
+  validateSubprocessSpec,
+} from './spawn.ts'
 import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'
 import { launchLinuxScope, probeLinuxScope } from './linux-scope.ts'
 import { launchWindowsJob, probeWindowsJob } from './windows-job.ts'
@@ -150,11 +156,14 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
   spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
     validateSubprocessSpec(spec)
     const mode = this.selectOrdinaryMode()
-    const handle = mode === 'linux-scope'
-      ? bindManagedProcess(spec, launchLinuxScope(spec), this.internals)
-      : mode === 'windows-job'
-        ? bindManagedProcess(spec, launchWindowsJob(spec), this.internals)
-        : spawnSubprocess(spec, this.internals)
+    let handle: LocalSubprocessHandle
+    if (mode === 'fallback') {
+      handle = spawnSubprocess(spec, this.internals)
+    } else {
+      const binding = prepareManagedProcessBinding(this.internals)
+      const launch = mode === 'linux-scope' ? launchLinuxScope(spec) : launchWindowsJob(spec)
+      handle = bindManagedProcess(spec, launch, binding)
+    }
     this.live.add(handle)
     // Release ownership only once the whole TREE is gone, not at direct-child
     // settlement — a TERM-trapping helper that outlives the leader must stay

+ 1 - 1
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -149,7 +149,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
     const output = `${result.stdout}\n${result.stderr}`
     if (result.status !== 0) {
       if (MISSING_UNIT.test(output)) {
-        if (this.runner.exitCode !== null || this.runner.signalCode !== null) return false
+        if (this.runner.pid === undefined || this.runner.exitCode !== null || this.runner.signalCode !== null) return false
       } else {
         if (result.error !== undefined) throw result.error
         throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`)

+ 14 - 2
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -1,6 +1,7 @@
 /** Native managed-range runner for ordinary local subprocesses. */
 
 import { spawn } from 'node:child_process'
+import { closeSync } from 'node:fs'
 import {
   closeHandleChecked,
   isJobEmpty,
@@ -96,6 +97,17 @@ function replaceEnvironment(env: Record<string, string>): void {
   Object.assign(process.env, env)
 }
 
+/** Release the runner's copies after the Windows target inherits its standard handles. */
+function releaseRunnerStdio(): void {
+  for (const fd of [0, 1, 2]) {
+    try {
+      closeSync(fd)
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error
+    }
+  }
+}
+
 async function runWin32(request: RunnerRequest, eventsPath: string): Promise<void> {
   replaceEnvironment(request.env)
   const api = loadWin32ProcessBindings()
@@ -114,8 +126,6 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise<voi
     processHandle = spawned.process
     jobHandle = spawned.job
     targetStarted = true
-    appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid })
-
     let terminationRequested = false
     const terminate = (): void => {
       if (terminationRequested || jobHandle === undefined) return
@@ -126,6 +136,8 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise<voi
       if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate()
     })
     process.on('disconnect', terminate)
+    releaseRunnerStdio()
+    appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid })
 
     await new Promise<void>((resolve, reject) => {
       const timer = setInterval(() => {

+ 14 - 2
packages/subprocess/subprocess-local/src/spawn.ts

@@ -93,6 +93,17 @@ function privateSpillDir(): string {
   return defaultSpillDir
 }
 
+/**
+ * Prepare fallible output storage before starting a managed native process.
+ * @param internals - optional caller-owned spill directory.
+ * @returns binding inputs whose spill directory is ready for use.
+ */
+export function prepareManagedProcessBinding(
+  internals: Pick<SpawnInternals, 'spillDir'> = {},
+): { spillDir: string } {
+  return { spillDir: internals.spillDir ?? privateSpillDir() }
+}
+
 /**
  * Collects one stream with a bounded in-memory tail. With a spill cap, on
  * first overflow a spill file is created and every chunk (including those
@@ -420,7 +431,7 @@ export function bindManagedProcess(
   internals: Pick<SpawnInternals, 'spillDir'> = {},
 ): LocalSubprocessHandle {
   validateSubprocessSpec(spec)
-  const spillDir = internals.spillDir ?? privateSpillDir()
+  const { spillDir } = prepareManagedProcessBinding(internals)
   const child = launch.child
 
   const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect =>
@@ -564,6 +575,7 @@ export function bindManagedProcess(
  */
 export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle {
   validateSubprocessSpec(spec)
+  const binding = prepareManagedProcessBinding(internals)
   const platform = internals.platform ?? process.platform
   const [program, ...args] = spec.argv
   const child = spawn(program as string, args, {
@@ -587,5 +599,5 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
     internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers,
     direct,
   )
-  return bindManagedProcess(spec, { child, pid, direct, closed, owner }, internals)
+  return bindManagedProcess(spec, { child, pid, direct, closed, owner }, binding)
 }

+ 16 - 0
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -245,6 +245,22 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     expect(runSyncMock.mock.calls.length).toBeGreaterThan(1)
   })
 
+  it('settles a missing scope immediately when the wrapper never started', async () => {
+    const launch = launchLinuxScope(spec([process.execPath, '-e', '']), {
+      systemdRun: `missing-systemd-run-${String(process.pid)}-${String(Date.now())}`,
+      systemctlQuery: async () => ({
+        status: 1,
+        stdout: '',
+        stderr: 'Unit dsh-subprocess-missing.scope could not be found',
+      }),
+      runnerInvocation: spawnRunnerInvocation(),
+    })
+    expect(launch.child.pid).toBeUndefined()
+    await expect(launch.direct).rejects.toThrow('runner failed to start')
+    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await launch.closed
+  })
+
   it('does not fabricate a direct outcome after a non-forced scope signal', async () => {
     let wrapper: ReturnType<typeof spawn> | undefined
     const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {

+ 8 - 1
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -452,6 +452,7 @@ describe('LocalSubprocessRuntime', () => {
     const launchWindowsJob = vi.fn(() => windowsLaunch)
     const probeLinuxScope = vi.fn(() => true)
     const probeWindowsJob = vi.fn(() => true)
+    const prepareManagedProcessBinding = vi.fn(() => ({ spillDir: '/tmp/dsh-test-spill' }))
     let nextPid = 100
     const handles = [true, false, false].map((failFirstWait) => {
       let waits = 0
@@ -468,7 +469,7 @@ describe('LocalSubprocessRuntime', () => {
         }),
       }
     })
-    const bindManagedProcess = vi.fn((_spec: unknown, _launch: unknown) => {
+    const bindManagedProcess = vi.fn((_spec: unknown, _launch: unknown, _binding: unknown) => {
       const handle = handles.shift()
       if (handle === undefined) throw new Error('missing fake handle')
       return handle
@@ -481,6 +482,7 @@ describe('LocalSubprocessRuntime', () => {
     vi.doMock('../src/spawn.ts', async importOriginal => ({
       ...await importOriginal<typeof import('../src/spawn.ts')>(),
       bindManagedProcess,
+      prepareManagedProcessBinding,
       spawnSubprocess,
     }))
     const fibers: Array<{ dispose(): Promise<void> }> = []
@@ -491,6 +493,10 @@ describe('LocalSubprocessRuntime', () => {
       fibers.push(linuxFiber)
       const linuxRuntime = linuxContext.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
       linuxRuntime.internals = { platform: 'linux' }
+      const preparationFailure = new Error('spill directory unavailable')
+      prepareManagedProcessBinding.mockImplementationOnce(() => { throw preparationFailure })
+      expect(() => linuxRuntime.spawn(spec('true'))).toThrow(preparationFailure)
+      expect(launchLinuxScope).not.toHaveBeenCalled()
       await linuxRuntime.spawn(spec('true')).done
       await new Promise(resolve => setImmediate(resolve))
       await linuxRuntime.spawn(spec('true')).done
@@ -512,6 +518,7 @@ describe('LocalSubprocessRuntime', () => {
         linuxLaunch,
         windowsLaunch,
       ])
+      expect(prepareManagedProcessBinding).toHaveBeenCalledTimes(4)
       expect(spawnSubprocess).not.toHaveBeenCalled()
     } finally {
       for (const fiber of fibers.reverse()) await fiber.dispose()

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

@@ -104,11 +104,23 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => {
       child.unref()
       process.exit(42)
     `
-    const request = spec([process.execPath, '-e', script, 'literal $HOME ${UNCHANGED}'], 100, { TARGET_VALUE: 'explicit' })
+    const request = {
+      ...spec([process.execPath, '-e', script, 'literal $HOME ${UNCHANGED}'], 100, { TARGET_VALUE: 'explicit' }),
+      stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' } as const,
+    }
     const handle = bindManagedProcess(request, launchWindowsJob(request))
     const descendant = await waitForPid(pidFile)
     try {
+      if (handle.stdout === undefined) throw new Error('expected piped stdout')
+      const stdoutEnded = Promise.race([
+        new Promise<boolean>((resolve, reject) => {
+          handle.stdout?.once('end', () => { resolve(true) })
+          handle.stdout?.once('error', reject)
+        }),
+        new Promise<boolean>(resolve => setTimeout(() => { resolve(false) }, 1_000)),
+      ])
       await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null })
+      await expect(stdoutEnded).resolves.toBe(true)
       expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({
         cwd: scratch,
         value: 'explicit',

+ 2 - 2
packages/subprocess/subprocess/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md
-README.md: 7972d933cfbb70627cf29e8e44d1f41d873f3075
-README.zh.md: a55ef79e664e4b837e8326bd4b513bfc6a844b75
+README.md: 174e9732d4e7fc2e2589d8f653c5f866873d277c
+README.zh.md: 6336600888d2c4eb324d21a118b95d7ddb447811

+ 2 - 2
packages/subprocess/subprocess/README.md

@@ -6,11 +6,11 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl
 
 ## Contract
 
-- `spawn(spec)` returns a live handle synchronously; a native provider may first complete its bounded setup handshake so the handle exposes the target pid. `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn-level or selected native-runner failures.
+- `spawn(spec)` returns a live handle synchronously; a native provider may first complete its bounded setup handshake so the handle exposes the target pid. `done` resolves with direct-process exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn-level or selected native-runner failures.
 - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides.
 - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
 - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement.
-- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence. The wait rejects when a selected native owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification).
+- Termination and waiting use one provider-managed range. Supported local Linux and Windows providers use a user-systemd scope or kill-on-close Job; weaker fallbacks use a detached POSIX process group or `taskkill /T`. `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so a consumer-owned teardown ladder holds each tier on real quiescence. The wait rejects when a selected native owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification).
 - `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches quiescence for every session member the provider can still observe and settles in-flight handle calls; providers document substrate-specific observability limits. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members; readiness, scrollback, and owner policy remain in the PTY consumer.
 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly.
 - Disposal of the service terminates all still-running managed processes and awaits their exit.

+ 2 - 2
packages/subprocess/subprocess/README.zh.md

@@ -6,11 +6,11 @@
 
 ## 约定
 
-- `spawn(spec)` 同步返回活动句柄;native provider 可以先完成有界 setup handshake,使该句柄公开 target pid。`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 层面或所选 native runner 失败时 reject。
+- `spawn(spec)` 同步返回活动句柄;native provider 可以先完成有界 setup handshake,使该句柄公开 target pid。`done` 以 direct-process exit facts resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 层面或所选 native runner 失败时 reject。
 - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。
 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。
 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。
-- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。所选 native owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。
+- 终止与等待使用同一个 provider-managed range。受支持的本地 Linux 与 Windows provider 使用 user-systemd scope 或 kill-on-close Job;较弱 fallback 使用 detached POSIX 进程组或 `taskkill /T`。`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。所选 native owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。
 - `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,并结算在途句柄调用;提供方会记录执行基底特有的可观察性限制。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出时,输出流在已排队输出之后结束;仍处于活动状态的传输若发生故障,会使 `done` 拒绝。这些操作保留为一项执行基底原语,因为普通管道无法分配控制终端或清理终端会话成员;就绪状态、scrollback 和所有者策略仍归 PTY 消费方所有。
 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。
 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。

+ 5 - 4
packages/subprocess/subprocess/src/index.ts

@@ -88,10 +88,11 @@ declare module '@deepseek-ai/cordis' {
  *   and the spill file holding the complete stream when one exists. Piped
  *   streams are handed to the caller raw and never buffered here.
  * - {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates
- *   SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every
- *   platform. {@link SubprocessHandle.waitForExit} observes whole-tree
- *   liveness, so a consumer-owned teardown ladder can hold each tier on real
- *   quiescence.
+ *   SIGTERM→grace→SIGKILL — the only termination verb — against the provider's
+ *   managed range. Supported local Linux and Windows providers use an OS-owned
+ *   scope or Job; weaker fallbacks use a detached process group or direct-parent
+ *   tree. {@link SubprocessHandle.waitForExit} observes that same range so a
+ *   consumer-owned teardown ladder can hold each tier on real quiescence.
  * - Disposal of the service terminates all still-running managed processes
  *   and awaits their exit.
  * - {@link spawnTerminal} owns terminal allocation, text transport,

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

@@ -156,7 +156,7 @@ export interface SubprocessCollectedOutputs {
 }
 
 /**
- * A live child process rooted in its own process tree. Collected output
+ * A live direct child and its provider-managed process range. Collected output
  * remains readable after exit; piped streams belong to the caller.
  *
  * Termination and {@link SubprocessHandle.waitForExit} use the same managed
@@ -164,7 +164,7 @@ export interface SubprocessCollectedOutputs {
  * weaker platform fallbacks are disclosed by the provider.
  */
 export interface SubprocessHandle {
-  /** Process id (tree root); -1 when the spawn itself failed. */
+  /** Direct target process id; -1 when the spawn itself failed. */
   readonly pid: number
   /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */
   readonly stdin: Writable | undefined
@@ -177,17 +177,17 @@ export interface SubprocessHandle {
   /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */
   readonly done: Promise<SubprocessOutcome>
   /**
-   * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
-   * (Windows force-terminates immediately) — the seam's only termination
-   * verb. Idempotent, a no-op once the tree is gone (the pid may be reused),
-   * and also triggered by the spec's abort signal.
+   * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the provider-managed
+   * range (Windows force-terminates immediately) — the seam's only termination
+   * verb. Idempotent, a no-op once that range is gone, and also triggered by
+   * the spec's abort signal.
    */
   terminate(): void
   /**
-   * Wait until the process tree has exited — the tree, not just the direct
-   * child, so a still-running helper is observable before teardown returns.
+   * Wait until the same managed range is empty — not just until the direct
+   * child exits, so a still-running helper is observable before teardown returns.
    * @param signal - optional bound for the wait.
-   * @returns `true` when the tree exited, `false` when the signal aborted first.
+   * @returns `true` when the managed range is empty, `false` when the signal aborted first.
    * @throws when the selected provider can no longer observe its managed range.
    */
   waitForExit(signal?: AbortSignal): Promise<boolean>