Explorar o código

fix(subprocess): await direct fallback settlement before scope failure

Tianyi Cui hai 1 semana
pai
achega
4b99ace8fb

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-12-linux-scope-direct-kill-settlement.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-09-12-linux-scope-direct-kill-settlement.md
+2026-09-12-linux-scope-direct-kill-settlement.md: fa127cb56d1aa561abe2843ab3ff8e65ffdcb848
+2026-09-12-linux-scope-direct-kill-settlement.zh.md: 50d8231e15d81dd0e4bcfa6cb67d08170b46776e

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-12-linux-scope-direct-kill-settlement.md

@@ -0,0 +1,29 @@
+# Agent Note: Join direct termination before rejecting Linux scope cleanup
+
+Status: implemented
+
+English | [中文](2026-09-12-linux-scope-direct-kill-settlement.zh.md)
+
+## Problem
+
+A failed scope signal can precede the exit notification of a direct process that accepted fallback `SIGKILL` or has already disappeared. Reporting the signal failure from an active scope observation during that interval can reject cleanup while termination remains in progress. The direct process's exit alone cannot prove that its descendants have stopped.
+
+## Decision
+
+The [Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) retains a failed final scope signal. Successful direct `SIGKILL` submission or independently proven direct-process absence permits one wait for the direct process's exit or launch-error settlement before a fresh scope observation. This event is independent of output draining, startup-error interpretation, and managed-range completion. After rejected direct signaling, a signal-zero probe must report `ESRCH` to establish absence; a surviving process or another probe error permits no such wait.
+
+Existing scope-emptiness proofs remain sufficient before direct settlement. When an active scope observation began before direct exit and cannot prove emptiness, the owner consumes the direct settlement wait once and then queries the scope again. An observation begun after direct exit requires no extra wait or query. A surviving range or unknown process count retains the original signal failure. State-query and parsing errors remain failures.
+
+The [native-containment decision](../architecture/2026-08-28-subprocess-native-containment.md) continues to own descendant membership and the separation between direct outcomes and whole-range quiescence. The [hosted-image fixture policy](../testing/2026-09-10-hosted-image-test-assumptions.md) retains its existing test budgets; those budgets do not establish signal completion.
+
+## Alternatives considered
+
+**Reject on the first active observation.** Rejected because successful direct signaling requests termination but does not synchronously deliver its exit notification.
+
+**Increase graces or repeat observations without a completion event.** Rejected because elapsed time cannot establish that the signaled process has exited.
+
+**Treat direct exit as successful cleanup.** Rejected because descendants can outlive their direct parent and remain in the scope.
+
+## Consequences
+
+Cleanup joins direct-process settlement only after a successful signal or proven absence while preserving independent scope verification. Configured grace periods and polling budgets remain unchanged. The wait can delay a signal error until direct settlement; it cannot convert a surviving or unobservable managed range into success.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-12-linux-scope-direct-kill-settlement.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 等待直接进程终止后再判定 Linux scope 清理失败
+
+Status: implemented
+
+[English](2026-09-12-linux-scope-direct-kill-settlement.md) | 中文
+
+## 问题
+
+scope 信号发送失败时,已接受 fallback `SIGKILL` 或已不存在的直接进程可能尚未送达退出通知。在此期间根据 active scope 状态报告信号失败,会让仍在终止过程中的清理被判为失败。直接进程退出本身也不能证明其后代已停止。
+
+## 决策
+
+[Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) 保留最终 scope 信号的失败。直接 `SIGKILL` 成功提交或直接进程被独立确认已不存在后,owner 可以等待一次直接进程退出或启动错误的完成事件,再取得新的 scope 状态。该事件独立于输出排空、启动错误解释和受管范围的完成。直接信号发送失败后,信号零探测必须报告 `ESRCH` 才能证明进程不存在;进程仍存活或探测报告其他错误时,不允许此等待。
+
+在直接进程停稳前,既有的 scope 为空证明仍足以完成清理。如果 active scope 查询开始时直接进程尚未退出,且该状态不能证明范围为空,owner 仅等待一次直接进程停稳,随后重新查询 scope。直接进程退出后才开始的查询不需要额外等待或再次查询。仍有进程存活或进程数未知时,保留原始信号失败。状态查询和解析错误仍然报错。
+
+[原生 containment 决策](../architecture/2026-08-28-subprocess-native-containment.zh.md) 继续负责后代成员关系,以及直接结果与整个范围完全停稳的区分。[托管镜像 fixture 策略](../testing/2026-09-10-hosted-image-test-assumptions.zh.md) 保留既有测试预算,但这些预算不能证明信号处理已经完成。
+
+## 考虑过的替代方案
+
+**首次观察到 active 状态就报错。** 否决,因为直接信号发送成功只是请求终止,不会同步送达退出通知。
+
+**增大宽限期或在没有完成事件时重复查询。** 否决,因为经过的时间不能证明收到信号的进程已经退出。
+
+**将直接进程退出视为清理成功。** 否决,因为后代可以在直接父进程退出后继续存活于 scope 内。
+
+## 影响
+
+清理只在信号成功发送或已证明进程不存在后等待直接进程停稳,同时保留独立的 scope 验证。配置的宽限期和轮询预算保持不变。此等待可能将信号错误推迟到直接进程停稳后报告,但不能让仍有存活进程或无法观察的受管范围被判为成功。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md
-2026-09-10-hosted-image-test-assumptions.md: 1053b43514c87e73a756e8cd5eeb26facc483a51
-2026-09-10-hosted-image-test-assumptions.zh.md: 9061ceb696101c08e644a256c54e2ffaa09d4b29
+2026-09-10-hosted-image-test-assumptions.md: facff4fe2067cfec600f75a93b05372795bfeabd
+2026-09-10-hosted-image-test-assumptions.zh.md: 6b65c68f363121ff009bd1fb75a514b69d1f9993

+ 4 - 4
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md

@@ -6,17 +6,17 @@ English | [中文](2026-09-10-hosted-image-test-assumptions.zh.md)
 
 ## Problem
 
-The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; the wall-clock grace a managed scope needed before it could take a `SIGKILL` was below what a loaded image provides; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright.
+The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; a managed scope reported a signal failure during ACP teardown; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright.
 
 ## Decision
 
-Every case names the host property it depends on, so the same revision reports the same verdict on the in-house pool and on a hosted image.
+The cases declare the host properties they depend on.
 
 Terminal cases that drive a mocked PTY exit pin the containment they need (`internals = { platform: 'darwin' }` in `packages/subprocess/subprocess-local/tests/local.spec.ts`); under the host's native scope the mocked exit races the scope bootstrap and fails as `terminal scope exited before its bootstrap consumed the launch request`. Mocking the `linux-scope.ts` probes to reach the same path was removed: the platform pin skips both probes, so the mock could not change the selected path.
 
 `disposal contains a spawn-failure rejection that races teardown` asserts the settlement contract instead of one winner of the race: a bootstrap that published its pre-exec failure rejects with that failure, and a teardown that stopped the bootstrap first settles as the requested `SIGTERM`. Only the Linux scope records the stopped arm, because the win32 job owner turns a cancelled start into a rejection and the fallback launcher rejects the missing directory.
 
-`plugin-config dispose graces reach the real ACP run` configures 5000ms dispose graces. At 150ms the hosted image escalated while the scope could not take the signal — `systemctl` failed the kill (`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`) and the teardown reported a failure the configuration never asked for. Its mock refuses stdin EOF and `SIGTERM` by design, so the case waits out both graces (~10s) and carries a 30s case budget, above the 5000ms default the local unit entry grants.
+`plugin-config dispose graces reach the real ACP run` configures 5000ms dispose graces. Its mock refuses stdin EOF and `SIGTERM` by design, so the case waits out both graces (~10s) and carries a 30s case budget, above the 5000ms default the local unit entry grants. The scope signal failure also occurs with these graces; increasing them does not establish termination. The [direct-settlement decision](../bug-fix/2026-09-12-linux-scope-direct-kill-settlement.md) defines the process event and fresh scope evidence required after a fallback kill succeeds or independently proves direct-process absence.
 
 Both illegal-UTF-8 residual cases in `packages/experimental/ptc-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start.
 
@@ -38,4 +38,4 @@ The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)`
 
 ## Consequences
 
-The suite's verdict no longer depends on which pool served the lane, at the cost of fixtures pinned to one containment choice: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, all deterministic rather than host-paced. Hosted-image evidence: [run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) failed on these cases, [run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`).
+Pinned fixtures avoid unintended containment choices: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, before additional host scheduling and native cleanup costs. Hosted-image evidence: [run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) failed on these cases, [run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`).

+ 4 - 4
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md

@@ -6,17 +6,17 @@ Status: implemented
 
 ## 问题
 
-[故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;托管 scope 接受 `SIGKILL` 之前所需的墙钟宽限,低于负载镜像实际提供的量;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。
+[故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;受管 scope 在 ACP 拆卸时报告信号失败;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。
 
 ## 决策
 
-每个用例都点明它依赖的宿主属性,因此同一份修订在自有池与托管镜像上给出同样的结论
+这些用例明确声明各自依赖的宿主属性
 
 驱动被 mock 的 PTY 退出的终端用例钉死自己需要的 containment(`packages/subprocess/subprocess-local/tests/local.spec.ts` 中的 `internals = { platform: 'darwin' }`);在宿主的原生 scope 下,被 mock 的退出会与 scope 的 bootstrap 竞争,并以 `terminal scope exited before its bootstrap consumed the launch request` 失败。为走到同一路径而 mock `linux-scope.ts` 的探针已被删除:平台钉死会让两个探针都不被调用,因此该 mock 无法改变选中的路径。
 
 `disposal contains a spawn-failure rejection that races teardown` 断言结算契约,而不是这场竞争的某一方获胜:已经发布其 pre-exec 失败的 bootstrap 以该失败 reject,先停住 bootstrap 的 teardown 则以被请求的 `SIGTERM` 结算。只有 Linux scope 会记录停止这一支,因为 win32 job owner 会把被取消的启动转成 rejection,fallback 启动器则因目录缺失而 reject。
 
-`plugin-config dispose graces reach the real ACP run` 配置 5000ms 的 dispose 宽限。在 150ms 时,托管镜像在 scope 还无法接受信号时就升级了信号——`systemctl` 的 kill 失败(`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`),teardown 上报了一个配置从未要求的失败。该用例的 mock 按设计既拒绝 stdin EOF 也拒绝 `SIGTERM`,因此用例会等满两个宽限(约 10s),并自带 30s 的用例预算,高于本地单测入口授予的 5000ms 默认值。
+`plugin-config dispose graces reach the real ACP run` 配置 5000ms 的 dispose 宽限。该用例的 mock 按设计既拒绝 stdin EOF 也拒绝 `SIGTERM`,因此用例会等满两个宽限(约 10s),并自带 30s 的用例预算,高于本地单测入口授予的 5000ms 默认值。采用这些宽限时仍会发生 scope 信号失败,增加宽限不能证明终止已完成。[直接进程停稳决策](../bug-fix/2026-09-12-linux-scope-direct-kill-settlement.zh.md) 规定了 fallback kill 成功或独立证明直接进程不存在后所需的进程事件与新取得的 scope 证据。
 
 `packages/experimental/ptc-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。
 
@@ -38,4 +38,4 @@ Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateIn
 
 ## 后果
 
-套件的结论不再取决于哪个池服务了这条通道,代价是被钉在某一 containment 选择上的 fixture:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,且都是确定成本而非随宿主浮动。托管镜像证据:[run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) 在这些用例上失败,[run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。
+钉死选择的 fixture 避免了非预期的 containment 选择:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,还需加上宿主调度与原生清理的额外开销。托管镜像证据:[run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) 在这些用例上失败,[run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。

+ 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: a1a6916e5330737e47eeed29b07b9750b9249b4b
-README.zh.md: f6d04b5d21b53a862e01f5a55b49670e643b0f84
+README.md: 7a5d6ecabc759f7f64260698d167e8bcd412d976
+README.zh.md: 808df295e26775b6b319d3d7cc4dd514c08fa435

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

@@ -58,7 +58,7 @@ An ordinary spawn can request the [subprocess control pipe](../subprocess/README
 
 Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. The same exit removes the private per-process spill directory when it holds no completed spill file; completed spill files remain as full-output recovery artifacts until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
 
-Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup. State queries interrupted by a termination signal are repeated before deciding whether cleanup succeeded. After termination, a consumed launch request and zero scope processes prove quiescence even before the direct-process exit notification. A failed final signal does not reject a range subsequently proven empty; active ranges without that proof retain the signal failure.
+Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup. State queries interrupted by a termination signal are repeated before deciding whether cleanup succeeded. After termination, a consumed launch request and zero scope processes prove quiescence even before the direct-process exit notification. If the final scope signal fails, an accepted direct `SIGKILL` or verified direct-process absence permits one wait for the pending direct-process settlement before rechecking an active range not yet proven empty. This wait is independent of output draining and whole-range settlement. The fresh scope observation must prove the range empty; surviving processes or an unknown process count retain the signal failure.
 
 ### What can go wrong
 

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

@@ -58,7 +58,7 @@ kind: "package-reference"
 
 正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。同一退出阶段会删除未持有任何已完成 spill 文件的每进程私有 spill 目录;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。
 
-Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。状态查询期间若发出终止信号,会重新查询后再判定清理是否成功。请求终止后,启动请求已消费且 scope 进程数为零即可证明完全停稳,无需等待直接进程的退出通知。即使最终信号发送失败,之后证明范围已为空仍可成功结束;未获得这一证明的 active 范围仍报告信号失败。
+Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。状态查询期间若发出终止信号,会重新查询后再判定清理是否成功。请求终止后,启动请求已消费且 scope 进程数为零即可证明完全停稳,无需等待直接进程的退出通知。如果最终 scope 信号发送失败,直接进程接受 `SIGKILL` 或被独立确认已不存在,owner 才可以等待一次尚未送达的直接进程停稳通知,再重新查询尚未证明为空的 active 范围。此等待独立于输出排空和整个范围的停稳。新取得的 scope 状态必须证明范围已为空;仍有进程存活或进程数未知时,仍报告信号失败。
 
 ### 可能出错的地方
 

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

@@ -35,6 +35,7 @@ import {
   prepareLinuxTerminalScope,
   probeLinuxManager,
   probeLinuxNative,
+  signalLinuxDirectProcess,
 } from './linux-scope.ts'
 import { launchWindowsJob, probeWindowsJob } from './windows-job.ts'
 import { targetEnvironment } from './runner-launch.ts'
@@ -281,11 +282,12 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
     }
     // oxlint-disable-next-line eslint/prefer-const -- The owner can query readiness before the handle is published.
     let handle: LocalTerminalHandle | undefined
+    const directSettlement = Promise.withResolvers<void>()
     const owner = scope?.bindOwner({
       running: () => handle?.running ?? true,
-      signal: (signal) => {
-        try { terminal.kill(signal) } catch { /* Direct process already exited. */ }
-      },
+      settled: directSettlement.promise,
+      // node-pty swallows signal errors; the scope owner requires their delivery result.
+      signal: signal => signalLinuxDirectProcess(terminal.pid, () => process.kill(terminal.pid, signal)),
     })
     handle = new LocalTerminalHandle(
       terminal,
@@ -297,6 +299,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
     )
     this.terminals.add(handle)
     const release = async (): Promise<void> => {
+      directSettlement.resolve()
       await handle.terminate()
       this.terminals.delete(handle)
     }

+ 52 - 10
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -157,7 +157,10 @@ export function probeLinuxNative(internals: LinuxScopeInternals = {}): boolean {
 
 interface DirectRange {
   running(): boolean
-  signal(signal: 'SIGTERM' | 'SIGKILL'): void
+  /** Report successful delivery or proven direct-process absence. */
+  signal(signal: 'SIGTERM' | 'SIGKILL'): boolean
+  /** Direct exit/error settlement, independent of output drain and managed-range completion. */
+  settled: Promise<unknown>
 }
 
 class LinuxScopeStartup {
@@ -182,6 +185,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
   private terminationRequested = false
   private observation: Promise<void> | undefined
   private killFailure: Error | undefined
+  private directKillSettlement: Promise<void> | undefined
   private wakeGeneration = 0
   private wakeWaiter: { generation: number; resolve: () => void } | undefined
 
@@ -201,7 +205,8 @@ class SystemdScopeOwner implements BoundProcessOwner {
     if (this.direct.running()) this.startup.terminationSignals.add(signal)
     this.observeRequestConsumption()
     const directFallbackRequired = this.establishment === 'pending'
-    if (directFallbackRequired && this.direct.running()) this.direct.signal(signal)
+    let directSignalled = false
+    if (directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal)
     const result = this.runSync(this.systemctl, [
       '--user',
       'kill',
@@ -211,16 +216,23 @@ class SystemdScopeOwner implements BoundProcessOwner {
     ], { encoding: 'utf8', env: managerEnvironment(), timeout: SYSTEMCTL_TIMEOUT_MS })
     this.wakeObservation()
     if (result.error === undefined && result.status === 0) {
-      if (signal === 'SIGKILL') this.killFailure = undefined
+      if (signal === 'SIGKILL') {
+        this.killFailure = undefined
+        this.directKillSettlement = undefined
+      }
       return
     }
-    if (!directFallbackRequired && this.direct.running()) this.direct.signal(signal)
+    if (!directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal)
     if (signal === 'SIGKILL') {
       const output = `${result.stdout}\n${result.stderr}`
       if (!MISSING_UNIT.test(output)) {
         this.killFailure = result.error ?? new Error(
           `systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`,
         )
+        // The direct outcome retains errors; this barrier only joins its physical settlement.
+        this.directKillSettlement = directSignalled
+          ? this.direct.settled.then(() => {}, () => {})
+          : undefined
       }
     }
   }
@@ -321,6 +333,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
   private async rangeActive(): Promise<boolean> {
     this.observeRequestConsumption()
     const generation = this.wakeGeneration
+    const directRunning = this.direct.running()
     const result = await this.query(this.systemctl, [
       '--user',
       'show',
@@ -349,7 +362,16 @@ class SystemdScopeOwner implements BoundProcessOwner {
         this.releaseEmptyRange()
         return false
       }
-      if (this.killFailure !== undefined) throw this.killFailure
+      if (this.killFailure !== undefined) {
+        if (directRunning && this.directKillSettlement !== undefined) {
+          const settlement = this.directKillSettlement
+          this.directKillSettlement = undefined
+          await settlement
+          // A query preceding direct exit cannot prove that its signalled processes survived.
+          return this.rangeActive()
+        }
+        throw this.killFailure
+      }
       return true
     }
     if (!MISSING_UNIT.test(output)) {
@@ -446,11 +468,29 @@ function directOutcome(
   })
 }
 
-function signalChildGroup(child: ReturnType<typeof spawn>, signal: 'SIGTERM' | 'SIGKILL'): void {
+/**
+ * Send a direct-process signal, distinguishing an absent PID from failed delivery.
+ * @param pid - owned direct-process identity whose exit notification can still be pending.
+ * @param send - platform signal operation; true means the signal was submitted.
+ * @returns whether the signal was submitted or the owned PID is already absent.
+ */
+export function signalLinuxDirectProcess(pid: number, send: () => boolean): boolean {
+  try {
+    if (send()) return true
+  } catch { /* A failed signal still permits an independent absence observation. */ }
+  try {
+    process.kill(pid, 0)
+    return false
+  } catch (error) {
+    return (error as NodeJS.ErrnoException).code === 'ESRCH'
+  }
+}
+
+function signalChildGroup(child: ReturnType<typeof spawn>, signal: 'SIGTERM' | 'SIGKILL'): boolean {
   try {
-    process.kill(-(child.pid as number), signal)
+    return process.kill(-(child.pid as number), signal)
   } catch {
-    try { child.kill(signal) } catch { /* The direct process already exited. */ }
+    return signalLinuxDirectProcess(child.pid as number, () => child.kill(signal))
   }
 }
 
@@ -535,12 +575,14 @@ export function launchLinuxScope(
     cleanupLinuxLaunchFiles(files)
     throw error
   }
+  const direct = directOutcome(child, startup)
   const owner = new SystemdScopeOwner(
     `${unitBase}.scope`,
     startup,
     {
       running: () => child.pid !== undefined && child.exitCode === null && child.signalCode === null,
-      signal: (signal) => { signalChildGroup(child, signal) },
+      signal: signal => signalChildGroup(child, signal),
+      settled: direct,
     },
     internals.systemctl ?? 'systemctl',
     internals.spawnSync ?? spawnSync,
@@ -552,7 +594,7 @@ export function launchLinuxScope(
     stdout: child.stdout,
     stderr: child.stderr,
     control: controlPipe(child, spec.stdio.control),
-    direct: directOutcome(child, startup),
+    direct,
     owner,
   }
 }

+ 159 - 4
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
 import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
 import { PassThrough } from 'node:stream'
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import {
   launchLinuxScope,
   prepareLinuxTerminalScope,
@@ -9,6 +10,7 @@ import {
   probeLinuxManager,
   probeLinuxNative,
   probeLinuxScope,
+  signalLinuxDirectProcess,
 } from '../src/linux-scope.ts'
 import type { LinuxScopeInternals } from '../src/linux-scope.ts'
 import {
@@ -42,6 +44,8 @@ class FakeChild extends EventEmitter {
   stdin = new PassThrough()
   stdout = new PassThrough()
   stderr = new PassThrough()
+  control = new PassThrough()
+  stdio = [this.stdin, this.stdout, this.stderr, null, null, null, null, this.control]
   kills: NodeJS.Signals[] = []
 
   kill(signal: NodeJS.Signals): boolean {
@@ -111,6 +115,7 @@ function spec() {
 function launch(
   query: LinuxScopeInternals['systemctlQuery'],
   overrides: LinuxScopeInternals = {},
+  request: SubprocessSpawnSpec = spec(),
 ) {
   const child = new FakeChild()
   let options: { env?: NodeJS.ProcessEnv; cwd?: string; detached?: boolean } | undefined
@@ -120,7 +125,7 @@ function launch(
   })
   const spawnSync = vi.fn(() => ({ status: 0, stdout: '', stderr: '' }))
   const systemctlQuery = overrides.systemctlQuery ?? query
-  const result = launchLinuxScope(spec(), { TARGET: 'yes' }, {
+  const result = launchLinuxScope(request, { TARGET: 'yes' }, {
     spawn: overrides.spawn ?? spawn as never,
     spawnSync: overrides.spawnSync ?? spawnSync as never,
     ...systemctlQuery === undefined ? {} : { systemctlQuery },
@@ -138,6 +143,26 @@ function launch(
 }
 
 describe('Linux native capability selection', () => {
+  it.each([
+    { delivered: true, error: undefined, accepted: true },
+    { delivered: false, error: undefined, accepted: false },
+    { delivered: false, error: 'ESRCH', accepted: true },
+    { delivered: false, error: 'EPERM', accepted: false },
+  ])('distinguishes direct signal delivery=$delivered and absence=$error', ({ delivered, error, accepted }) => {
+    const probe = vi.spyOn(process, 'kill').mockImplementation(() => {
+      if (error !== undefined) throw Object.assign(new Error(error), { code: error })
+      return true
+    })
+    expect(signalLinuxDirectProcess(123, () => delivered)).toBe(accepted)
+    if (delivered) expect(probe).not.toHaveBeenCalled()
+    else expect(probe).toHaveBeenCalledExactlyOnceWith(123, 0)
+  })
+
+  it('checks direct absence after a signal operation throws', () => {
+    vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('absent'), { code: 'ESRCH' }) })
+    expect(signalLinuxDirectProcess(123, () => { throw new Error('signal failed') })).toBe(true)
+  })
+
   it('rechecks bootstrap and literal transient-scope support', () => {
     const spawnSync = vi.fn(() => ({ status: 0, error: undefined }))
     const runnerAvailable = vi.fn(() => true)
@@ -425,6 +450,8 @@ describe('Linux scope establishment and quiescence', () => {
     const killFailed = launch(async () => activeUnit(), {
       spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
     })
+    denyProcessGroups()
+    vi.spyOn(killFailed.child, 'kill').mockReturnValue(false)
     killFailed.result.owner.signal('SIGKILL')
     await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('could not signal')
     killFailed.result.owner.cleanup?.()
@@ -487,6 +514,133 @@ describe('Linux scope establishment and quiescence', () => {
     launched.result.owner.cleanup?.()
   })
 
+  it.each([
+    { state: 'empty', fresh: activeUnitWithTasks('0'), settles: true },
+    { state: 'inactive', fresh: activeUnit('inactive'), settles: true },
+    { state: 'populated', fresh: activeUnitWithTasks('1'), settles: false },
+    { state: 'unknown', fresh: activeUnitWithTasks('[not set]'), settles: false },
+  ].flatMap(value => ['delivered', 'already absent'].map(delivery => ({ ...value, delivery }))))(
+    'joins a $delivery fallback kill before deciding a $state scope', async ({ fresh, settles, delivery }) => {
+      denyProcessGroups()
+      const firstRead = Promise.withResolvers<ReturnType<typeof activeUnit>>()
+      const queried = Promise.withResolvers<undefined>()
+      const query = vi.fn()
+        .mockImplementationOnce(() => { queried.resolve(undefined); return firstRead.promise })
+        .mockResolvedValueOnce(fresh)
+      const sleep = vi.fn(async () => { throw new Error('unexpected poll delay') })
+      const spawnSync = recordingSystemctl()
+        .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
+      const launched = launch(query, { sleep, spawnSync: spawnSync as never }, {
+        ...spec(), stdio: { ...spec().stdio, control: 'pipe' },
+      })
+      if (delivery === 'already absent') {
+        vi.spyOn(launched.child, 'kill').mockReturnValue(false)
+        vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('absent'), { code: 'ESRCH' }) })
+      }
+      consumeLinuxLaunchRequest(launched.requestPath)
+      launched.result.owner.signal('SIGKILL')
+      let completed = false
+      const waiting = launched.result.owner.waitForExit().finally(() => { completed = true })
+      void waiting.catch(() => {})
+      try {
+        await queried.promise
+        firstRead.resolve(activeUnitWithTasks('1'))
+        await new Promise<void>(resolve => setImmediate(resolve))
+        expect(completed).toBe(false)
+        expect(query).toHaveBeenCalledOnce()
+        expect(sleep).not.toHaveBeenCalled()
+        launched.child.exit(null, 'SIGKILL')
+        if (settles) await expect(waiting).resolves.toBeUndefined()
+        else await expect(waiting).rejects.toThrow('Invalid argument')
+        expect(query).toHaveBeenCalledTimes(2)
+        expect(sleep).not.toHaveBeenCalled()
+        expect(launched.result.control).toBe(launched.child.control)
+        expect(launched.child.stdout.destroyed).toBe(false)
+        expect(launched.child.control.destroyed).toBe(false)
+      } finally {
+        launched.child.exit(null, 'SIGKILL')
+        await launched.result.direct
+        launched.child.stdout.destroy()
+        launched.child.stderr.destroy()
+        launched.child.control.destroy()
+        launched.result.owner.cleanup?.()
+      }
+    },
+  )
+
+  it('reports failed scope and direct kill submission without awaiting direct exit', async () => {
+    denyProcessGroups()
+    const query = vi.fn(async () => activeUnitWithTasks('1'))
+    const launched = launch(query, {
+      spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
+    })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    vi.spyOn(launched.child, 'kill').mockReturnValue(false)
+    launched.result.owner.signal('SIGKILL')
+    try {
+      await expect(launched.result.owner.waitForExit()).rejects.toThrow('permission denied')
+      expect(launched.child.signalCode).toBeNull()
+      expect(query).toHaveBeenCalledOnce()
+    } finally {
+      launched.child.exit(null, 'SIGKILL')
+      await launched.result.direct
+      launched.result.owner.cleanup?.()
+    }
+  })
+
+  it('reports a fresh surviving range immediately when direct exit precedes its query', async () => {
+    denyProcessGroups()
+    const query = vi.fn(async () => activeUnitWithTasks('1'))
+    const launched = launch(query, {
+      spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
+    })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    launched.result.owner.signal('SIGKILL')
+    launched.child.exit(null, 'SIGKILL')
+    await expect(launched.result.owner.waitForExit()).rejects.toThrow('Invalid argument')
+    expect(query).toHaveBeenCalledOnce()
+    await launched.result.direct
+    launched.result.owner.cleanup?.()
+  })
+
+  it('keeps a direct launch error observable while joining its settlement', async () => {
+    denyProcessGroups()
+    const firstRead = Promise.withResolvers<ReturnType<typeof activeUnit>>()
+    const query = vi.fn()
+      .mockImplementationOnce(() => firstRead.promise)
+      .mockResolvedValueOnce(activeUnit('inactive'))
+    const launched = launch(query, {
+      spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
+    })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    launched.result.owner.signal('SIGKILL')
+    const waiting = launched.result.owner.waitForExit()
+    firstRead.resolve(activeUnitWithTasks('1'))
+    await new Promise<void>(resolve => setImmediate(resolve))
+    const failure = new Error('direct process error')
+    const directFailure = expect(launched.result.direct).rejects.toBe(failure)
+    launched.child.emit('error', failure)
+    await directFailure
+    await expect(waiting).resolves.toBeUndefined()
+    expect(query).toHaveBeenCalledTimes(2)
+    launched.result.owner.cleanup?.()
+  })
+
+  it('retains state-query errors without awaiting direct settlement', async () => {
+    denyProcessGroups()
+    const failure = new Error('manager unreachable')
+    const launched = launch(async () => { throw failure }, {
+      spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
+    })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    launched.result.owner.signal('SIGKILL')
+    await expect(launched.result.owner.waitForExit()).rejects.toBe(failure)
+    expect(launched.child.signalCode).toBeNull()
+    launched.child.exit(null, 'SIGKILL')
+    await launched.result.direct
+    launched.result.owner.cleanup?.()
+  })
+
   it('settles a consumed empty scope before its launcher reports exit after a failed kill', async () => {
     denyProcessGroups()
     const spawnSync = recordingSystemctl()
@@ -515,6 +669,7 @@ describe('Linux scope establishment and quiescence', () => {
     const spawnSync = recordingSystemctl()
       .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
     const launched = launch(async () => activeUnitWithTasks(tasks), { spawnSync: spawnSync as never })
+    if (clientRunning) vi.spyOn(launched.child, 'kill').mockReturnValue(false)
     if (!clientRunning) consumeLinuxLaunchRequest(launched.requestPath)
     launched.result.owner.signal('SIGKILL')
     if (!clientRunning) launched.child.exit(null, 'SIGKILL')
@@ -754,7 +909,7 @@ describe('Linux PTY bootstrap reuse', () => {
     directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
     let running = true
     const kill = vi.fn()
-    const owner = scope.bindOwner({ running: () => running, signal: kill })
+    const owner = scope.bindOwner({ running: () => running, signal: kill.mockReturnValue(true), settled: Promise.resolve() })
     owner.signal(signal)
     expect(kill).toHaveBeenCalledExactlyOnceWith(signal)
     running = false
@@ -777,7 +932,7 @@ describe('Linux PTY bootstrap reuse', () => {
     if (requestPath === undefined) throw new Error('missing PTY request')
     expect(scope.args.slice(-3)).toEqual(['--', 'bash', '--noprofile'])
     expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
-    const owner = scope.bindOwner({ running: () => false, signal: vi.fn() })
+    const owner = scope.bindOwner({ running: () => false, signal: vi.fn(() => true), settled: Promise.resolve() })
     await expect(owner.waitForExit()).resolves.toBeUndefined()
     expect(scope.resolveOutcome({ exitCode: 0, signal: null })).toEqual({ exitCode: 0, signal: null })
     scope.cleanup()
@@ -802,7 +957,7 @@ describe('Linux PTY bootstrap reuse', () => {
     const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
     if (requestPath === undefined) throw new Error('missing PTY request')
     directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
-    scope.bindOwner({ running: () => true, signal: vi.fn() })
+    scope.bindOwner({ running: () => true, signal: vi.fn(() => true), settled: Promise.resolve() })
     expect(() => scope.resolveOutcome({ exitCode: 1, signal: null })).toThrow(
       'before its bootstrap consumed',
     )

+ 36 - 9
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
 import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import { childEnv } from '../src/spawn.ts'
+import { signalLinuxDirectProcess } from '../src/linux-scope.ts'
 
 function mockWin32ForIsolatedRuntime(): void {
   vi.doMock('@deepseek-ai/dsh-win32-process', () => ({
@@ -388,14 +389,15 @@ describe('LocalSubprocessRuntime', () => {
       ...await importOriginal<typeof import('../src/process-inspector.ts')>(),
       createProcessInspector: () => inspector,
     }))
+    vi.doMock('../src/linux-scope.ts', async importOriginal => ({
+      ...await importOriginal<typeof import('../src/linux-scope.ts')>(),
+      probeLinuxNative: () => false,
+    }))
     try {
       const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
       const ctx = new Context()
       const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
       const service = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
-      // Pins the containment choice: with the host's native scope a mocked PTY
-      // exit races the scope bootstrap.
-      service.internals = { platform: 'darwin' }
       const handle = await ctx.subprocess.spawnTerminal({
         argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
       })
@@ -407,6 +409,7 @@ describe('LocalSubprocessRuntime', () => {
     } finally {
       vi.doUnmock('node-pty')
       vi.doUnmock('../src/process-inspector.ts')
+      vi.doUnmock('../src/linux-scope.ts')
       unmockWin32ForIsolatedRuntime()
       vi.resetModules()
     }
@@ -415,8 +418,13 @@ describe('LocalSubprocessRuntime', () => {
   it('wraps Linux terminals in the selected scope and binds owner liveness', async () => {
     let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
     let launcherRunning: (() => boolean) | undefined
-    let launcherSignal: ((signal: 'SIGTERM' | 'SIGKILL') => void) | undefined
-    const terminalKill = vi.fn(() => { throw new Error('terminal already exited') })
+    let launcherSignal: ((signal: 'SIGTERM' | 'SIGKILL') => boolean) | undefined
+    let launcherSettlement: Promise<unknown> | undefined
+    const directProbe = vi.spyOn(process, 'kill').mockImplementation((_pid, signal) => {
+      if (signal === 0) return true
+      throw Object.assign(new Error('denied'), { code: 'EPERM' })
+    })
+    const terminalKill = vi.fn(() => {})
     const terminal = {
       pid: 123,
       onData: () => ({ dispose: () => {} }),
@@ -434,9 +442,10 @@ describe('LocalSubprocessRuntime', () => {
       terminateForHostExit: vi.fn(),
     }
     const launcherStates: boolean[] = []
-    const bindOwner = vi.fn((direct: { running(): boolean; signal(signal: 'SIGTERM' | 'SIGKILL'): void }) => {
+    const bindOwner = vi.fn((direct: { running(): boolean; signal(signal: 'SIGTERM' | 'SIGKILL'): boolean; settled: Promise<unknown> }) => {
       launcherRunning = () => direct.running()
-      launcherSignal = (signal) => { direct.signal(signal) }
+      launcherSignal = signal => direct.signal(signal)
+      launcherSettlement = direct.settled
       launcherStates.push(direct.running())
       return owner
     })
@@ -468,6 +477,7 @@ describe('LocalSubprocessRuntime', () => {
     mockWin32ForIsolatedRuntime()
     vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
     vi.doMock('../src/linux-scope.ts', () => ({
+      signalLinuxDirectProcess,
       launchLinuxScope: vi.fn(),
       prepareLinuxTerminalScope,
       probeLinuxManager,
@@ -505,17 +515,31 @@ describe('LocalSubprocessRuntime', () => {
       expect(bindOwner).toHaveBeenCalledOnce()
       expect(launcherStates).toEqual([true])
       expect(launcherRunning?.()).toBe(true)
-      expect(() => { launcherSignal?.('SIGTERM') }).not.toThrow()
-      expect(terminalKill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
+      expect(launcherSignal?.('SIGTERM')).toBe(false)
+      expect(terminalKill).not.toHaveBeenCalled()
+      directProbe.mockImplementationOnce(() => true)
+      expect(launcherSignal?.('SIGKILL')).toBe(true)
+      directProbe.mockImplementation(() => { throw Object.assign(new Error('absent'), { code: 'ESRCH' }) })
+      expect(launcherSignal?.('SIGKILL')).toBe(true)
+      expect(directProbe.mock.calls).toEqual([
+        [123, 'SIGTERM'], [123, 0], [123, 'SIGKILL'], [123, 'SIGKILL'], [123, 0],
+      ])
+      let directSettled = false
+      void launcherSettlement?.then(() => { directSettled = true })
+      await new Promise<void>(resolve => setImmediate(resolve))
+      expect(directSettled).toBe(false)
 
       exitListener?.({ exitCode: 0 })
       expect(launcherRunning?.()).toBe(false)
+      await launcherSettlement
+      expect(directSettled).toBe(true)
       await handle.done
       await new Promise(resolve => setImmediate(resolve))
       expect(owner.signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
       expect(owner.waitForExit).toHaveBeenCalledOnce()
     } finally {
       await fiber?.dispose()
+      directProbe.mockRestore()
       vi.doUnmock('node-pty')
       vi.doUnmock('../src/linux-scope.ts')
       unmockWin32ForIsolatedRuntime()
@@ -553,6 +577,7 @@ describe('LocalSubprocessRuntime', () => {
     mockWin32ForIsolatedRuntime()
     vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
     vi.doMock('../src/linux-scope.ts', () => ({
+      signalLinuxDirectProcess,
       launchLinuxScope: vi.fn(),
       prepareLinuxTerminalScope,
       probeLinuxManager: () => true,
@@ -725,6 +750,7 @@ describe('LocalSubprocessRuntime', () => {
     vi.resetModules()
     mockWin32ForIsolatedRuntime()
     vi.doMock('../src/linux-scope.ts', () => ({
+      signalLinuxDirectProcess,
       launchLinuxScope,
       prepareLinuxTerminalScope: vi.fn(),
       probeLinuxManager,
@@ -803,6 +829,7 @@ describe('LocalSubprocessRuntime', () => {
     vi.resetModules()
     mockWin32ForIsolatedRuntime()
     vi.doMock('../src/linux-scope.ts', () => ({
+      signalLinuxDirectProcess,
       launchLinuxScope: vi.fn(),
       prepareLinuxTerminalScope: vi.fn(),
       probeLinuxManager,