Kaynağa Gözat

fix(subprocess): keep Windows Job ownership in parent

pku-xht 3 hafta önce
ebeveyn
işleme
ea7de60840
26 değiştirilmiş dosya ile 408 ekleme ve 297 silme
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md
  4. 2 2
      .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml
  5. 1 1
      .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md
  6. 1 1
      .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md
  7. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml
  8. 3 3
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md
  9. 3 3
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md
  10. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  11. 3 3
      packages/subprocess/subprocess-local/README.md
  12. 3 3
      packages/subprocess/subprocess-local/README.zh.md
  13. 2 5
      packages/subprocess/subprocess-local/src/runner-launch.ts
  14. 20 66
      packages/subprocess/subprocess-local/src/spawn-runner.ts
  15. 104 48
      packages/subprocess/subprocess-local/src/windows-job.ts
  16. 2 15
      packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts
  17. 2 2
      packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts
  18. 137 96
      packages/subprocess/subprocess-local/tests/windows-job.spec.ts
  19. 2 2
      packages/subprocess/win32-process/README.i18n.yaml
  20. 2 2
      packages/subprocess/win32-process/README.md
  21. 2 2
      packages/subprocess/win32-process/README.zh.md
  22. 2 0
      packages/subprocess/win32-process/src/abi.ts
  23. 6 4
      packages/subprocess/win32-process/src/ffi.ts
  24. 5 2
      packages/subprocess/win32-process/src/index.ts
  25. 57 25
      packages/subprocess/win32-process/src/process.ts
  26. 41 4
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts

+ 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: a3ab8ebcfac7c2ad3429bcea2993fb5281a9d2e0
-2026-08-19-shared-win32-process-primitives.zh.md: e64f7537cb54e9eb1aaeb3dcf3b37cf300721b36
+2026-08-19-shared-win32-process-primitives.md: 67ee4527cdbb63724e371707bfa2ef19f4a86838
+2026-08-19-shared-win32-process-primitives.zh.md: 4c7a683c38c03dbc64a3fd77f33d71daf7b7a209

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

@@ -14,7 +14,7 @@ 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 polls the direct process separately and closes the Job only after it is empty.
+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 polls the direct process separately, while the subprocess parent owns 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.
 

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

@@ -14,7 +14,7 @@ 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,并只在 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
 
 该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-stdio release、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-11-synchronous-subprocess-exit-cleanup.md
-2026-08-11-synchronous-subprocess-exit-cleanup.md: 4f0603f6e2cb3c0cc4c0d4c0cde1b0da0ae82763
-2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: acb20c8989d21b1fd5e97fa44247cd2875fb4570
+2026-08-11-synchronous-subprocess-exit-cleanup.md: 8e0e9b338f1c0f5f2aa0d6a7acde5807025f82ad
+2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 723d93c9bfb6898563b10c05ac2705a3dee50646

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md

@@ -16,7 +16,7 @@ The public subprocess seam correctly promises awaited quiescence during normal d
 
 The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces:
 
-- An ordinary handle synchronously signals its bound native scope or Job runner when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID <pid> /T /F` on Windows.
+- An ordinary handle synchronously signals its bound native scope or parent-held Job when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID <pid> /T /F` on Windows.
 - A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary.
 - The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作:
 
-- 普通 handle在可用时同步向绑定的 native scope 或 Job runner 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID <pid> /T /F`。
+- 普通 handle在可用时同步向绑定的 native scope 或 parent-held Job 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID <pid> /T /F`。
 - Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。
 - 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。
 

+ 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: c1f9901c10e1ac09304bba10805ec019e8ae3247
-2026-08-20-subprocess-native-containment.zh.md: 1a3bddf797b1a9c4c189501a733a1242cb153b06
+2026-08-20-subprocess-native-containment.md: 4ff86b626f8d0fbb7c09ce82f5115774199e397a
+2026-08-20-subprocess-native-containment.zh.md: 4f8390974f2d3a4e34704dce574d45079c9a27be

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

@@ -10,11 +10,11 @@ The local subprocess provider treated a POSIX process group or a Windows direct-
 
 ## Decision
 
-`LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities.
+`LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows creates and retains a named kill-on-close Job; a local runner backed by `@deepseek-ai/dsh-win32-process` opens that Job, creates the target suspended, assigns it, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities.
 
 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 Job by default; after target creation, the runner releases its own standard-handle copies before publishing startup, so pipe EOF follows the target and descendants that actually inherited the stream. The runner remains until the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit.
+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.
 
 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 native range retains one runner process until settlement. 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, 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.

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

@@ -10,11 +10,11 @@ Status: implemented
 
 ## Decision
 
-`LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。
+`LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 创建并持有 named kill-on-close Job;由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。
 
 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 默认继承 Job;target 创建后,runner 会在发布启动事实前释放自身持有的标准句柄副本,因此 pipe EOF 取决于 target 与实际继承该流的 descendant。runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job
+Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;如果 scope KILL 阻止最终 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 会终止剩余成员
 
 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 秒,每个 native range 还会保留一个 runner process 直到 settlement。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 秒,每个 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 宿主继续可用,但保证会被明确削弱。

+ 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: af971f2f9706117d9a7be8fdeb29eea4d33f72e9
-README.zh.md: e829dce28d2e70d75665aa0de25611f445a49e6a
+README.md: a2766a3a90f14c69727d2708d92927e03334ed1f
+README.zh.md: d26b01ebebd0fe3ccd37b4771f1d224b96973bfd

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

@@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA
 
 ## Behavior
 
-- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. The Windows runner releases its own standard-handle copies before publishing target start, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than the Job observer's lifetime. 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, 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.
 - **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).
@@ -14,7 +14,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA
 - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd.
 - **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
 - **Terminate-and-join disposal** — the service retains live handles so its own disposal can run each provider-owned termination procedure and await its exit; quiescent and spawn-failed handles leave the live set after managed-range or terminal-session cleanup finishes.
-- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md).
+- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the parent-owned Windows Job receives immediate termination and its handle also closes with the host; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md).
 
 ## Model Experience
 
@@ -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 supported native command also keeps one runner process alive until the OS-owned range is empty. 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. 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.
 - **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.

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

@@ -6,7 +6,7 @@
 
 ## 行为
 
-- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。Windows runner 会在发布 target start 前释放自身持有的标准句柄副本,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observer 的生命周期。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 状态创建目标、完成分配后再恢复,随后关闭自己的 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 保留既有有界排空宽限期。
 - **明确披露较弱 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)。
@@ -14,7 +14,7 @@
 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
 - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能执行每个 provider-owned termination procedure 并等待其退出;完全停稳与 spawn 失败的句柄会在 managed range 或 terminal session 清理完成后离开存活集合。
-- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。
+- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;parent-owned Windows Job 会被立即终止,其 handle 也会随 host 关闭;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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。每条受支持的 native command 还会保留一个 runner process,直到 OS-owned range 为空。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。每个 runner 只保留到 direct target result,后续 descendant 则继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,runner event 每 100 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` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。

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

@@ -41,17 +41,14 @@ export function spawnRunnerInvocation(): string[] {
 /**
  * Build wrapper stdio corresponding to the public target dispositions.
  * @param spec - target stdio request.
- * @param ipc - append a Node IPC channel for the Windows runner.
  * @returns child-process stdio configuration.
  */
-export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions {
-  const stdio: StdioOptions = [
+export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions {
+  return [
     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
 }
 
 /**

+ 20 - 66
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -1,14 +1,12 @@
 /** Native managed-range runner for ordinary local subprocesses. */
 
 import { spawn } from 'node:child_process'
-import { closeSync } from 'node:fs'
 import {
   closeHandleChecked,
-  isJobEmpty,
   loadWin32ProcessBindings,
+  openJobForAssignment,
   pollProcessExit,
-  spawnOrdinaryJobProcess,
-  terminateJob,
+  spawnOrdinaryProcessInJob,
   Win32Error,
 } from '@deepseek-ai/dsh-win32-process'
 import type { NativePtr } from '@deepseek-ai/dsh-win32-process'
@@ -22,10 +20,12 @@ import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts'
 type RunnerArgs =
   | { mode: 'probe-node' }
   | { mode: 'probe-win32' }
-  | { mode: 'node' | 'win32'; requestPath: string; eventsPath: string }
+  | { mode: 'node'; requestPath: string; eventsPath: string }
+  | { mode: 'win32'; requestPath: string; eventsPath: string; jobName: string }
 
 function parseArgs(argv: string[]): RunnerArgs {
   let mode: string | undefined
+  let jobName: string | undefined
   let requestPath: string | undefined
   let eventsPath: string | undefined
   for (let index = 0; index < argv.length; index += 2) {
@@ -33,6 +33,7 @@ function parseArgs(argv: string[]): RunnerArgs {
     const value = argv[index + 1]
     if (value === undefined) throw new Error(`subprocess runner missing value after ${String(key)}`)
     if (key === '--mode') mode = value
+    else if (key === '--job') jobName = value
     else if (key === '--request') requestPath = value
     else if (key === '--events') eventsPath = value
     else throw new Error(`subprocess runner unknown argument: ${String(key)}`)
@@ -40,6 +41,10 @@ function parseArgs(argv: string[]): RunnerArgs {
   if (mode === 'probe-node' || mode === 'probe-win32') return { mode }
   if (mode !== 'node' && mode !== 'win32') throw new Error(`subprocess runner unknown mode: ${String(mode)}`)
   if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths')
+  if (mode === 'win32') {
+    if (jobName === undefined || jobName.length === 0) throw new Error('subprocess runner requires a Windows Job name')
+    return { mode, requestPath, eventsPath, jobName }
+  }
   return { mode, requestPath, eventsPath }
 }
 
@@ -97,62 +102,21 @@ function replaceEnvironment(env: Record<string, string>): void {
   Object.assign(process.env, env)
 }
 
-interface MaterializedStdioStream {
-  readonly _handle?: { close(): void } | null
-}
-
-/** Release the runner's copies after the Windows target inherits its standard handles. */
-function releaseRunnerStdio(): void {
-  const stdin = process.stdin
-  const stdout = process.stdout
-  const stderr = process.stderr
-  const stdoutHandle = (stdout as unknown as MaterializedStdioStream)._handle
-  const stderrHandle = (stderr as unknown as MaterializedStdioStream)._handle
-  stdin.destroy()
-  for (const fd of [0, 1, 2]) {
-    try {
-      closeSync(fd)
-    } catch (error) {
-      if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error
-    }
-  }
-  // Node deliberately keeps stdout/stderr alive when destroy() is called. A
-  // loader may already have materialized their libuv handles, so close those
-  // runner-owned references explicitly; the target keeps its inherited copies.
-  stdoutHandle?.close()
-  stderrHandle?.close()
-}
-
-async function runWin32(request: RunnerRequest, eventsPath: string): Promise<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 {
-    let spawned
-    try {
-      process.chdir(request.cwd)
-      const [command, ...args] = request.argv
-      spawned = spawnOrdinaryJobProcess(api, { command: command as string, args, cwd: request.cwd })
-    } catch (error) {
-      appendRunnerEvent(eventsPath, { type: 'spawn-error', error: win32SpawnError(error, request) })
-      return
-    }
+    process.chdir(request.cwd)
+    jobHandle = openJobForAssignment(api, jobName)
+    const [command, ...args] = request.argv
+    const spawned = spawnOrdinaryProcessInJob(api, { command: command as string, args, cwd: process.cwd() }, jobHandle)
     processHandle = spawned.process
-    jobHandle = spawned.job
     targetStarted = true
-    let terminationRequested = false
-    const terminate = (): void => {
-      if (terminationRequested || jobHandle === undefined) return
-      terminationRequested = true
-      terminateJob(api, jobHandle, 1)
-    }
-    process.on('message', (message: unknown) => {
-      if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate()
-    })
-    process.on('disconnect', terminate)
-    releaseRunnerStdio()
+    closeHandleChecked(api, jobHandle, 'ordinary process Job assignment')
+    jobHandle = undefined
     appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid })
 
     await new Promise<void>((resolve, reject) => {
@@ -164,14 +128,10 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise<voi
               appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null })
               closeHandleChecked(api, processHandle, 'ordinary direct process')
               processHandle = undefined
+              clearInterval(timer)
+              resolve()
             }
           }
-          if (processHandle === undefined && jobHandle !== undefined && isJobEmpty(api, jobHandle)) {
-            closeHandleChecked(api, jobHandle, 'ordinary process Job')
-            jobHandle = undefined
-            clearInterval(timer)
-            resolve()
-          }
         } catch (error) {
           clearInterval(timer)
           reject(error instanceof Error ? error : new Error(String(error)))
@@ -203,13 +163,7 @@ async function main(): Promise<void> {
   }
   const request = consumeRunnerRequest(args.requestPath)
   if (args.mode === 'node') runNode(request, args.eventsPath)
-  else {
-    try {
-      await runWin32(request, args.eventsPath)
-    } finally {
-      if (process.connected) process.disconnect()
-    }
-  }
+  else await runWin32(request, args.eventsPath, args.jobName)
 }
 
 main().catch((error: unknown) => {

+ 104 - 48
packages/subprocess/subprocess-local/src/windows-job.ts

@@ -1,7 +1,17 @@
 /** Windows Job runner launch and managed-range ownership. */
 
 import { spawn, spawnSync } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import { setTimeout as sleepMs } from 'node:timers/promises'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import {
+  closeHandleChecked,
+  createKillOnCloseJob,
+  isJobEmpty,
+  loadWin32ProcessBindings,
+  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 { childEnv } from './spawn.ts'
@@ -12,12 +22,35 @@ import {
   runnerStdio,
   spawnRunnerInvocation,
 } from './runner-launch.ts'
+import { cleanupRunnerFiles } from './runner-protocol.ts'
+
+const JOB_POLL_INTERVAL_MS = 10
+
+/** Parent-side operations for one Windows Job handle. */
+export interface WindowsJobOperations {
+  create(name: string): NativePtr
+  empty(job: NativePtr): boolean
+  terminate(job: NativePtr): void
+  close(job: NativePtr): void
+}
+
+function nativeJobOperations(): WindowsJobOperations {
+  const api = loadWin32ProcessBindings()
+  return {
+    create: name => createKillOnCloseJob(api, name),
+    empty: job => isJobEmpty(api, job),
+    terminate: (job) => { terminateJob(api, job, 1) },
+    close: (job) => { closeHandleChecked(api, job, 'ordinary process Job') },
+  }
+}
 
 /** Test seams for the runner process. */
 export interface WindowsJobInternals {
   spawn?: typeof spawn
   spawnSync?: typeof spawnSync
   runnerInvocation?: string[]
+  jobs?: WindowsJobOperations
+  jobName?: () => string
 }
 
 /**
@@ -39,53 +72,57 @@ export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean {
 
 class WindowsJobOwner implements BoundProcessOwner {
   private stopped = false
-  private runnerClosed = false
-  private readonly observation: Promise<void>
+  private closed = false
+  private terminationRequested = false
+  private terminationFailure: Error | undefined
+  private observation: Promise<void> | undefined
 
-  constructor(private readonly runner: ReturnType<typeof spawn>) {
-    this.observation = new Promise((resolve, reject) => {
-      runner.once('close', (exitCode, signal) => {
-        this.runnerClosed = true
-        if (exitCode === 0 && signal === null) {
-          this.stopped = true
-          resolve()
-          return
-        }
-        const status = signal !== null
-          ? `signal ${signal}`
-          : exitCode === null
-            ? 'without an exit status'
-            : `exit code ${String(exitCode)}`
-        reject(new Error(
-          `subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`,
-        ))
-      })
-    })
-    void this.observation.catch(() => {})
-  }
+  constructor(
+    private readonly job: NativePtr,
+    private readonly operations: WindowsJobOperations,
+    private readonly runnerClosed: Promise<void>,
+  ) {}
 
   signal(_signal: NodeJS.Signals): void {
-    if (this.stopped || this.runnerClosed) return
+    if (this.stopped || this.terminationRequested) return
+    this.terminationRequested = true
     try {
-      if (this.runner.connected) {
-        this.runner.send({ type: 'terminate' }, (error) => {
-          if (error !== null) this.runner.kill()
-        })
-      } else {
-        this.runner.kill()
-      }
-    } catch {
-      this.runner.kill()
+      this.operations.terminate(this.job)
+    } catch (error) {
+      this.terminationFailure = error instanceof Error ? error : new Error(String(error))
     }
   }
 
   waitForExit(signal?: AbortSignal): Promise<boolean> {
-    return this.stopped ? Promise.resolve(true) : waitWithAbort(this.observation, signal)
+    if (this.observation !== undefined) return waitWithAbort(this.observation, signal)
+    if (this.stopped) return Promise.resolve(true)
+    this.observation = (async () => {
+      try {
+        while (!this.operations.empty(this.job)) {
+          if (this.terminationFailure !== undefined) throw this.terminationFailure
+          await sleepMs(JOB_POLL_INTERVAL_MS)
+        }
+        this.stopped = true
+        this.close()
+        await this.runnerClosed
+      } catch (error) {
+        this.stopped = true
+        try { this.close() } catch { /* Preserve the observation failure. */ }
+        throw error
+      }
+    })()
+    return waitWithAbort(this.observation, signal)
+  }
+
+  private close(): void {
+    if (this.closed) return
+    this.operations.close(this.job)
+    this.closed = true
   }
 }
 
 /**
- * Launch one direct command through the Job-owning runner.
+ * Launch one direct command through a runner into a parent-owned Job.
  * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings.
  * @param internals - injected process runner used by tests.
  * @returns wrapper streams, target outcome, and the bound Job owner.
@@ -98,21 +135,40 @@ export function launchWindowsJob(
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   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 jobName = (internals.jobName ?? (() => `Local\\dsh-subprocess-${randomUUID()}`))()
   const files = runnerFiles(spec)
-  const child = run(command, [
-    ...prefix,
-    '--mode',
-    'win32',
-    '--request',
-    files.requestPath,
-    '--events',
-    files.eventsPath,
-  ], {
-    env: childEnv(),
-    stdio: runnerStdio(spec, true),
-  })
+  let job: NativePtr
+  try {
+    job = jobs.create(jobName)
+  } catch (error) {
+    cleanupRunnerFiles(files)
+    throw error
+  }
+  let child: ReturnType<typeof spawn>
+  try {
+    child = run(command, [
+      ...prefix,
+      '--mode',
+      'win32',
+      '--job',
+      jobName,
+      '--request',
+      files.requestPath,
+      '--events',
+      files.eventsPath,
+    ], {
+      env: childEnv(),
+      stdio: runnerStdio(spec),
+    })
+  } catch (error) {
+    try { jobs.close(job) } catch { /* Preserve the launch failure. */ }
+    cleanupRunnerFiles(files)
+    throw error
+  }
   const closed = observeChildClose(child)
-  const owner = new WindowsJobOwner(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 }

+ 2 - 15
packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts

@@ -7,25 +7,12 @@ const request = consumeRunnerRequest(requestPath)
 appendRunnerEvent(eventsPath, { type: 'started', pid: process.pid })
 
 const configuredExit = Number(request.argv[1])
-// Events carry target results; zero means the runner completed its own observation.
+// Events carry target results; zero means the runner completed its own work.
 if (Number.isSafeInteger(configuredExit)) {
   setTimeout(() => {
     appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null })
     process.exitCode = 0
   }, 10)
 } else {
-  const hold = setInterval(() => {}, 1_000)
-  let terminated = false
-  const terminate = (): void => {
-    if (terminated) return
-    terminated = true
-    appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null })
-    clearInterval(hold)
-    if (process.connected) process.disconnect()
-    process.exitCode = 0
-  }
-  process.on('message', (message: unknown) => {
-    if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate()
-  })
-  process.on('disconnect', terminate)
+  setInterval(() => {}, 1_000)
 }

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

@@ -85,11 +85,11 @@ describe('spawn runner transport', () => {
     expect(result.status).toBe(0)
   })
 
-  it('maps every target stdio disposition and optional IPC channel', () => {
+  it('maps every target stdio disposition', () => {
     expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
     expect(runnerStdio(spec({
       stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' },
-    }), true)).toEqual(['pipe', 'inherit', 'inherit', 'ipc'])
+    }))).toEqual(['pipe', 'inherit', 'inherit'])
   })
 
   it('materializes and consumes the exact runner request once', () => {

+ 137 - 96
packages/subprocess/subprocess-local/tests/windows-job.spec.ts

@@ -4,8 +4,10 @@ import { EventEmitter } from 'node:events'
 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'
 
 const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url))
 const invocation = [process.execPath, '--import', 'tsx/esm', fixture]
@@ -19,6 +21,26 @@ function spec(argv: string[]): SubprocessSpawnSpec {
   }
 }
 
+function jobOperations(overrides: Partial<WindowsJobOperations> = {}): {
+  operations: WindowsJobOperations
+  create: ReturnType<typeof vi.fn>
+  empty: ReturnType<typeof vi.fn>
+  terminate: ReturnType<typeof vi.fn>
+  close: ReturnType<typeof vi.fn>
+} {
+  const create = vi.fn(() => 50n as NativePtr)
+  const empty = vi.fn(() => true)
+  const terminate = vi.fn()
+  const close = vi.fn()
+  return {
+    operations: { create, empty, terminate, close, ...overrides },
+    create,
+    empty,
+    terminate,
+    close,
+  }
+}
+
 describe('Windows Job runner adapter', () => {
   it('probes the runner before a user command is selected', () => {
     const runSync = vi.fn(() => ({ status: 0, error: undefined })) as unknown as typeof spawnSync
@@ -40,153 +62,172 @@ describe('Windows Job runner adapter', () => {
   })
 
   it('reports direct outcome separately from runner settlement', async () => {
+    const jobs = jobOperations()
     const launch = launchWindowsJob(spec(['fake-target', '7']), {
       spawn,
       runnerInvocation: invocation,
+      jobs: jobs.operations,
+      jobName: () => 'Local\\dsh-test-job',
     })
     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).toHaveBeenCalledExactlyOnceWith('Local\\dsh-test-job')
+    expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n)
   })
 
-  it('signals the Job runner and waits for its managed range to stop', async () => {
+  it('signals and waits through the parent-owned Job', async () => {
+    const child = new EventEmitter() as ChildProcess
+    Object.assign(child, { pid: 321 })
+    let eventsPath = ''
+    let empty = false
+    const terminate = vi.fn(() => {
+      empty = true
+      appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null })
+      child.emit('close', 0, null)
+    })
+    const jobs = jobOperations({
+      empty: vi.fn(() => empty),
+      terminate,
+    })
+    const run = vi.fn((_command: string, args: readonly string[]) => {
+      eventsPath = args[args.indexOf('--events') + 1] as string
+      appendRunnerEvent(eventsPath, { type: 'started', pid: 321 })
+      return child
+    }) as unknown as typeof spawn
     const launch = launchWindowsJob(spec(['fake-target']), {
-      spawn,
-      runnerInvocation: invocation,
+      spawn: run,
+      runnerInvocation: ['fake-runner'],
+      jobs: jobs.operations,
+      jobName: () => 'Local\\dsh-test-job',
     })
     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(terminate).toHaveBeenCalledExactlyOnceWith(50n)
+    expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n)
   })
 
-  it.each([
-    { exitCode: 127, signal: null, status: 'exit code 127' },
-    { exitCode: null, signal: 'SIGTERM' as NodeJS.Signals, status: 'signal SIGTERM' },
-    { exitCode: null, signal: null, status: 'without an exit status' },
-  ])('rejects range settlement when the runner exits with $status', async ({ exitCode, signal, status }) => {
+  it('does not treat runner exit as proof that the Job is empty', async () => {
     const child = new EventEmitter() as ChildProcess
-    const kill = vi.fn(() => true)
-    Object.assign(child, { pid: 432, connected: false, kill })
+    Object.assign(child, { pid: 432 })
     let eventsPath = ''
+    let empty = false
+    const jobs = jobOperations({ empty: vi.fn(() => empty) })
     const run = vi.fn((_command: string, args: readonly string[]) => {
       eventsPath = args[args.indexOf('--events') + 1] as string
       appendRunnerEvent(eventsPath, { type: 'started', pid: 432 })
       return child
     }) as unknown as typeof spawn
-    const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] })
+    const launch = launchWindowsJob(spec(['fake-target']), {
+      spawn: run,
+      runnerInvocation: ['fake-runner'],
+      jobs: jobs.operations,
+      jobName: () => 'Local\\dsh-test-job',
+    })
     const directFailure = launch.direct.catch((error: unknown) => error)
 
-    child.emit('close', exitCode, signal)
+    child.emit('close', 127, null)
 
-    await expect(launch.owner.waitForExit()).rejects.toThrow(
-      `Windows Job runner exited with ${status} before proving its managed range empty`,
-    )
+    await expect(launch.owner.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false)
     await expect(directFailure).resolves.toBeInstanceOf(Error)
+    empty = true
+    await expect(launch.owner.waitForExit()).resolves.toBe(true)
     launch.owner.signal('SIGKILL')
-    expect(kill).not.toHaveBeenCalled()
+    expect(jobs.terminate).not.toHaveBeenCalled()
   })
 
-  it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => {
-    for (const mode of ['callback-error', 'disconnected', 'throw'] as const) {
-      const child = new EventEmitter() as ChildProcess
-      const kill = vi.fn(() => true)
-      const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => {
-        if (mode === 'throw') throw new Error('send threw')
-        callback(mode === 'callback-error' ? new Error('send failed') : null)
-        return true
-      })
-      Object.assign(child, {
-        pid: 321,
-        connected: mode !== 'disconnected',
-        kill,
-        send,
-      })
-      let eventsPath = ''
-      const run = vi.fn((_command: string, args: readonly string[]) => {
-        eventsPath = args[args.indexOf('--events') + 1] as string
-        appendRunnerEvent(eventsPath, { type: 'started', pid: 321 })
-        return child
-      }) as unknown as typeof spawn
-      const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] })
-
-      launch.owner.signal('SIGTERM')
-      if (mode === 'callback-error' || mode === 'throw' || mode === 'disconnected') {
-        expect(kill).toHaveBeenCalledOnce()
-      }
-      if (mode === 'disconnected') expect(send).not.toHaveBeenCalled()
-
-      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)
-      const sends = send.mock.calls.length
-      const kills = kill.mock.calls.length
-      launch.owner.signal('SIGKILL')
-      expect(send).toHaveBeenCalledTimes(sends)
-      expect(kill).toHaveBeenCalledTimes(kills)
-    }
-
+  it('reports Job termination failures through waitForExit', async () => {
     const child = new EventEmitter() as ChildProcess
-    const kill = vi.fn(() => true)
-    const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => {
-      callback(null)
-      return true
-    })
-    Object.assign(child, { pid: 654, connected: true, kill, send })
+    Object.assign(child, { pid: 654 })
     let eventsPath = ''
     const run = vi.fn((_command: string, args: readonly string[]) => {
       eventsPath = args[args.indexOf('--events') + 1] as string
       appendRunnerEvent(eventsPath, { type: 'started', pid: 654 })
       return child
     }) as unknown as typeof spawn
-    const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] })
+    const failure = new Error('TerminateJobObject failed')
+    const jobs = jobOperations({ empty: vi.fn(() => false), terminate: vi.fn(() => { throw failure }) })
+    const launch = launchWindowsJob(spec(['fake-target']), {
+      spawn: run,
+      runnerInvocation: ['fake-runner'],
+      jobs: jobs.operations,
+      jobName: () => 'Local\\dsh-test-job',
+    })
+    void launch.direct.catch(() => {})
     launch.owner.signal('SIGTERM')
-    expect(send).toHaveBeenCalledOnce()
-    expect(kill).not.toHaveBeenCalled()
+    await expect(launch.owner.waitForExit()).rejects.toBe(failure)
+    await expect(launch.owner.waitForExit()).rejects.toBe(failure)
+    expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n)
+  })
+
+  it('keeps a Job observation failure visible on repeated waits', async () => {
+    const child = new EventEmitter() as ChildProcess
+    Object.assign(child, { pid: 655 })
+    let eventsPath = ''
+    const run = vi.fn((_command: string, args: readonly string[]) => {
+      eventsPath = args[args.indexOf('--events') + 1] as string
+      appendRunnerEvent(eventsPath, { type: 'started', pid: 655 })
+      return child
+    }) as unknown as typeof spawn
+    const failure = new Error('QueryInformationJobObject failed')
+    const jobs = jobOperations({ empty: vi.fn(() => { throw failure }) })
+    const launch = launchWindowsJob(spec(['fake-target']), {
+      spawn: run,
+      runnerInvocation: ['fake-runner'],
+      jobs: jobs.operations,
+      jobName: () => 'Local\\dsh-test-job',
+    })
     appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null })
     child.emit('close', 0, null)
-    await launch.direct
-    await launch.owner.waitForExit()
+    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)
   })
 
-  it('uses production runner defaults and rejects an empty invocation', async () => {
-    expect(() => launchWindowsJob(spec(['fake-target']), { runnerInvocation: [] }))
+  it('closes the parent Job when spawning the runner throws synchronously', () => {
+    const failure = new Error('runner spawn failed')
+    const jobs = jobOperations()
+    expect(() => launchWindowsJob(spec(['fake-target']), {
+      spawn: vi.fn(() => { throw failure }) as unknown as typeof spawn,
+      runnerInvocation: ['fake-runner'],
+      jobs: jobs.operations,
+      jobName: () => 'Local\\dsh-test-job',
+    })).toThrow(failure)
+    expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n)
+  })
+
+  it('passes a generated Job name to the runner and rejects an empty invocation', async () => {
+    const emptyJobs = jobOperations()
+    expect(() => launchWindowsJob(spec(['fake-target']), {
+      runnerInvocation: [],
+      jobs: emptyJobs.operations,
+    }))
       .toThrow('Windows runner invocation is empty')
+    expect(emptyJobs.create).not.toHaveBeenCalled()
 
     const child = new EventEmitter() as ChildProcess
-    Object.assign(child, {
-      pid: 987,
-      connected: true,
-      kill: vi.fn(() => true),
-      send: vi.fn(),
-    })
+    Object.assign(child, { pid: 987 })
     let eventsPath = ''
+    let jobName = ''
     const run = vi.fn((_command: string, args: readonly string[]) => {
+      jobName = args[args.indexOf('--job') + 1] as string
       eventsPath = args[args.indexOf('--events') + 1] as string
       appendRunnerEvent(eventsPath, { type: 'started', pid: 987 })
       return child
-    })
-    const runSync = vi.fn(() => ({ status: 0, error: undefined }))
-    vi.resetModules()
-    vi.doMock('node:child_process', async importOriginal => ({
-      ...await importOriginal<typeof import('node:child_process')>(),
+    }) as unknown as typeof spawn
+    const jobs = jobOperations()
+    const launch = launchWindowsJob(spec(['fake-target']), {
       spawn: run,
-      spawnSync: runSync,
-    }))
-    try {
-      const defaults = await import('../src/windows-job.ts')
-      expect(defaults.probeWindowsJob()).toBe(true)
-      const launch = defaults.launchWindowsJob(spec(['fake-target']))
-      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(run).toHaveBeenCalledOnce()
-      expect(runSync).toHaveBeenCalledOnce()
-    } finally {
-      vi.doUnmock('node:child_process')
-      vi.resetModules()
-    }
+      runnerInvocation: ['fake-runner'],
+      jobs: 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(jobName).toMatch(/^Local\\dsh-subprocess-/u)
   })
 })

+ 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: 5d14ead5a8d9b6b5d00ee298f274a3d4a1a9aae8
-README.zh.md: faa2dc829db4e4772384bb8a58ca56cebb12dd5c
+README.md: 83edff9e4c7cc7dab0c05d539578893ddc409a4a
+README.zh.md: 18c36f6bf425f86218bd8398ea770bb17a765eb7

+ 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 primitive** — `spawnOrdinaryJobProcess()` applies the same suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A zero-time process wait publishes the direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps the runner alive until `ActiveProcesses` reaches zero.
-- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/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`. A zero-time process wait lets the runner publish direct exit, while the parent polls `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero.
+- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling and 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.
 
 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 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用相同的 suspended-create、Job-assignment 与 resume 生命周期。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让 runner 一存活到 `ActiveProcesses` 归零。
-- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job 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 生命周期。process 的 zero-time wait 让 runner 单独发布 direct exit,parent 则轮询 `QueryInformationJobObject(JobObjectBasicAccountingInformation)` 直到 `ActiveProcesses` 归零。
+- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling 与 parent-owned Job 的 accounting、termination、closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。
 
 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。
 

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

@@ -28,6 +28,8 @@ export const ERROR_BROKEN_PIPE = 109
 export const ERROR_NO_DATA = 232
 /** Job limit that terminates every member when the final Job handle closes. */
 export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
+/** Access right required to assign a process to an existing Job. */
+export const JOB_OBJECT_ASSIGN_PROCESS = 0x0001
 /** QueryInformationJobObject class for basic accounting and active-process count. */
 export const JobObjectBasicAccountingInformation = 1
 /** SetInformationJobObject class for JOBOBJECT_EXTENDED_LIMIT_INFORMATION. */

+ 6 - 4
packages/subprocess/win32-process/src/ffi.ts

@@ -53,7 +53,7 @@ export interface ProcessInfoOutput {
   dwThreadId: number
 }
 
-/** Generic Win32 calls consumed by restricted-token sandbox process operations. */
+/** Generic Win32 calls consumed by sandbox and ordinary process operations. */
 export interface Win32ProcessBindings {
   closeHandle(handle: NativePtr): number
   getLastError(): number
@@ -104,7 +104,8 @@ export interface Win32ProcessBindings {
   ): number
   waitForSingleObject(handle: NativePtr, milliseconds: number): number
   getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number
-  createJobObjectW(attributes: null, name: null): NativePtr
+  createJobObjectW(attributes: null, name: string | null): NativePtr
+  openJobObjectW(desiredAccess: number, inheritHandle: number, name: string): NativePtr
   setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number
   queryInformationJobObject(
     job: NativePtr,
@@ -121,7 +122,7 @@ export interface Win32ProcessBindings {
 }
 
 /** Koffi STARTUPINFOW layout. */
-export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', {
+export const STARTUPINFOW = koffi.struct({
   cb: 'uint32',
   lpReserved: 'str16',
   lpDesktop: 'str16',
@@ -143,7 +144,7 @@ export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', {
 })
 
 /** Koffi PROCESS_INFORMATION layout. */
-export const PROCESS_INFORMATION = koffi.struct('DSH_PROCESS_INFORMATION', {
+export const PROCESS_INFORMATION = koffi.struct({
   hProcess: PVOID,
   hThread: PVOID,
   dwProcessId: 'uint32',
@@ -272,6 +273,7 @@ function bindings(): Win32ProcessBindings {
     waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']),
     getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]),
     createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']),
+    openJobObjectW: bind(kernel32, 'OpenJobObjectW', PVOID, ['uint32', 'int', 'str16']),
     setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']),
     queryInformationJobObject: bind(kernel32, 'QueryInformationJobObject', 'int', [
       PVOID, 'int', PVOID, 'uint32', PVOID,

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

@@ -1,4 +1,4 @@
-/** Low-level Win32 process, stdio, and Job Object primitives used by the Windows ACL sandbox. */
+/** Low-level Win32 process, stdio, and Job Object primitives shared by sandbox and ordinary subprocess paths. */
 
 export { ERROR_INSUFFICIENT_BUFFER } from './abi.ts'
 export * from './errors.ts'
@@ -19,17 +19,20 @@ export type {
 } from './ffi.ts'
 export {
   closeHandleChecked,
+  createKillOnCloseJob,
   drainPipe,
   isJobEmpty,
+  openJobForAssignment,
   pollProcessExit,
   spawnInheritedJobProcess,
-  spawnOrdinaryJobProcess,
+  spawnOrdinaryProcessInJob,
   spawnPipedProcess,
   terminateJob,
   waitForProcessExit,
 } from './process.ts'
 export type {
   OrdinaryProcessSpawnOptions,
+  SpawnedAssignedProcess,
   SpawnedJobProcess,
   SpawnedPipedProcess,
 } from './process.ts'

+ 57 - 25
packages/subprocess/win32-process/src/process.ts

@@ -91,6 +91,14 @@ export interface SpawnedJobProcess {
   job: NativePtr
 }
 
+/** Suspended child already assigned to a caller-owned Job. */
+export interface SpawnedAssignedProcess {
+  /** Direct child process id. */
+  pid: number
+  /** Process handle closed by waitForProcessExit. */
+  process: NativePtr
+}
+
 interface PipePair {
   read: NativePtr
   write: NativePtr
@@ -299,8 +307,14 @@ export function waitForProcessExit(api: Win32ProcessBindings, process: NativePtr
   }
 }
 
-function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr {
-  const job = api.createJobObjectW(null, null)
+/**
+ * Create a caller-owned Job whose final handle closure terminates all members.
+ * @param api - active binding table.
+ * @param name - optional name used when another process must open the same Job.
+ * @returns caller-owned Job handle.
+ */
+export function createKillOnCloseJob(api: Win32ProcessBindings, name: string | null = null): NativePtr {
+  const job = api.createJobObjectW(null, name)
   if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW')
   const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE)
   information.writeUInt32LE(
@@ -320,19 +334,30 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr {
   return job
 }
 
+/**
+ * Open one named Job for assigning a process from another process.
+ * @param api - active binding table.
+ * @param name - name supplied by the Job's owner.
+ * @returns caller-owned Job handle with assignment access.
+ */
+export function openJobForAssignment(api: Win32ProcessBindings, name: string): NativePtr {
+  const job = api.openJobObjectW(abi.JOB_OBJECT_ASSIGN_PROCESS, 0, name)
+  if (isNullPtr(job)) throwLastError(api, 'OpenJobObjectW', name)
+  return job
+}
+
 /** Shared suspended-create, Job-assignment, and resume lifecycle. */
 function spawnJobProcess(
   api: Win32ProcessBindings,
   options: OrdinaryProcessSpawnOptions,
+  job: NativePtr,
   createName: 'CreateProcessAsUserW' | 'CreateProcessW',
   create: (startupInfo: NativePtr, processInfo: NativePtr) => number,
-): SpawnedJobProcess {
-  const job = createKillOnCloseJob(api)
+): SpawnedAssignedProcess {
   const getStdHandle = (selector: number, label: string): NativePtr => {
     const handle = api.getStdHandle(selector)
     if (!isNullPtr(handle)) return handle
     const win32Code = api.getLastError()
-    api.closeHandle(job)
     throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`)
   }
   const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin')
@@ -367,7 +392,6 @@ function spawnJobProcess(
     if (created === 0) createFailureCode = api.getLastError()
   } catch (error) {
     freeNative(processInfo)
-    api.closeHandle(job)
     throw error
   } finally {
     freeNative(startupInfo)
@@ -378,7 +402,6 @@ function spawnJobProcess(
   }
   if (created === 0) {
     freeNative(processInfo)
-    api.closeHandle(job)
     throwWin32(
       api,
       createName,
@@ -394,7 +417,6 @@ function spawnJobProcess(
   }
   if (info.hProcess === null || info.hThread === null) {
     if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1)
-    api.closeHandle(job)
     closeBestEffort(api, info.hThread)
     closeBestEffort(api, info.hProcess)
     throw new Error(`${createName} succeeded but returned null process/thread handles (pid ${info.dwProcessId})`)
@@ -404,18 +426,17 @@ function spawnJobProcess(
     api.terminateProcess(info.hProcess, 1)
     closeBestEffort(api, info.hThread)
     closeBestEffort(api, info.hProcess)
-    api.closeHandle(job)
     throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`)
   }
   if (api.resumeThread(info.hThread) === 0xFFFFFFFF) {
     const win32Code = api.getLastError()
+    api.terminateProcess(info.hProcess, 1)
     closeBestEffort(api, info.hThread)
     closeBestEffort(api, info.hProcess)
-    api.closeHandle(job)
     throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`)
   }
   closeBestEffort(api, info.hThread)
-  return { pid: info.dwProcessId, process: info.hProcess, job }
+  return { pid: info.dwProcessId, process: info.hProcess }
 }
 
 /**
@@ -432,30 +453,41 @@ export function spawnInheritedJobProcess(
   api: Win32ProcessBindings,
   options: RestrictedProcessSpawnOptions,
 ): SpawnedJobProcess {
+  const job = createKillOnCloseJob(api)
   const commandLine = buildCommandLine(options.command, options.args)
-  return spawnJobProcess(api, options, 'CreateProcessAsUserW', (startupInfo, processInfo) =>
-    createRestrictedProcess(
-      api,
-      options,
-      commandLine,
-      abi.CREATE_SUSPENDED,
-      startupInfo,
-      processInfo,
-    ))
+  try {
+    return {
+      ...spawnJobProcess(api, options, job, 'CreateProcessAsUserW', (startupInfo, processInfo) =>
+        createRestrictedProcess(
+          api,
+          options,
+          commandLine,
+          abi.CREATE_SUSPENDED,
+          startupInfo,
+          processInfo,
+        )),
+      job,
+    }
+  } catch (error) {
+    api.closeHandle(job)
+    throw error
+  }
 }
 
 /**
- * Spawn an ordinary process suspended, assign its Job, then resume it.
+ * Spawn an ordinary process suspended, assign a caller-owned Job, then resume it.
  * @param api - active binding table.
  * @param options - command, cwd, and argv.
- * @returns caller-owned process and Job handles after successful resume.
+ * @param job - caller-owned Job handle that remains open after this call.
+ * @returns caller-owned process handle after successful resume.
  */
-export function spawnOrdinaryJobProcess(
+export function spawnOrdinaryProcessInJob(
   api: Win32ProcessBindings,
   options: OrdinaryProcessSpawnOptions,
-): SpawnedJobProcess {
+  job: NativePtr,
+): SpawnedAssignedProcess {
   const commandLine = buildCommandLine(options.command, options.args)
-  return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) =>
+  return spawnJobProcess(api, options, job, 'CreateProcessW', (startupInfo, processInfo) =>
     api.createProcessW(
       null,
       commandLine,

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

@@ -2,14 +2,17 @@ import koffi from 'koffi'
 import { describe, expect, it, vi } from 'vitest'
 import {
   closeHandleChecked,
+  createKillOnCloseJob,
   isJobEmpty,
+  openJobForAssignment,
   pollProcessExit,
-  spawnOrdinaryJobProcess,
+  spawnOrdinaryProcessInJob,
   terminateJob,
   Win32Error,
 } from '../src/index.ts'
 import {
   CREATE_SUSPENDED,
+  JOB_OBJECT_ASSIGN_PROCESS,
   JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET,
   JOBOBJECT_BASIC_ACCOUNTING_SIZE,
   JobObjectBasicAccountingInformation,
@@ -21,6 +24,7 @@ import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
 function api(overrides: Partial<Win32ProcessBindings> = {}): Win32ProcessBindings {
   return {
     createJobObjectW: vi.fn(() => 50n),
+    openJobObjectW: vi.fn(() => 55n),
     setInformationJobObject: vi.fn(() => 1),
     queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
       information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
@@ -56,6 +60,7 @@ function api(overrides: Partial<Win32ProcessBindings> = {}): Win32ProcessBinding
 describe('ordinary Job process operations', () => {
   it('creates suspended, assigns the Job, and resumes before returning', () => {
     const events: string[] = []
+    const createJobObjectW = vi.fn(() => 50n as NativePtr)
     const createProcessW = vi.fn((
       _app: unknown,
       _line: unknown,
@@ -73,16 +78,19 @@ describe('ordinary Job process operations', () => {
       return 1
     })
     const bindings = api({
+      createJobObjectW,
       createProcessW,
       assignProcessToJobObject: vi.fn(() => { events.push('assign'); return 1 }),
       resumeThread: vi.fn(() => { events.push('resume'); return 0 }),
       closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }),
     })
-    expect(spawnOrdinaryJobProcess(bindings, {
+    const job = createKillOnCloseJob(bindings, 'Local\\test-job')
+    expect(spawnOrdinaryProcessInJob(bindings, {
       command: 'probe.exe',
       args: ['literal $VALUE', 'a b'],
       cwd: 'C:\\work',
-    })).toEqual({ pid: 1234, process: 60n, job: 50n })
+    }, job)).toEqual({ pid: 1234, process: 60n })
+    expect(createJobObjectW).toHaveBeenCalledWith(null, 'Local\\test-job')
     expect(createProcessW).toHaveBeenCalledWith(
       null,
       'probe.exe "literal $VALUE" "a b"',
@@ -104,13 +112,32 @@ describe('ordinary Job process operations', () => {
     const bindings = api({ createProcessW: vi.fn(() => 0) })
     let caught: unknown
     try {
-      spawnOrdinaryJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' })
+      spawnOrdinaryProcessInJob(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }, 50n as NativePtr)
     } catch (error) {
       caught = error
     }
     expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 })
   })
 
+  it('terminates an assigned suspended process when resume fails', () => {
+    const terminateProcess = vi.fn(() => 1)
+    const closeHandle = vi.fn(() => 1)
+    const bindings = api({
+      resumeThread: vi.fn(() => 0xFFFFFFFF),
+      terminateProcess,
+      closeHandle,
+    })
+    expect(() => spawnOrdinaryProcessInJob(bindings, {
+      command: 'probe.exe',
+      args: [],
+      cwd: 'C:\\work',
+    }, 50n as NativePtr)).toThrow(Win32Error)
+    expect(terminateProcess).toHaveBeenCalledWith(60n, 1)
+    expect(closeHandle).toHaveBeenCalledWith(61n)
+    expect(closeHandle).toHaveBeenCalledWith(60n)
+    expect(closeHandle).not.toHaveBeenCalledWith(50n)
+  })
+
   it('polls direct exit and Job emptiness without blocking', () => {
     const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
       information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
@@ -159,4 +186,14 @@ describe('ordinary Job process operations', () => {
     const closeFailure = api({ closeHandle: vi.fn(() => 0) })
     expect(() => { closeHandleChecked(closeFailure, 50n as NativePtr, 'test Job') }).toThrow(Win32Error)
   })
+
+  it('opens a named Job for process assignment', () => {
+    const openJobObjectW = vi.fn(() => 55n as NativePtr)
+    const bindings = api({ openJobObjectW })
+    expect(openJobForAssignment(bindings, 'Local\\test-job')).toBe(55n)
+    expect(openJobObjectW).toHaveBeenCalledWith(JOB_OBJECT_ASSIGN_PROCESS, 0, 'Local\\test-job')
+
+    const missing = api({ openJobObjectW: vi.fn(() => 0n as NativePtr) })
+    expect(() => openJobForAssignment(missing, 'Local\\missing-job')).toThrow(Win32Error)
+  })
 })