Browse Source

fix(subprocess): hand off Windows process observation

pku-xht 1 tháng trước cách đây
mục cha
commit
5b215932f8
39 tập tin đã thay đổi với 647 bổ sung và 164 xóa
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md
  4. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml
  5. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md
  6. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md
  7. 0 2
      packages/sandbox/sandbox-windows-acl/src/ffi.ts
  8. 2 2
      packages/shell/bash-local/README.i18n.yaml
  9. 1 1
      packages/shell/bash-local/README.md
  10. 1 1
      packages/shell/bash-local/README.zh.md
  11. 23 14
      packages/shell/bash-local/src/index.ts
  12. 72 0
      packages/shell/bash-local/tests/executor.spec.ts
  13. 3 2
      packages/shell/bash-sandbox/src/index.ts
  14. 2 2
      packages/shell/bash-sandbox/tests/sandbox.spec.ts
  15. 2 2
      packages/shell/pwsh-local/README.i18n.yaml
  16. 1 1
      packages/shell/pwsh-local/README.md
  17. 1 1
      packages/shell/pwsh-local/README.zh.md
  18. 22 13
      packages/shell/pwsh-local/src/index.ts
  19. 54 0
      packages/shell/pwsh-local/tests/executor.spec.ts
  20. 3 2
      packages/shell/pwsh-sandbox/src/index.ts
  21. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  22. 2 2
      packages/subprocess/subprocess-local/README.md
  23. 2 2
      packages/subprocess/subprocess-local/README.zh.md
  24. 5 2
      packages/subprocess/subprocess-local/src/runner-launch.ts
  25. 15 10
      packages/subprocess/subprocess-local/src/spawn-runner.ts
  26. 125 21
      packages/subprocess/subprocess-local/src/windows-job.ts
  27. 5 3
      packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts
  28. 27 0
      packages/subprocess/subprocess-local/tests/native-windows.spec.ts
  29. 1 0
      packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts
  30. 177 55
      packages/subprocess/subprocess-local/tests/windows-job.spec.ts
  31. 2 2
      packages/subprocess/win32-process/README.i18n.yaml
  32. 2 2
      packages/subprocess/win32-process/README.md
  33. 2 2
      packages/subprocess/win32-process/README.zh.md
  34. 6 0
      packages/subprocess/win32-process/src/abi.ts
  35. 2 0
      packages/subprocess/win32-process/src/ffi.ts
  36. 2 0
      packages/subprocess/win32-process/src/index.ts
  37. 31 0
      packages/subprocess/win32-process/src/process.ts
  38. 32 4
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts
  39. 6 0
      packages/subprocess/win32-process/verify/abi-probe.cpp

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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-19-shared-win32-process-primitives.md
-2026-08-19-shared-win32-process-primitives.md: eef98440b4e1c6762f5c7f5fafa63ea77d795d39
-2026-08-19-shared-win32-process-primitives.zh.md: 795248083297eb14d45e7fa625c38954d072140c
+2026-08-19-shared-win32-process-primitives.md: 086bc83c2c146cc836d32c1b4b73991fa00f6fc0
+2026-08-19-shared-win32-process-primitives.zh.md: 62a519e4e327ad238eeb4fd861ea14646ef28b85

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md

@@ -14,13 +14,13 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p
 
 The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary.
 
-Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner waits for the direct process separately, while the subprocess parent owns Job accounting, termination, and closure.
+Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner retains its process handle only until the parent opens a separate wait handle, while the subprocess parent owns direct-result polling and Job accounting, termination, and closure.
 
 The package exports only operations used by the two production consumers. Exact `applicationName`, parent-stdio release, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK.
 
 ## Verification
 
-The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking direct-exit reads, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal.
+The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time direct-exit reads, parent-side process opening, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal.
 
 ## Alternatives considered
 
@@ -28,7 +28,7 @@ The shared suite covers x64 ABI values, command-line quoting, binding extension,
 
 **Copy the Koffi implementation into each consumer.** Rejected because struct layouts, error capture, and partial-failure cleanup would have multiple owners.
 
-**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, direct wait, and Job controls were added only with their runner consumer.
+**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, direct wait/poll, and Job controls were added only with their runner and parent consumers.
 
 ## Consequences
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md

@@ -14,13 +14,13 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p
 
 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。
 
-每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独等待 direct process,而 subprocess parent 拥有 Job accounting、termination 与 closure。
+每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 只把自己的 process handle 保留到 parent 打开独立 wait handle,而 subprocess parent 拥有 direct-result polling 以及 Job accounting、termination 与 closure。
 
 该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-stdio release、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。
 
 ## Verification
 
-shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking direct-exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。
+shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time direct-exit 读取、parent-side process opening、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。
 
 ## Alternatives considered
 
@@ -28,7 +28,7 @@ shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF
 
 **为每个 consumer 复制 Koffi 实现。** 拒绝,因为 struct layout、错误捕获与局部失败清理会出现多个 owner。
 
-**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、direct wait 与 Job control 只随实际 runner consumer 一起加入。
+**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、direct wait/poll 与 Job control 只随实际 runner 和 parent consumer 一起加入。
 
 ## Consequences
 

+ 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: 4ff86b626f8d0fbb7c09ce82f5115774199e397a
-2026-08-20-subprocess-native-containment.zh.md: 4f8390974f2d3a4e34704dce574d45079c9a27be
+2026-08-20-subprocess-native-containment.md: 89b6648b6edf2b2e5e84a2900417ecd5229289e3
+2026-08-20-subprocess-native-containment.zh.md: af1b3cb960e03813dcaba67c027a785f1aab2943

+ 2 - 2
.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, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.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; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. Windows target descendants inherit the parent-owned Job by default. The runner opens that Job only for suspended create, assignment, and resume, closes its copy, then exits after publishing the direct result; the parent owner independently terminates the Job and polls `ActiveProcesses`. Raw pipe EOF therefore follows the target and descendants that actually inherited the stream. Host exit closes the parent's owner handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members.
+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; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. Windows target descendants inherit the parent-owned Job by default. The runner opens that Job only for suspended create, assignment, and resume; after the parent opens its own direct-process wait handle, it releases the runner through their private IPC channel. The parent then observes the target exit, terminates the Job, and polls `ActiveProcesses` without retaining a runner-owned copy of the target's stdio. Raw pipe EOF therefore follows the target and descendants that actually inherited the stream. Host exit closes the parent's owner handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members.
 
 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.
 
@@ -34,4 +34,4 @@ Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with syste
 
 ## Consequences
 
-Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each runner remains only until the direct target result while the OS owner persists for descendants. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee.
+Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports. The Windows runner remains only until the parent acquires direct-process observation, while the Linux runner remains until the direct target result and the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux event-file reads use asynchronous 100 ms polling, Windows direct-process state uses 10 ms polling, and Linux scope state uses 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee.

+ 2 - 2
.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 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.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 阻止最终 target event 写入,`.done` 会拒绝而不是虚构结果。Windows target descendant 默认继承 parent-owned Job。runner 只为 suspended create、assignment 与 resume 打开该 Job,随后关闭自身副本,并在发布 direct result 后退出;parent owner 独立终止 Job 并轮询 `ActiveProcesses`。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant。host exit 会关闭 parent 的 owner handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。
+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 阻止最终 target event 写入,`.done` 会拒绝而不是虚构结果。Windows target descendant 默认继承 parent-owned Job。runner 只为 suspended create、assignment 与 resume 打开该 Job;parent 打开自己的 direct-process wait handle 后,通过双方的 private IPC channel 释放 runner。随后由 parent 观察 target exit、终止 Job 并轮询 `ActiveProcesses`,不再保留 runner 拥有的 target stdio 副本。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant。host exit 会关闭 parent 的 owner handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。
 
 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。
 
@@ -34,4 +34,4 @@ Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 
 
 ## Consequences
 
-受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 runner 只保留到 direct target result,后续 descendant 则继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。
+受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒。Windows runner 只保留到 parent 取得 direct-process observation,Linux runner 则保留到 direct target result,后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux event file 每 100 ms、Windows direct-process state 每 10 ms、Linux scope state 每 200 ms 异步轮询,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。

+ 0 - 2
packages/sandbox/sandbox-windows-acl/src/ffi.ts

@@ -28,7 +28,6 @@ const PPVOID: Ptr = koffi.pointer(PVOID)
 
 /** ACL/token calls composed with the generic Win32 process binding table. */
 export interface Win32Bindings extends Win32ProcessBindings {
-  openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr
   openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number
   localAlloc(flags: number, bytes: number): NativePtr
   localFree(memory: NativePtr): NativePtr
@@ -222,7 +221,6 @@ let cached: Win32Bindings | undefined
 function bindings(): Win32Bindings {
   if (cached !== undefined) return cached
   cached = extendWin32ProcessBindings(({ kernel32, advapi32, bind }) => ({
-    openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']),
     openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]),
     localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']),
     localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]),

+ 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: 7bdaa7442e7d4166ecae6328d2456ddc22a01cbc
-README.zh.md: bf33c9d1108be92ffadcd60363d4d773f7591071
+README.md: 051b8aba0c66d2970f42ada9d3437265c29cd839
+README.zh.md: ff5a0178f8dedaf2f4bea5eb603e51bd2ffa74bc

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

@@ -42,6 +42,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
 - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
 - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
 - **POSIX-only** — the `bash` binary is hardcoded, so this executor is not composed on Windows.
-- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
+- **A background failure note is single-delivery** — when `done` rejects and no real stderr is available, the executor injects one diagnostic into exactly one `readOutput()` delta. A Node-shaped rejection that identifies `argv[0]` uses `spawn failed: …`; a provider failure without proof that the target never started uses `subprocess failed: …`.
 
 Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.

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

@@ -42,6 +42,6 @@
 - **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要隔离的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.zh.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。
 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。
 - **仅支持 POSIX**:`bash` 二进制已硬编码,因此本执行器不会在 Windows 上组装。
-- **后台 spawn 失败提示只交付一次**:subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。
+- **后台失败提示只交付一次**:`done` 拒绝且没有真实 stderr 时,执行器会把一条诊断注入恰好一个 `readOutput()` 增量。能以 Node-shaped 字段确认 `argv[0]` 未启动的拒绝使用 `spawn failed: …`;无法证明目标未启动的 provider failure 使用 `subprocess failed: …`。
 
 凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 记录;这些机制归它所有。

+ 23 - 14
packages/shell/bash-local/src/index.ts

@@ -66,6 +66,15 @@ function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
   }
 }
 
+/** Whether a rejection carries direct evidence that argv[0] never started. */
+function isSpawnFailure(error: unknown, program: string): boolean {
+  if (typeof error !== 'object' || error === null) return false
+  const { path, syscall } = error as { path?: unknown; syscall?: unknown }
+  if (typeof syscall !== 'string') return false
+  if (syscall !== 'spawn' && syscall !== `spawn ${program}`) return false
+  return path === undefined || path === program
+}
+
 function assertPositiveFinite(name: string, value: number): void {
   if (!Number.isFinite(value) || value <= 0) {
     throw new Error(`bash-local: ${name} must be a positive finite number`)
@@ -257,12 +266,12 @@ export class LocalBashExecutor extends ShellExecutor {
     const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
     const collected = LocalBashExecutor.collected(running)
 
-    // A spawn failure produces no process output, so the subprocess service has nothing
-    // to buffer; the note is delivered exactly once through the read path.
-    let spawnFailureNote: string | undefined
-    const consumeSpawnFailure = (): string => {
-      const note = spawnFailureNote ?? ''
-      spawnFailureNote = undefined
+    // A rejected subprocess result has no settled outcome. Its diagnostic is
+    // delivered exactly once through the read path.
+    let failureNote: string | undefined
+    const consumeFailure = (): string => {
+      const note = failureNote ?? ''
+      failureNote = undefined
       return note
     }
 
@@ -281,10 +290,10 @@ export class LocalBashExecutor extends ShellExecutor {
         proc.signal = outcome.signal
         this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
       }, (error: unknown) => {
-        // Background spawn failures settle as killed and surface through the read path.
+        const spawnFailed = running.pid <= 0 && isSpawnFailure(error, argv[0] as string)
         proc.status = 'killed'
-        spawnFailureNote = `spawn failed: ${String(error)}`
-        this.onProcessDone(proc, spawnFailureNote, true, error)
+        failureNote = `${spawnFailed ? 'spawn' : 'subprocess'} failed: ${String(error)}`
+        this.onProcessDone(proc, failureNote, spawnFailed, error)
       }),
       readOutput: (): ShellProcessRead => {
         const out = collected.stdout.readFrom(stdoutOffset)
@@ -292,9 +301,9 @@ export class LocalBashExecutor extends ShellExecutor {
         stdoutOffset = out.nextOffset
         stderrOffset = err.nextOffset
 
-        // A failed spawn never produced process output, so the note and real
-        // stderr text are mutually exclusive.
-        const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
+        // A rejected subprocess may have no process output; its synthetic note
+        // is used only when no real stderr is available.
+        const errText = err.text.length > 0 ? err.text : consumeFailure()
         // Single newline between sections: stdout chunks usually end with one
         // already; add it only when missing.
         const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
@@ -319,13 +328,13 @@ export class LocalBashExecutor extends ShellExecutor {
 
   /**
    * Settlement hook for subclasses that attach execution facts to a process.
-   * Called after exit facts or spawn-failure output are stamped and before
+   * Called after exit facts or rejection output are stamped and before
    * {@link ShellProcess.done} resolves. The base implementation is intentionally
    * empty.
    * @param _proc - the settled process handle.
    * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
    * @param _spawnFailed - whether the subprocess promise rejected before a process started.
-   * @param _spawnError - the original spawn rejection reason, which may itself be undefined.
+   * @param _spawnError - the original subprocess rejection when settlement failed; it may itself be undefined.
    */
   protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
 }

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

@@ -4,9 +4,11 @@ import { join } from 'node:path'
 import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import SubprocessRuntime from '@deepseek-ai/dsh-subprocess'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
 import type { ShellProcess } from '@deepseek-ai/dsh-shell'
+import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
 
@@ -20,6 +22,43 @@ async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1]
   return { ctx, bash }
 }
 
+class RejectingSubprocessRuntime extends SubprocessRuntime {
+  private readonly reader: SubprocessOutputReader = {
+    readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
+  }
+
+  constructor(ctx: Context, private readonly failure: unknown, private readonly processId = 123) {
+    super(ctx)
+  }
+
+  override async resolveExecutable(command: string): Promise<string> { return command }
+  override spawnTerminal(): Promise<never> { throw new Error('bash spawns pipes, never terminals') }
+  override spawn(_spec: SubprocessSpawnSpec): SubprocessHandle {
+    return {
+      pid: this.processId,
+      stdin: undefined,
+      stdout: undefined,
+      stderr: undefined,
+      collected: { stdout: this.reader, stderr: this.reader },
+      done: Promise.resolve().then(() => { throw this.failure }),
+      terminate: () => {},
+      waitForExit: async () => true,
+    }
+  }
+}
+
+class ObservingBashExecutor extends LocalBashExecutor {
+  spawnFailed: boolean | undefined
+
+  protected override onProcessDone(
+    _proc: ShellProcess,
+    _stderr: string,
+    spawnFailed: boolean,
+  ): void {
+    this.spawnFailed = spawnFailed
+  }
+}
+
 /**
  * Poll a handle's consuming readOutput until the ACCUMULATED delta contains
  * `expected`; returns the accumulation (reads never re-deliver, so the caller
@@ -296,6 +335,39 @@ describe('LocalBashExecutor.start (background process handles)', () => {
     expect(proc.status).toBe('killed')
     expect(proc.readOutput().delta).toContain('spawn failed:')
   })
+
+  it('does not label a post-start provider rejection as a spawn failure', async () => {
+    const ctx = new Context()
+    const failure = Object.assign(new Error('managed owner became unreadable'), {
+      code: 'ENOENT',
+      syscall: 'spawn bash',
+      path: 'bash',
+    })
+    new RejectingSubprocessRuntime(ctx, failure)
+    await ctx.plugin(ObservingBashExecutor)
+    const bash = ctx.shell as ObservingBashExecutor
+    const proc = bash.start(bash.resolve({ command: 'true' }))
+    await proc.done
+    expect(proc.readOutput().delta).toContain('subprocess failed:')
+    expect(proc.readOutput().delta).toBe('')
+    expect(bash.spawnFailed).toBe(false)
+  })
+
+  it.each([
+    ['non-object rejection', undefined, 'subprocess failed:', false],
+    ['non-string syscall', { syscall: 1 }, 'subprocess failed:', false],
+    ['non-spawn syscall', { syscall: 'kill', path: 'bash' }, 'subprocess failed:', false],
+    ['matching syscall without path', { syscall: 'spawn bash' }, 'spawn failed:', true],
+  ])('classifies a pre-start %s from structured evidence', async (_label, failure, note, spawnFailed) => {
+    const ctx = new Context()
+    new RejectingSubprocessRuntime(ctx, failure, -1)
+    await ctx.plugin(ObservingBashExecutor)
+    const bash = ctx.shell as ObservingBashExecutor
+    const proc = bash.start(bash.resolve({ command: 'true' }))
+    await proc.done
+    expect(proc.readOutput().delta).toContain(note)
+    expect(bash.spawnFailed).toBe(spawnFailed)
+  })
 })
 
 describe('process lifecycle ownership (the subprocess service, not the executor)', () => {

+ 3 - 2
packages/shell/bash-sandbox/src/index.ts

@@ -151,8 +151,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
     const facts = this.processFacts.get(proc)
     if (facts !== undefined) {
       this.processFacts.delete(proc)
-      // A rejected spawn never started the confined launch. Otherwise runner
-      // failure outranks denial because its diagnostics may contain denial terms.
+      // A definite spawn rejection never started the confined launch. A
+      // settled runner failure outranks denial because its diagnostics may
+      // contain denial terms; unclassified provider rejection proves neither.
       const runnerFailed = spawnFailed
         ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
         : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined

+ 2 - 2
packages/shell/bash-sandbox/tests/sandbox.spec.ts

@@ -558,7 +558,7 @@ describe('background sandbox facts', () => {
     }
   })
 
-  it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
+  it('does not invent spawn or runner evidence for an unstructured subprocess rejection', async () => {
     const { ctx, bash } = await setup()
     const emptyReader: SubprocessOutputReader = {
       readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
@@ -579,7 +579,7 @@ describe('background sandbox facts', () => {
     const task = bash.start(bash.resolve({ command: 'true' }))
     await task.done
 
-    expect(task.readOutput().delta).toContain('spawn failed: undefined')
+    expect(task.readOutput().delta).toContain('subprocess failed: undefined')
     expect(task.sandbox).toEqual({
       mode: 'read-only',
       denied: false,

+ 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: 788495ebc5bb4d498d53eada8fb5401bb3639b43
-README.zh.md: 65297a0606f62c769b8ae219066e1ecce32d1b82
+README.md: 44ea2f3d9face0499f0b7088a257a391a7370225
+README.zh.md: 81e46ad52f0c00817f31f0ba51379f2f1cbbc840

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

@@ -49,7 +49,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
 - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead.
 - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`.
 - **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
-- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
+- **A background failure note is single-delivery** — when `done` rejects and no real stderr is available, the executor injects one diagnostic into exactly one `readOutput()` delta. A Node-shaped rejection that identifies `argv[0]` uses `spawn failed: …`; a provider failure without proof that the target never started uses `subprocess failed: …`.
 - **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.
 - **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead.
 - **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected.

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

@@ -49,7 +49,7 @@
 - **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要隔离的部署应组合启用沙箱的 bash 执行器或策略。
 - **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`。
 - **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
-- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
+- **后台失败提示只投递一次**——`done` 拒绝且没有真实 stderr 时,执行器只把一条诊断注入一次 `readOutput()` 增量。能以 Node-shaped 字段确认 `argv[0]` 未启动的拒绝使用 `spawn failed: …`;无法证明目标未启动的 provider failure 使用 `subprocess failed: …`。
 - **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接标记为 `killed`。
 - **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。
 - **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。

+ 22 - 13
packages/shell/pwsh-local/src/index.ts

@@ -94,6 +94,15 @@ function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
   }
 }
 
+/** Whether a rejection carries direct evidence that argv[0] never started. */
+function isSpawnFailure(error: unknown, program: string): boolean {
+  if (typeof error !== 'object' || error === null) return false
+  const { path, syscall } = error as { path?: unknown; syscall?: unknown }
+  if (typeof syscall !== 'string') return false
+  if (syscall !== 'spawn' && syscall !== `spawn ${program}`) return false
+  return path === undefined || path === program
+}
+
 function assertPositiveFinite(name: string, value: number): void {
   if (!Number.isFinite(value) || value <= 0) {
     throw new Error(`pwsh-local: ${name} must be a positive finite number`)
@@ -286,12 +295,12 @@ export class PwshLocalExecutor extends ShellExecutor {
     const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv))
     const collected = PwshLocalExecutor.collected(running)
 
-    // A spawn failure produces no process output, so the subprocess service has nothing
-    // to buffer; the note is delivered exactly once through the read path.
-    let spawnFailureNote: string | undefined
-    const consumeSpawnFailure = (): string => {
-      const note = spawnFailureNote ?? ''
-      spawnFailureNote = undefined
+    // A rejected subprocess result has no settled outcome. Its diagnostic is
+    // delivered exactly once through the read path.
+    let failureNote: string | undefined
+    const consumeFailure = (): string => {
+      const note = failureNote ?? ''
+      failureNote = undefined
       return note
     }
 
@@ -310,10 +319,10 @@ export class PwshLocalExecutor extends ShellExecutor {
         proc.signal = outcome.signal
         this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
       }, (error: unknown) => {
-        // Background spawn failures settle as killed and surface through the read path.
+        const spawnFailed = running.pid <= 0 && isSpawnFailure(error, argv[0] as string)
         proc.status = 'killed'
-        spawnFailureNote = `spawn failed: ${String(error)}`
-        this.onProcessDone(proc, spawnFailureNote, true, error)
+        failureNote = `${spawnFailed ? 'spawn' : 'subprocess'} failed: ${String(error)}`
+        this.onProcessDone(proc, failureNote, spawnFailed, error)
       }),
       readOutput: (): ShellProcessRead => {
         const out = collected.stdout.readFrom(stdoutOffset)
@@ -321,9 +330,9 @@ export class PwshLocalExecutor extends ShellExecutor {
         stdoutOffset = out.nextOffset
         stderrOffset = err.nextOffset
 
-        // A failed spawn never produced process output, so the note and real
-        // stderr text are mutually exclusive.
-        const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
+        // A rejected subprocess may have no process output; its synthetic note
+        // is used only when no real stderr is available.
+        const errText = err.text.length > 0 ? err.text : consumeFailure()
         // Single newline between sections: stdout chunks usually end with one
         // already; add it only when missing.
         const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
@@ -354,7 +363,7 @@ export class PwshLocalExecutor extends ShellExecutor {
    * @param _proc - the settled process handle.
    * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
    * @param _spawnFailed - whether the spawn rejected before any process existed.
-   * @param _spawnError - the spawn rejection, when `_spawnFailed`.
+   * @param _spawnError - the original subprocess rejection when settlement failed; it may itself be undefined.
    */
   protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
 }

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

@@ -175,6 +175,43 @@ describe('spawn construction (pure, every platform)', () => {
     }
   }
 
+  class RejectingSubprocessRuntime extends SubprocessRuntime {
+    private readonly reader: SubprocessOutputReader = {
+      readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
+    }
+
+    constructor(ctx: Context, private readonly failure: unknown) {
+      super(ctx)
+    }
+
+    override async resolveExecutable(command: string): Promise<string> { return command }
+    override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
+    override spawn(_spec: SubprocessSpawnSpec): SubprocessHandle {
+      return {
+        pid: 123,
+        stdin: undefined,
+        stdout: undefined,
+        stderr: undefined,
+        collected: { stdout: this.reader, stderr: this.reader },
+        done: Promise.resolve().then(() => { throw this.failure }),
+        terminate: () => {},
+        waitForExit: async () => true,
+      }
+    }
+  }
+
+  class ObservingPwshExecutor extends PwshLocalExecutor {
+    spawnFailed: boolean | undefined
+
+    protected override onProcessDone(
+      _proc: ShellProcess,
+      _stderr: string,
+      spawnFailed: boolean,
+    ): void {
+      this.spawnFailed = spawnFailed
+    }
+  }
+
   it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => {
     const ctx = new Context()
     const subprocess = new CapturingSubprocessRuntime(ctx)
@@ -187,6 +224,23 @@ describe('spawn construction (pure, every platform)', () => {
     expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding')
     expect(ENCODING_PREAMBLE).toContain('$OutputEncoding')
   })
+
+  it('does not label a post-start provider rejection as a spawn failure', async () => {
+    const ctx = new Context()
+    const failure = Object.assign(new Error('managed owner became unreadable'), {
+      code: 'ENOENT',
+      syscall: 'spawn pwsh',
+      path: 'pwsh',
+    })
+    new RejectingSubprocessRuntime(ctx, failure)
+    await ctx.plugin(ObservingPwshExecutor, { pwshPath: 'pwsh' })
+    const pwsh = ctx.shell as ObservingPwshExecutor
+    const proc = pwsh.start(pwsh.resolve({ command: 'Write-Output ok' }))
+    await proc.done
+    expect(proc.readOutput().delta).toContain('subprocess failed:')
+    expect(proc.readOutput().delta).toBe('')
+    expect(pwsh.spawnFailed).toBe(false)
+  })
 })
 
 describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {

+ 3 - 2
packages/shell/pwsh-sandbox/src/index.ts

@@ -157,8 +157,9 @@ export class SandboxPwshExecutor extends PwshLocalExecutor {
     const facts = this.processFacts.get(proc)
     if (facts !== undefined) {
       this.processFacts.delete(proc)
-      // A rejected spawn never started the confined launch. Otherwise runner
-      // failure outranks denial because its diagnostics may contain denial terms.
+      // A definite spawn rejection never started the confined launch. A
+      // settled runner failure outranks denial because its diagnostics may
+      // contain denial terms; unclassified provider rejection proves neither.
       const runnerFailed = spawnFailed
         ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
         : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined

+ 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: a2766a3a90f14c69727d2708d92927e03334ed1f
-README.zh.md: d26b01ebebd0fe3ccd37b4771f1d224b96973bfd
+README.md: d62b946f274a4d3337ad7d5ced01a8d8b51924b2
+README.zh.md: 78405529cf054456496c68739bc2037ef2712111

+ 2 - 2
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 creates a parent-owned kill-on-close Job; its runner opens that Job, creates the target suspended, assigns it, resumes it, then closes its own Job handle and exits after publishing the direct result. Raw pipe EOF therefore follows the target and descendants that actually inherit the stream rather than Job observation. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `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.
+- **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 creates a parent-owned kill-on-close Job; its runner opens that Job, creates the target suspended, assigns it, and resumes it. The parent opens its own direct-process wait handle before releasing the runner, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than Job observation. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `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, while the parent observes the Windows target exit separately from range lifetime; 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).
@@ -27,7 +27,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
 ## Known Limitations and Deferred Work
 
 - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists.
-- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each runner remains only until the direct target result; the OS-owned scope or parent-held Job persists for later descendants. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms.
+- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. The Windows runner is released as soon as the parent acquires direct-process observation; the Linux runner remains until the direct target result, while the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux runner events and Windows direct-process state are polled asynchronously every 100 ms and 10 ms respectively, while Linux scope state is polled every 200 ms.
 - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind.
 - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest.
 - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor.

+ 2 - 2
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 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复,随后关闭自己的 Job handle,并在发布 direct result 后退出。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observation。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。
+- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。parent 会先打开自己的 direct-process wait handle,再释放 runner,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observation。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 报告目标启动失败,而 parent 独立观察 Windows target exit,不把 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)。
@@ -27,7 +27,7 @@
 ## 已知限制与暂缓事项
 
 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。
-- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每个 runner 只保留到 direct target result,后续 descendant 则继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。
+- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。Windows runner 会在 parent 取得 direct-process observation 后立即释放;Linux runner 保留到 direct target result,而后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux runner event 与 Windows direct-process state 分别每 100 ms 和 10 ms 异步轮询,Linux scope state 每 200 ms 轮询。
 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。
 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。
 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。

+ 5 - 2
packages/subprocess/subprocess-local/src/runner-launch.ts

@@ -41,14 +41,17 @@ export function spawnRunnerInvocation(): string[] {
 /**
  * Build wrapper stdio corresponding to the public target dispositions.
  * @param spec - target stdio request.
+ * @param ipc - append a private control channel for the Windows launcher.
  * @returns child-process stdio configuration.
  */
-export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions {
-  return [
+export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions {
+  const stdio: StdioOptions = [
     spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe',
     spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe',
     spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe',
   ]
+  if (ipc) stdio.push('ipc')
+  return stdio
 }
 
 /**

+ 15 - 10
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -6,7 +6,6 @@ import {
   loadWin32ProcessBindings,
   openJobForAssignment,
   spawnOrdinaryProcessInJob,
-  waitForProcessExit,
   Win32Error,
 } from '@deepseek-ai/dsh-win32-process'
 import type { NativePtr } from '@deepseek-ai/dsh-win32-process'
@@ -102,13 +101,15 @@ function replaceEnvironment(env: Record<string, string>): void {
   Object.assign(process.env, env)
 }
 
-function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): void {
+async function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): Promise<void> {
   replaceEnvironment(request.env)
   const api = loadWin32ProcessBindings()
   let processHandle: NativePtr | undefined
   let jobHandle: NativePtr | undefined
   let targetStarted = false
   try {
+    if (!process.connected) throw new Error('Windows subprocess runner requires a parent IPC channel')
+    const released = new Promise<void>((resolve) => { process.once('disconnect', resolve) })
     process.chdir(request.cwd)
     jobHandle = openJobForAssignment(api, jobName)
     const [command, ...args] = request.argv
@@ -118,10 +119,10 @@ function runWin32(request: RunnerRequest, eventsPath: string, jobName: string):
     closeHandleChecked(api, jobHandle, 'ordinary process Job assignment')
     jobHandle = undefined
     appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid })
+    await released
     const directProcess = processHandle
     processHandle = undefined
-    const exitCode = waitForProcessExit(api, directProcess)
-    appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null })
+    closeHandleChecked(api, directProcess, 'ordinary direct process handoff')
   } catch (error) {
     appendRunnerEvent(eventsPath, {
       type: targetStarted ? 'runner-error' : 'spawn-error',
@@ -138,7 +139,7 @@ function runWin32(request: RunnerRequest, eventsPath: string, jobName: string):
   }
 }
 
-function main(): void {
+async function main(): Promise<void> {
   const args = parseArgs(process.argv.slice(2))
   if (args.mode === 'probe-node') return
   if (args.mode === 'probe-win32') {
@@ -147,12 +148,16 @@ function main(): void {
   }
   const request = consumeRunnerRequest(args.requestPath)
   if (args.mode === 'node') runNode(request, args.eventsPath)
-  else runWin32(request, args.eventsPath, args.jobName)
+  else {
+    try {
+      await runWin32(request, args.eventsPath, args.jobName)
+    } finally {
+      if (process.connected) process.disconnect()
+    }
+  }
 }
 
-try {
-  main()
-} catch (error: unknown) {
+main().catch((error: unknown) => {
   try {
     const args = parseArgs(process.argv.slice(2))
     if (args.mode !== 'probe-node' && args.mode !== 'probe-win32') {
@@ -162,4 +167,4 @@ try {
     // No trustworthy transport remains; the parent reports the missing result.
   }
   process.exitCode = 127
-}
+})

+ 125 - 21
packages/subprocess/subprocess-local/src/windows-job.ts

@@ -2,18 +2,21 @@
 
 import { spawn, spawnSync } from 'node:child_process'
 import { randomUUID } from 'node:crypto'
+import type { Readable } from 'node:stream'
 import { setTimeout as sleepMs } from 'node:timers/promises'
-import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import {
   closeHandleChecked,
   createKillOnCloseJob,
   isJobEmpty,
   loadWin32ProcessBindings,
+  openProcessForWait,
+  pollProcessExit,
   terminateJob,
 } from '@deepseek-ai/dsh-win32-process'
 import type { NativePtr } from '@deepseek-ai/dsh-win32-process'
 import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
-import { observeChildClose, waitWithAbort } from './managed-owner.ts'
+import { waitWithAbort } from './managed-owner.ts'
 import { childEnv } from './spawn.ts'
 import {
   cleanupAfterRunner,
@@ -25,31 +28,107 @@ import {
 import { cleanupRunnerFiles } from './runner-protocol.ts'
 
 const JOB_POLL_INTERVAL_MS = 10
+const PROCESS_POLL_INTERVAL_MS = 10
 
-/** Parent-side operations for one Windows Job handle. */
-export interface WindowsJobOperations {
+/** Parent-side operations for one Windows managed launch. */
+export interface WindowsProcessOperations {
   create(name: string): NativePtr
+  openProcess(pid: number): NativePtr
+  pollProcess(process: NativePtr): number | undefined
   empty(job: NativePtr): boolean
   terminate(job: NativePtr): void
-  close(job: NativePtr): void
+  closeJob(job: NativePtr): void
+  closeProcess(process: NativePtr): void
 }
 
-function nativeJobOperations(): WindowsJobOperations {
+function nativeProcessOperations(): WindowsProcessOperations {
   const api = loadWin32ProcessBindings()
   return {
     create: name => createKillOnCloseJob(api, name),
+    openProcess: pid => openProcessForWait(api, pid),
+    pollProcess: process => pollProcessExit(api, process),
     empty: job => isJobEmpty(api, job),
     terminate: (job) => { terminateJob(api, job, 1) },
-    close: (job) => { closeHandleChecked(api, job, 'ordinary process Job') },
+    closeJob: (job) => { closeHandleChecked(api, job, 'ordinary process Job') },
+    closeProcess: (process) => { closeHandleChecked(api, process, 'ordinary direct process') },
   }
 }
 
+function releaseRunner(child: ReturnType<typeof spawn>): Error | undefined {
+  if (!child.connected) return undefined
+  try {
+    child.disconnect()
+    return undefined
+  } catch (error) {
+    try { child.kill() } catch { /* The direct process and Job remain parent-owned. */ }
+    return error instanceof Error ? error : new Error(String(error))
+  }
+}
+
+function observeRunnerExit(child: ReturnType<typeof spawn>): Promise<void> {
+  return new Promise((resolve) => {
+    child.once('error', () => { resolve() })
+    child.once('exit', () => { resolve() })
+  })
+}
+
+function observeCollectedStream(
+  mode: SubprocessSpawnSpec['stdio']['stdout'],
+  stream: Readable | null | undefined,
+): Promise<void> {
+  if (mode === 'pipe' || mode === 'inherit' || stream === null || stream === undefined
+    || stream.readableEnded || stream.destroyed) {
+    return Promise.resolve()
+  }
+  return new Promise((resolve) => {
+    const settle = (): void => {
+      stream.off('end', settle)
+      stream.off('close', settle)
+      stream.off('error', settle)
+      resolve()
+    }
+    stream.once('end', settle)
+    stream.once('close', settle)
+    stream.once('error', settle)
+  })
+}
+
 /** Test seams for the runner process. */
 export interface WindowsJobInternals {
   spawn?: typeof spawn
   spawnSync?: typeof spawnSync
   runnerInvocation?: string[]
-  jobs?: WindowsJobOperations
+  operations?: WindowsProcessOperations
+}
+
+function observeDirectProcess(
+  pid: number,
+  operations: WindowsProcessOperations,
+): Promise<SubprocessOutcome> {
+  const processHandle = operations.openProcess(pid)
+  let closed = false
+  const close = (): void => {
+    if (closed) return
+    operations.closeProcess(processHandle)
+    closed = true
+  }
+  return new Promise((resolve, reject) => {
+    const poll = (): void => {
+      try {
+        const exitCode = operations.pollProcess(processHandle)
+        if (exitCode === undefined) {
+          setTimeout(poll, PROCESS_POLL_INTERVAL_MS)
+          return
+        }
+        close()
+        resolve({ exitCode, signal: null })
+      } catch (error) {
+        try { close() } catch { /* Preserve the observation failure. */ }
+        reject(error instanceof Error ? error : new Error(String(error)))
+      }
+    }
+    poll()
+  })
 }
 
 /**
@@ -78,8 +157,8 @@ class WindowsJobOwner implements BoundProcessOwner {
 
   constructor(
     private readonly job: NativePtr,
-    private readonly operations: WindowsJobOperations,
-    private readonly runnerClosed: Promise<void>,
+    private readonly operations: WindowsProcessOperations,
+    private readonly runnerExited: Promise<void>,
   ) {}
 
   signal(_signal: NodeJS.Signals): void {
@@ -103,7 +182,7 @@ class WindowsJobOwner implements BoundProcessOwner {
         }
         this.stopped = true
         this.close()
-        await this.runnerClosed
+        await this.runnerExited
       } catch (error) {
         this.stopped = true
         try { this.close() } catch { /* Preserve the observation failure. */ }
@@ -115,7 +194,7 @@ class WindowsJobOwner implements BoundProcessOwner {
 
   private close(): void {
     if (this.closed) return
-    this.operations.close(this.job)
+    this.operations.closeJob(this.job)
     this.closed = true
   }
 }
@@ -135,12 +214,12 @@ export function launchWindowsJob(
   const [command, ...prefix] = invocation
   if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty')
   /* v8 ignore next -- the native Windows suite exercises the real Job operations. */
-  const jobs = internals.jobs ?? nativeJobOperations()
+  const operations = internals.operations ?? nativeProcessOperations()
   const jobName = `Local\\dsh-subprocess-${randomUUID()}`
   const files = runnerFiles(spec)
   let job: NativePtr
   try {
-    job = jobs.create(jobName)
+    job = operations.create(jobName)
   } catch (error) {
     cleanupRunnerFiles(files)
     throw error
@@ -159,16 +238,41 @@ export function launchWindowsJob(
       files.eventsPath,
     ], {
       env: childEnv(),
-      stdio: runnerStdio(spec),
+      stdio: runnerStdio(spec, true),
     })
   } catch (error) {
-    try { jobs.close(job) } catch { /* Preserve the launch failure. */ }
+    try { operations.closeJob(job) } catch { /* Preserve the launch failure. */ }
     cleanupRunnerFiles(files)
     throw error
   }
-  const closed = observeChildClose(child)
-  const owner = new WindowsJobOwner(job, jobs, closed)
-  const result = runnerDirectResult(child, files, closed)
-  cleanupAfterRunner(files, result.direct, closed)
-  return { child, pid: result.pid, direct: result.direct, closed, owner }
+  const runnerExited = observeRunnerExit(child)
+  const closed = Promise.all([
+    runnerExited,
+    observeCollectedStream(spec.stdio.stdout, child.stdout),
+    observeCollectedStream(spec.stdio.stderr, child.stderr),
+  ]).then(() => undefined)
+  const owner = new WindowsJobOwner(job, operations, runnerExited)
+  const transport = runnerDirectResult(child, files, runnerExited)
+  if (transport.pid <= 0) {
+    cleanupAfterRunner(files, transport.direct, runnerExited)
+    return { child, pid: transport.pid, direct: transport.direct, closed, owner }
+  }
+  // The launcher retains its original process handle until this process opens
+  // an independent one, preventing PID reuse during the ownership handoff.
+  // Its event reader then becomes intentionally irrelevant: Windows direct
+  // settlement is owned by the handle below, not by the released runner.
+  void transport.direct.catch(() => {})
+  let direct: Promise<SubprocessOutcome>
+  try {
+    direct = observeDirectProcess(transport.pid, operations)
+  } catch (error) {
+    direct = Promise.resolve().then(() => { throw error })
+  }
+  const releaseFailure = releaseRunner(child)
+  if (releaseFailure !== undefined) {
+    void direct.catch(() => {})
+    direct = Promise.resolve().then(() => { throw releaseFailure })
+  }
+  cleanupAfterRunner(files, direct, runnerExited)
+  return { child, pid: transport.pid, direct, closed, owner }
 }

+ 5 - 3
packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts

@@ -9,10 +9,12 @@ appendRunnerEvent(eventsPath, { type: 'started', pid: process.pid })
 const configuredExit = Number(request.argv[1])
 // Events carry target results; zero means the runner completed its own work.
 if (Number.isSafeInteger(configuredExit)) {
-  setTimeout(() => {
+  const finish = (): void => {
     appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null })
-    process.exitCode = 0
-  }, 10)
+    process.exit(0)
+  }
+  if (process.connected) process.once('disconnect', finish)
+  else setTimeout(finish, 10)
 } else {
   setInterval(() => {}, 1_000)
 }

+ 27 - 0
packages/subprocess/subprocess-local/tests/native-windows.spec.ts

@@ -70,6 +70,33 @@ function directSpawnFailure(argv: string[]): Promise<NodeJS.ErrnoException> {
 const windowsNative = process.platform === 'win32' && probeWindowsJob()
 
 describe.skipIf(!windowsNative)('Windows Job native containment', () => {
+  it('releases raw stdout when the target closes it before exiting', async () => {
+    const request = {
+      ...spec([process.execPath, '-e', 'process.stdout.end(); setInterval(() => {}, 1000)']),
+      stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' } as const,
+    }
+    const handle = bindManagedProcess(request, launchWindowsJob(request))
+    if (handle.stdout === undefined) throw new Error('expected piped stdout')
+    const stdoutEnded = new Promise<void>((resolve, reject) => {
+      handle.stdout?.once('end', resolve)
+      handle.stdout?.once('error', reject)
+    })
+    handle.stdout.resume()
+    let directSettled = false
+    void handle.done.then(
+      () => { directSettled = true },
+      () => { directSettled = true },
+    )
+    await expect(Promise.race([
+      stdoutEnded.then(() => true),
+      new Promise<boolean>(resolve => setTimeout(() => { resolve(false) }, 1_000)),
+    ])).resolves.toBe(true)
+    expect(directSettled).toBe(false)
+    handle.terminate()
+    await handle.done
+    await expect(handle.waitForExit()).resolves.toBe(true)
+  })
+
   it('terminates the direct target and its default-inheritance descendant', async () => {
     const pidFile = join(scratch, `job-child-${Date.now()}.pid`)
     const script = `

+ 1 - 0
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -87,6 +87,7 @@ describe('spawn runner transport', () => {
 
   it('maps every target stdio disposition', () => {
     expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
+    expect(runnerStdio(spec(), true)).toEqual(['ignore', 'pipe', 'pipe', 'ipc'])
     expect(runnerStdio(spec({
       stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' },
     }))).toEqual(['pipe', 'inherit', 'inherit'])

+ 177 - 55
packages/subprocess/subprocess-local/tests/windows-job.spec.ts

@@ -1,13 +1,14 @@
 import { spawn, spawnSync } from 'node:child_process'
 import type { ChildProcess } from 'node:child_process'
 import { EventEmitter } from 'node:events'
+import { PassThrough } from 'node:stream'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it, vi } from 'vitest'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { NativePtr } from '@deepseek-ai/dsh-win32-process'
 import { appendRunnerEvent } from '../src/runner-protocol.ts'
 import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts'
-import type { WindowsJobOperations } from '../src/windows-job.ts'
+import type { WindowsProcessOperations } from '../src/windows-job.ts'
 
 const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url))
 const invocation = [process.execPath, '--import', 'tsx/esm', fixture]
@@ -21,23 +22,45 @@ function spec(argv: string[]): SubprocessSpawnSpec {
   }
 }
 
-function jobOperations(overrides: Partial<WindowsJobOperations> = {}): {
-  operations: WindowsJobOperations
+function fakeRunner(pid: number): { child: ChildProcess; disconnect: ReturnType<typeof vi.fn> } {
+  const child = new EventEmitter() as ChildProcess
+  const disconnect = vi.fn(() => {
+    Object.assign(child, { connected: false })
+    queueMicrotask(() => {
+      child.emit('exit', 0, null)
+      child.emit('close', 0, null)
+    })
+  })
+  Object.assign(child, { pid, connected: true, disconnect, kill: vi.fn(() => true) })
+  return { child, disconnect }
+}
+
+function processOperations(overrides: Partial<WindowsProcessOperations> = {}): {
+  operations: WindowsProcessOperations
   create: ReturnType<typeof vi.fn>
+  openProcess: ReturnType<typeof vi.fn>
+  pollProcess: ReturnType<typeof vi.fn>
   empty: ReturnType<typeof vi.fn>
   terminate: ReturnType<typeof vi.fn>
-  close: ReturnType<typeof vi.fn>
+  closeJob: ReturnType<typeof vi.fn>
+  closeProcess: ReturnType<typeof vi.fn>
 } {
   const create = vi.fn(() => 50n as NativePtr)
+  const openProcess = vi.fn(() => 60n as NativePtr)
+  const pollProcess = vi.fn(() => 0)
   const empty = vi.fn(() => true)
   const terminate = vi.fn()
-  const close = vi.fn()
+  const closeJob = vi.fn()
+  const closeProcess = vi.fn()
   return {
-    operations: { create, empty, terminate, close, ...overrides },
+    operations: { create, openProcess, pollProcess, empty, terminate, closeJob, closeProcess, ...overrides },
     create,
+    openProcess,
+    pollProcess,
     empty,
     terminate,
-    close,
+    closeJob,
+    closeProcess,
   }
 }
 
@@ -62,32 +85,37 @@ describe('Windows Job runner adapter', () => {
   })
 
   it('reports direct outcome separately from runner settlement', async () => {
-    const jobs = jobOperations()
-    const launch = launchWindowsJob(spec(['fake-target', '7']), {
+    const jobs = processOperations({ pollProcess: vi.fn(() => 7) })
+    const request = {
+      ...spec(['fake-target', '7']),
+      stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' } as const,
+    }
+    const launch = launchWindowsJob(request, {
       spawn,
       runnerInvocation: invocation,
-      jobs: jobs.operations,
+      operations: jobs.operations,
     })
     expect(launch.pid).toBeGreaterThan(0)
     await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null })
     await expect(launch.owner.waitForExit()).resolves.toBe(true)
     expect(jobs.create).toHaveBeenCalledOnce()
     expect(jobs.create.mock.calls[0]?.[0]).toMatch(/^Local\\dsh-subprocess-/u)
-    expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n)
+    expect(jobs.openProcess).toHaveBeenCalledWith(launch.pid)
+    expect(jobs.closeProcess).toHaveBeenCalledExactlyOnceWith(60n)
+    expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n)
   })
 
   it('signals and waits through the parent-owned Job', async () => {
-    const child = new EventEmitter() as ChildProcess
-    Object.assign(child, { pid: 321 })
+    const { child, disconnect } = fakeRunner(321)
     let eventsPath = ''
-    let empty = false
+    const state = { empty: false, exitCode: undefined as number | undefined }
     const terminate = vi.fn(() => {
-      empty = true
-      appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null })
-      child.emit('close', 0, null)
+      state.empty = true
+      state.exitCode = 1
     })
-    const jobs = jobOperations({
-      empty: vi.fn(() => empty),
+    const jobs = processOperations({
+      pollProcess: vi.fn(() => state.exitCode),
+      empty: vi.fn(() => state.empty),
       terminate,
     })
     const run = vi.fn((_command: string, args: readonly string[]) => {
@@ -98,22 +126,25 @@ describe('Windows Job runner adapter', () => {
     const launch = launchWindowsJob(spec(['fake-target']), {
       spawn: run,
       runnerInvocation: ['fake-runner'],
-      jobs: jobs.operations,
+      operations: jobs.operations,
     })
     launch.owner.signal('SIGTERM')
     await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null })
     await expect(launch.owner.waitForExit()).resolves.toBe(true)
     launch.owner.signal('SIGKILL')
+    expect(disconnect).toHaveBeenCalledOnce()
     expect(terminate).toHaveBeenCalledExactlyOnceWith(50n)
-    expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n)
+    expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n)
   })
 
   it('does not treat runner exit as proof that the Job is empty', async () => {
-    const child = new EventEmitter() as ChildProcess
-    Object.assign(child, { pid: 432 })
+    const { child, disconnect } = fakeRunner(432)
     let eventsPath = ''
-    let empty = false
-    const jobs = jobOperations({ empty: vi.fn(() => empty) })
+    const state = { empty: false, exitCode: undefined as number | undefined }
+    const jobs = processOperations({
+      pollProcess: vi.fn(() => state.exitCode),
+      empty: vi.fn(() => state.empty),
+    })
     const run = vi.fn((_command: string, args: readonly string[]) => {
       eventsPath = args[args.indexOf('--events') + 1] as string
       appendRunnerEvent(eventsPath, { type: 'started', pid: 432 })
@@ -122,23 +153,20 @@ describe('Windows Job runner adapter', () => {
     const launch = launchWindowsJob(spec(['fake-target']), {
       spawn: run,
       runnerInvocation: ['fake-runner'],
-      jobs: jobs.operations,
+      operations: jobs.operations,
     })
-    const directFailure = launch.direct.catch((error: unknown) => error)
-
-    child.emit('close', 127, null)
-
     await expect(launch.owner.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false)
-    await expect(directFailure).resolves.toBeInstanceOf(Error)
-    empty = true
+    state.exitCode = 0
+    await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
+    state.empty = true
     await expect(launch.owner.waitForExit()).resolves.toBe(true)
     launch.owner.signal('SIGKILL')
+    expect(disconnect).toHaveBeenCalledOnce()
     expect(jobs.terminate).not.toHaveBeenCalled()
   })
 
   it('reports Job termination failures through waitForExit', async () => {
-    const child = new EventEmitter() as ChildProcess
-    Object.assign(child, { pid: 654 })
+    const { child } = fakeRunner(654)
     let eventsPath = ''
     const run = vi.fn((_command: string, args: readonly string[]) => {
       eventsPath = args[args.indexOf('--events') + 1] as string
@@ -146,22 +174,21 @@ describe('Windows Job runner adapter', () => {
       return child
     }) as unknown as typeof spawn
     const failure = new Error('TerminateJobObject failed')
-    const jobs = jobOperations({ empty: vi.fn(() => false), terminate: vi.fn(() => { throw failure }) })
+    const jobs = processOperations({ empty: vi.fn(() => false), terminate: vi.fn(() => { throw failure }) })
     const launch = launchWindowsJob(spec(['fake-target']), {
       spawn: run,
       runnerInvocation: ['fake-runner'],
-      jobs: jobs.operations,
+      operations: jobs.operations,
     })
     void launch.direct.catch(() => {})
     launch.owner.signal('SIGTERM')
     await expect(launch.owner.waitForExit()).rejects.toBe(failure)
     await expect(launch.owner.waitForExit()).rejects.toBe(failure)
-    expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n)
+    expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n)
   })
 
   it('keeps a Job observation failure visible on repeated waits', async () => {
-    const child = new EventEmitter() as ChildProcess
-    Object.assign(child, { pid: 655 })
+    const { child } = fakeRunner(655)
     let eventsPath = ''
     const run = vi.fn((_command: string, args: readonly string[]) => {
       eventsPath = args[args.indexOf('--events') + 1] as string
@@ -169,42 +196,138 @@ describe('Windows Job runner adapter', () => {
       return child
     }) as unknown as typeof spawn
     const failure = new Error('QueryInformationJobObject failed')
-    const jobs = jobOperations({ empty: vi.fn(() => { throw failure }) })
+    const jobs = processOperations({ empty: vi.fn(() => { throw failure }) })
     const launch = launchWindowsJob(spec(['fake-target']), {
       spawn: run,
       runnerInvocation: ['fake-runner'],
-      jobs: jobs.operations,
+      operations: jobs.operations,
     })
-    appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null })
-    child.emit('close', 0, null)
     await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
     await expect(launch.owner.waitForExit()).rejects.toBe(failure)
     await expect(launch.owner.waitForExit()).rejects.toBe(failure)
-    expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n)
+    expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n)
+  })
+
+  it('reports direct-process observation failure and closes its handle', async () => {
+    const { child } = fakeRunner(656)
+    let eventsPath = ''
+    const run = vi.fn((_command: string, args: readonly string[]) => {
+      eventsPath = args[args.indexOf('--events') + 1] as string
+      appendRunnerEvent(eventsPath, { type: 'started', pid: 656 })
+      return child
+    }) as unknown as typeof spawn
+    const failure = new Error('WaitForSingleObject failed')
+    const jobs = processOperations({ pollProcess: vi.fn(() => { throw failure }) })
+    const launch = launchWindowsJob(spec(['fake-target']), {
+      spawn: run,
+      runnerInvocation: ['fake-runner'],
+      operations: jobs.operations,
+    })
+    await expect(launch.direct).rejects.toBe(failure)
+    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    expect(jobs.closeProcess).toHaveBeenCalledExactlyOnceWith(60n)
+  })
+
+  it('releases the runner when the parent cannot open the direct process', async () => {
+    const { child, disconnect } = fakeRunner(659)
+    let eventsPath = ''
+    const run = vi.fn((_command: string, args: readonly string[]) => {
+      eventsPath = args[args.indexOf('--events') + 1] as string
+      appendRunnerEvent(eventsPath, { type: 'started', pid: 659 })
+      return child
+    }) as unknown as typeof spawn
+    const failure = new Error('OpenProcess failed')
+    const jobs = processOperations({ openProcess: vi.fn(() => { throw failure }) })
+    const launch = launchWindowsJob(spec(['fake-target']), {
+      spawn: run,
+      runnerInvocation: ['fake-runner'],
+      operations: jobs.operations,
+    })
+    await expect(launch.direct).rejects.toBe(failure)
+    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    expect(disconnect).toHaveBeenCalledOnce()
+    expect(jobs.closeProcess).not.toHaveBeenCalled()
+  })
+
+  it('reports a failed runner release after acquiring direct-process observation', async () => {
+    const child = new EventEmitter() as ChildProcess
+    const failure = new Error('IPC disconnect failed')
+    const kill = vi.fn(() => {
+      queueMicrotask(() => { child.emit('exit', 0, null) })
+      return true
+    })
+    Object.assign(child, {
+      pid: 657,
+      connected: true,
+      disconnect: vi.fn(() => { throw failure }),
+      kill,
+    })
+    let eventsPath = ''
+    const run = vi.fn((_command: string, args: readonly string[]) => {
+      eventsPath = args[args.indexOf('--events') + 1] as string
+      appendRunnerEvent(eventsPath, { type: 'started', pid: 657 })
+      return child
+    }) as unknown as typeof spawn
+    const jobs = processOperations()
+    const launch = launchWindowsJob(spec(['fake-target']), {
+      spawn: run,
+      runnerInvocation: ['fake-runner'],
+      operations: jobs.operations,
+    })
+    await expect(launch.direct).rejects.toBe(failure)
+    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    expect(kill).toHaveBeenCalledOnce()
+  })
+
+  it('keeps collected settlement pending until the runner and collected streams close', async () => {
+    const { child } = fakeRunner(658)
+    const stdout = new PassThrough()
+    const stderr = new PassThrough()
+    Object.assign(child, { stdout, stderr })
+    let eventsPath = ''
+    const run = vi.fn((_command: string, args: readonly string[]) => {
+      eventsPath = args[args.indexOf('--events') + 1] as string
+      appendRunnerEvent(eventsPath, { type: 'started', pid: 658 })
+      return child
+    }) as unknown as typeof spawn
+    const jobs = processOperations()
+    const launch = launchWindowsJob(spec(['fake-target']), {
+      spawn: run,
+      runnerInvocation: ['fake-runner'],
+      operations: jobs.operations,
+    })
+    let closed = false
+    void launch.closed.then(() => { closed = true })
+    await new Promise(resolve => setImmediate(resolve))
+    expect(closed).toBe(false)
+    stdout.resume()
+    stderr.resume()
+    stdout.end()
+    stderr.end()
+    await expect(launch.closed).resolves.toBeUndefined()
   })
 
   it('closes the parent Job when spawning the runner throws synchronously', () => {
     const failure = new Error('runner spawn failed')
-    const jobs = jobOperations()
+    const jobs = processOperations()
     expect(() => launchWindowsJob(spec(['fake-target']), {
       spawn: vi.fn(() => { throw failure }) as unknown as typeof spawn,
       runnerInvocation: ['fake-runner'],
-      jobs: jobs.operations,
+      operations: jobs.operations,
     })).toThrow(failure)
-    expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n)
+    expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n)
   })
 
   it('passes a generated Job name to the runner and rejects an empty invocation', async () => {
-    const emptyJobs = jobOperations()
+    const emptyJobs = processOperations()
     expect(() => launchWindowsJob(spec(['fake-target']), {
       runnerInvocation: [],
-      jobs: emptyJobs.operations,
+      operations: emptyJobs.operations,
     }))
       .toThrow('Windows runner invocation is empty')
     expect(emptyJobs.create).not.toHaveBeenCalled()
 
-    const child = new EventEmitter() as ChildProcess
-    Object.assign(child, { pid: 987 })
+    const { child, disconnect } = fakeRunner(987)
     let eventsPath = ''
     let jobName = ''
     const run = vi.fn((_command: string, args: readonly string[]) => {
@@ -213,16 +336,15 @@ describe('Windows Job runner adapter', () => {
       appendRunnerEvent(eventsPath, { type: 'started', pid: 987 })
       return child
     }) as unknown as typeof spawn
-    const jobs = jobOperations()
+    const jobs = processOperations()
     const launch = launchWindowsJob(spec(['fake-target']), {
       spawn: run,
       runnerInvocation: ['fake-runner'],
-      jobs: jobs.operations,
+      operations: jobs.operations,
     })
-    appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null })
-    child.emit('close', 0, null)
     await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
     await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    expect(disconnect).toHaveBeenCalledOnce()
     expect(jobName).toMatch(/^Local\\dsh-subprocess-/u)
     expect(jobs.create).toHaveBeenCalledExactlyOnceWith(jobName)
   })

+ 2 - 2
packages/subprocess/win32-process/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/win32-process/README.md
-README.md: 71130d4289716e55d645743c5c9e9f6fa4cb40ca
-README.zh.md: 3de82596dffdfa264ebd31ad87517b2e8e330052
+README.md: 61c9272a24e9007e2944992db6db1211942cdc99
+README.zh.md: 64ade89692a5a6edd73fe8a9cfdf214f8cb67405

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

@@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi
 - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup.
 - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime.
 - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle.
-- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job, the runner opens it for assignment, and `spawnOrdinaryProcessInJob()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A blocking process wait inside the isolated runner publishes direct exit, while the parent polls `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero.
-- **Explicit settlement ownership** — `waitForProcessExit()` waits for and closes a sandbox or ordinary-runner process handle; parent-owned Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles.
+- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job, the runner opens it for assignment, and `spawnOrdinaryProcessInJob()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. Before releasing the runner, the parent opens a separate process handle and polls its zero-time state for the direct result; Job accounting independently continues until `ActiveProcesses` reaches zero.
+- **Explicit settlement ownership** — `waitForProcessExit()` waits for and closes a sandbox process handle; ordinary parent-side process polling and Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles.
 
 The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives.
 

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

@@ -10,8 +10,8 @@
 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。
 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。
 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。
-- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job,runner 以 assignment 权限打开该 Job,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期。隔离 runner 内的 blocking process wait 会发布 direct exit,parent 则轮询 `QueryInformationJobObject(JobObjectBasicAccountingInformation)` 直到 `ActiveProcesses` 归零。
-- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox 或 ordinary runner 的 process handle;parent-owned Job 的 accounting、termination 与 closure 仍是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。
+- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job,runner 以 assignment 权限打开该 Job,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期。释放 runner 前,parent 会打开另一个 process handle,并轮询其 zero-time state 得到 direct result;Job accounting 则独立持续到 `ActiveProcesses` 归零。
+- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary parent-side process polling 与 Job accounting、termination、closure 保持独立。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。
 
 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。
 

+ 6 - 0
packages/subprocess/win32-process/src/abi.ts

@@ -6,8 +6,14 @@ export const STARTF_USESTDHANDLES = 0x00000100
 export const HANDLE_FLAG_INHERIT = 0x1
 /** Infinite WaitForSingleObject timeout. */
 export const INFINITE = 0xFFFFFFFF
+/** WaitForSingleObject returned because a zero-time probe is not signalled. */
+export const WAIT_TIMEOUT = 258
 /** CreateProcess flag that prevents user code from running before resume. */
 export const CREATE_SUSPENDED = 0x4
+/** OpenProcess right required to read limited process information. */
+export const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
+/** Standard right required to wait on a process handle. */
+export const SYNCHRONIZE = 0x00100000
 /** GetStdHandle selector for standard input. */
 export const STD_INPUT_HANDLE = -10
 /** GetStdHandle selector for standard output. */

+ 2 - 0
packages/subprocess/win32-process/src/ffi.ts

@@ -57,6 +57,7 @@ export interface ProcessInfoOutput {
 export interface Win32ProcessBindings {
   closeHandle(handle: NativePtr): number
   getLastError(): number
+  openProcess(desiredAccess: number, inheritHandle: number, processId: number): NativePtr
   formatMessageW(
     flags: number,
     source: null,
@@ -253,6 +254,7 @@ function bindings(): Win32ProcessBindings {
   cached = {
     closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]),
     getLastError: bind(kernel32, 'GetLastError', 'uint32', []),
+    openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']),
     formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', [
       'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID,
     ]),

+ 2 - 0
packages/subprocess/win32-process/src/index.ts

@@ -23,6 +23,8 @@ export {
   drainPipe,
   isJobEmpty,
   openJobForAssignment,
+  openProcessForWait,
+  pollProcessExit,
   spawnInheritedJobProcess,
   spawnOrdinaryProcessInJob,
   spawnPipedProcess,

+ 31 - 0
packages/subprocess/win32-process/src/process.ts

@@ -346,6 +346,18 @@ export function openJobForAssignment(api: Win32ProcessBindings, name: string): N
   return job
 }
 
+/**
+ * Open a process for non-blocking exit observation.
+ * @param api - active binding table.
+ * @param pid - direct process id published by the launcher.
+ * @returns caller-owned process handle with query and synchronize access.
+ */
+export function openProcessForWait(api: Win32ProcessBindings, pid: number): NativePtr {
+  const process = api.openProcess(abi.PROCESS_QUERY_LIMITED_INFORMATION | abi.SYNCHRONIZE, 0, pid)
+  if (isNullPtr(process)) throwLastError(api, 'OpenProcess', `pid ${String(pid)}`)
+  return process
+}
+
 /** Shared suspended-create, Job-assignment, and resume lifecycle. */
 function spawnJobProcess(
   api: Win32ProcessBindings,
@@ -502,6 +514,25 @@ export function spawnOrdinaryProcessInJob(
     ))
 }
 
+/**
+ * Poll one process handle without blocking the caller event loop.
+ * @param api - active binding table.
+ * @param process - caller-owned process handle.
+ * @returns the direct exit code when signalled, or undefined while running.
+ */
+export function pollProcessExit(api: Win32ProcessBindings, process: NativePtr): number | undefined {
+  const waitResult = api.waitForSingleObject(process, 0)
+  if (waitResult === abi.WAIT_TIMEOUT) return undefined
+  if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject')
+  const exitCodeSlot = allocUint32()
+  try {
+    if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess')
+    return decodeUint32(exitCodeSlot)
+  } finally {
+    koffi.free(exitCodeSlot)
+  }
+}
+
 /**
  * Return whether a Job has no active processes.
  * @param api - active binding table.

+ 32 - 4
packages/subprocess/win32-process/tests/ordinary-process.spec.ts

@@ -5,6 +5,8 @@ import {
   createKillOnCloseJob,
   isJobEmpty,
   openJobForAssignment,
+  openProcessForWait,
+  pollProcessExit,
   spawnOrdinaryProcessInJob,
   terminateJob,
   Win32Error,
@@ -15,6 +17,9 @@ import {
   JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET,
   JOBOBJECT_BASIC_ACCOUNTING_SIZE,
   JobObjectBasicAccountingInformation,
+  PROCESS_QUERY_LIMITED_INFORMATION,
+  SYNCHRONIZE,
+  WAIT_TIMEOUT,
 } from '../src/abi.ts'
 import { PROCESS_INFORMATION } from '../src/ffi.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
@@ -23,6 +28,7 @@ function api(overrides: Partial<Win32ProcessBindings> = {}): Win32ProcessBinding
   return {
     createJobObjectW: vi.fn(() => 50n),
     openJobObjectW: vi.fn(() => 55n),
+    openProcess: vi.fn(() => 60n),
     setInformationJobObject: vi.fn(() => 1),
     queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
       information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
@@ -136,12 +142,16 @@ describe('ordinary Job process operations', () => {
     expect(closeHandle).not.toHaveBeenCalledWith(50n)
   })
 
-  it('reads Job emptiness without blocking', () => {
+  it('polls a parent-owned process handle and reads Job emptiness without blocking', () => {
     const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
       information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
       return 1
     })
-    const running = api({ queryInformationJobObject })
+    const running = api({
+      waitForSingleObject: vi.fn(() => WAIT_TIMEOUT),
+      queryInformationJobObject,
+    })
+    expect(pollProcessExit(running, 60n as NativePtr)).toBeUndefined()
     expect(isJobEmpty(running, 50n as NativePtr)).toBe(false)
     expect(queryInformationJobObject).toHaveBeenCalledWith(
       50n,
@@ -150,10 +160,18 @@ describe('ordinary Job process operations', () => {
       JOBOBJECT_BASIC_ACCOUNTING_SIZE,
       null,
     )
-    expect(isJobEmpty(api(), 50n as NativePtr)).toBe(true)
+    const exited = api()
+    expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42)
+    expect(isJobEmpty(exited, 50n as NativePtr)).toBe(true)
   })
 
-  it('reports a Job accounting query failure', () => {
+  it('reports process wait, exit-code query, and Job accounting failures', () => {
+    const processWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) })
+    expect(() => pollProcessExit(processWait, 60n as NativePtr)).toThrow(Win32Error)
+
+    const exitCode = api({ getExitCodeProcess: vi.fn(() => 0) })
+    expect(() => pollProcessExit(exitCode, 60n as NativePtr)).toThrow(Win32Error)
+
     const jobQuery = api({ queryInformationJobObject: vi.fn(() => 0) })
     expect(() => isJobEmpty(jobQuery, 50n as NativePtr)).toThrow(Win32Error)
   })
@@ -181,4 +199,14 @@ describe('ordinary Job process operations', () => {
     const missing = api({ openJobObjectW: vi.fn(() => 0n as NativePtr) })
     expect(() => openJobForAssignment(missing, 'Local\\missing-job')).toThrow(Win32Error)
   })
+
+  it('opens a direct process for parent-side exit observation', () => {
+    const openProcess = vi.fn(() => 60n as NativePtr)
+    const bindings = api({ openProcess })
+    expect(openProcessForWait(bindings, 1234)).toBe(60n)
+    expect(openProcess).toHaveBeenCalledWith(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, 1234)
+
+    const missing = api({ openProcess: vi.fn(() => 0n as NativePtr) })
+    expect(() => openProcessForWait(missing, 1234)).toThrow(Win32Error)
+  })
 })

+ 6 - 0
packages/subprocess/win32-process/verify/abi-probe.cpp

@@ -21,6 +21,9 @@ int wmain()
   P(STARTF_USESTDHANDLES);
   P(HANDLE_FLAG_INHERIT);
   P(INFINITE);
+  P(WAIT_TIMEOUT);
+  P(PROCESS_QUERY_LIMITED_INFORMATION);
+  P(SYNCHRONIZE);
   P(STD_INPUT_HANDLE);
   P(STD_OUTPUT_HANDLE);
   P(STD_ERROR_HANDLE);
@@ -42,6 +45,9 @@ int wmain()
   static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag");
   static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag");
   static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag");
+  static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout");
+  static_assert(PROCESS_QUERY_LIMITED_INFORMATION == 0x1000, "limited process query right");
+  static_assert(SYNCHRONIZE == 0x100000, "synchronize right");
   static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size");
   static_assert(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses) == 40, "active process offset");
   static_assert(JobObjectBasicAccountingInformation == 1, "basic accounting class");