diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 0e523f1d20..580e74f217 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.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-07-06-timeout-deadline-library.md -2026-07-06-timeout-deadline-library.md: 0c29c1d82c7ebced3d6b5ce6c6ae0ecafff8af43 -2026-07-06-timeout-deadline-library.zh.md: 4c56061f7430ec6d220b2110939ad29c0160bf21 +2026-07-06-timeout-deadline-library.md: 38f048d16ecba0e5278ae34b0c88b7889dcfa47a +2026-07-06-timeout-deadline-library.zh.md: c8d189c2a7588ee57b0f7fe02137b78c9ad7ff9d diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 0c29c1d82c..38f048d16e 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -97,7 +97,7 @@ The signal only *notifies*; termination is always the listener's job, and the li ## Consequences -- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The subprocess provider still owns termination of its managed range, and the Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched. +- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The uniform SIGTERM→grace→SIGKILL kill is unchanged, and the Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched. - `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. An always-0 field read by nothing is dead weight under the per-file coverage gate. - web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`. - `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met). @@ -113,4 +113,4 @@ Out of scope, named to mark the boundary: `web_search` can gain an optional mode **A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule. -**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing provider-owned managed-range termination path remains unchanged. +**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing SIGTERM-to-SIGKILL termination path remains unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 4c56061f74..c8d189c2a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -97,7 +97,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): ## 后果 -- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。subprocess provider 继续拥有其 managed range 的终止过程,Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 +- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 - `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 - web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 @@ -113,4 +113,4 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): **用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到完全停稳,而非仅仅请求它」的防御性规则一致。 -**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,而既有的 provider-owned managed-range 终止路径保持不变。 +**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,而既有的 SIGTERM→SIGKILL 终止路径保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 8827fccdd1..277e0b097d 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.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-07-15-lsp-capability-seam.md -2026-07-15-lsp-capability-seam.md: b89b9afaa90aac8abf1f78c797f3219b6ef40248 -2026-07-15-lsp-capability-seam.zh.md: 40785612c3bfc219b0946ac09d6d0c36873b0c28 +2026-07-15-lsp-capability-seam.md: 90f9daf4b890bd53621916e492d4d82baaa3e8cc +2026-07-15-lsp-capability-seam.zh.md: bdb5e812e94a4aec4402fe83ca9c818bfa00f9f9 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md index b89b9afaa9..90f9daf4b8 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -108,7 +108,7 @@ The transport-neutral presenter uses `{ card: 'generic', kind: 'search', title, The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget. -Provider disposal occurs outside tool execution, so `dsh-lsp-stdio` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for request-cancel grace plus subprocess termination and output draining; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The LSP provider uses `deadline()` and `timeoutOf()` and owns request cancellation, while the subprocess provider owns range termination and observation; timeout notification alone does not terminate work. +Provider disposal occurs outside tool execution, so `dsh-lsp-stdio` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for both request-cancel grace and SIGTERM-to-SIGKILL escalation; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The provider uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. ## Workspace, filesystem, and document synchronization diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 40785612c3..bdb5e812e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -108,7 +108,7 @@ interface LspToolInput { seam 和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。 -提供方 dispose 发生在工具执行之外,因此 `dsh-lsp-stdio` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期,并提供给 subprocess 终止与输出排空;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。LSP provider 使用 `deadline()` 和 `timeoutOf()` 并拥有请求取消,subprocess provider 则拥有范围终止与观察;超时通知本身不会终止工作。 +提供方 dispose 发生在工具执行之外,因此 `dsh-lsp-stdio` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期和从 SIGTERM 升级到 SIGKILL 的宽限期;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。提供方使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 ## 工作区、文件系统与文档同步 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index a2d9f7d146..166541f1f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.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-07-26-subprocess-seam.md -2026-07-26-subprocess-seam.md: 973d3b2f9c88033ac61016c42ed3fe3320f4e0f6 -2026-07-26-subprocess-seam.zh.md: e535369b50f8f3e3b74c96de4e0f4341c185f1a6 +2026-07-26-subprocess-seam.md: 92d36bf6d522ab939ef6c0de1063e0accea2946f +2026-07-26-subprocess-seam.zh.md: d271ad95ce84bb34256d3bf2ee6d793e21623d2b diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index 973d3b2f9c..92d36bf6d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -39,6 +39,6 @@ Observed stream and lifecycle needs then moved the eligible process consumers on ## Consequences -Bought: "run and manage a process" is a swappable capability used by Bash, LSP, PTY, and ACP consumers; a containerized or remote process backend slots in without changing their domain semantics; managed-range signalling, termination scheduling, bounded collection, terminal mechanics, and credential scrubbing each have one owner; and background processes survive executor reloads, matching the job registry's lifetime model. Process and terminal plumbing is tested through `dsh-subprocess-local`; consumer suites pin only their owned behavior against the real service. +Bought: "run and manage a process" is a swappable capability used by Bash, LSP, PTY, and ACP consumers; a containerized or remote process backend slots in without changing their domain semantics; tree signalling, escalation, bounded collection, terminal mechanics, and credential scrubbing each have one implementation; and background processes survive executor reloads, matching the job registry's lifetime model. Process and terminal plumbing is tested through `dsh-subprocess-local`; consumer suites pin only their owned behavior against the real service. -Cost: one more package pair and one more composition row wherever a consumer loads; a missing subprocess provider leaves the consumer pending by standard service-injection behavior. Every backend implements executable lookup, three stdio modes, managed-range lifecycle, and one terminal primitive. The moved-vocabulary re-exports keep `dsh-shell` imports working but mean two packages name the same types; the subprocess seam is the owner. The spawn-failure note became single-delivery through Bash's consuming read cursor instead of repeatable stderr-buffer content. +Cost: one more package pair and one more composition row wherever a consumer loads; a missing subprocess provider leaves the consumer pending by standard service-injection behavior. Every backend implements executable lookup, three stdio modes, tree lifecycle, and one terminal primitive. The moved-vocabulary re-exports keep `dsh-shell` imports working but mean two packages name the same types; the subprocess seam is the owner. The spawn-failure note became single-delivery through Bash's consuming read cursor instead of repeatable stderr-buffer content. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index e535369b50..d271ad95ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -39,6 +39,6 @@ Status: implemented ## 后果 -换来的是:「运行并管理一个进程」成为 Bash、LSP、PTY 与 ACP 消费方共用的可替换能力;容器化或远程进程后端可以直接接入,而无需改变各领域语义;managed-range 信号、终止调度、有界收集、终端机制与凭据清除各自只有一个 owner;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。进程与终端管道通过 `dsh-subprocess-local` 测试;消费方测试套件只需针对真实服务固定各自拥有的行为。 +换来的是:「运行并管理一个进程」成为 Bash、LSP、PTY 与 ACP 消费方共用的可替换能力;容器化或远程进程后端可以直接接入,而无需改变各领域语义;进程树信号、升级终止、有界收集、终端机制与凭据清除各自只剩一份实现;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。进程与终端管道通过 `dsh-subprocess-local` 测试;消费方测试套件只需针对真实服务固定各自拥有的行为。 -代价是:多出一对包,而且凡加载消费方之处都多一行组合配置;缺少 subprocess 提供方时,消费方会按标准服务注入行为保持挂起。每个后端都要实现可执行文件查找、三种 stdio 模式、managed-range 生命周期和一个终端原语。迁移词汇的重导出让 `dsh-shell` 的导入继续可用,但也意味着两个包命名同一批类型;进程 seam 是所有者。spawn 失败提示经由 Bash 的消费式读取游标变为单次交付,不再是可重复读取的 stderr 缓冲内容。 +代价是:多出一对包,而且凡加载消费方之处都多一行组合配置;缺少 subprocess 提供方时,消费方会按标准服务注入行为保持挂起。每个后端都要实现可执行文件查找、三种 stdio 模式、进程树生命周期和一个终端原语。迁移词汇的重导出让 `dsh-shell` 的导入继续可用,但也意味着两个包命名同一批类型;进程 seam 是所有者。spawn 失败提示经由 Bash 的消费式读取游标变为单次交付,不再是可重复读取的 stderr 缓冲内容。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml index a96f2137a3..a81c4706e7 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.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-07-27-dispose-ladder-to-consumer.md -2026-07-27-dispose-ladder-to-consumer.md: bcd6aa98f45fabda702e41738bbec929f145f739 -2026-07-27-dispose-ladder-to-consumer.zh.md: 4e0dc565e3c19f3780a04d00991490f7a56d5c0f +2026-07-27-dispose-ladder-to-consumer.md: 58d835864d2f6544152fdf09cf6e93380793a2ae +2026-07-27-dispose-ladder-to-consumer.zh.md: dc7413e85fa6e52de14f51fd175c7b37b43029a4 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md index bcd6aa98f4..58d835864d 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -10,7 +10,7 @@ English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) ## Decision -The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call the provider-owned `terminate()` procedure and await an unbounded `waitForExit()` for the subprocess owner's managed-range exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real range exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call `terminate()`, whose SIGTERM→spec-grace→SIGKILL escalation already owns the signal timer, and await an unbounded `waitForExit()` for the subprocess owner's whole-tree exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real tree exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. ## Alternatives considered @@ -20,4 +20,4 @@ The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(c ## Consequences -Bought: the Service Definition is one method and one type smaller; Service Providers owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns termination and the final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the Service Definition suite pins the verbs the ladder composes (bounded `waitForExit` false before termination and an unbounded managed-range join after it) instead of the composed policy. +Bought: the Service Definition is one method and one type smaller; Service Providers owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns the termination window and final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the Service Definition suite pins the verbs the ladder composes (bounded `waitForExit` false before escalation and an unbounded whole-tree join after it) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md index 4e0dc565e3..dc7413e85f 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 provider-owned `terminate()` 过程,再无界等待 `waitForExit()`,由子进程责任方证明 managed range 已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认范围真正退出所需的完全停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已拥有信号定时器),再无界等待 `waitForExit()`,由子进程责任方证明整棵进程树已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认进程树真正退出所需的完全停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 ## 曾考虑的替代方案 @@ -20,4 +20,4 @@ Status: implemented ## 后果 -买到的:Service Definition 少了一个方法和一个类型;Service Provider 只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止与最终的 managed-range 等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,Service Definition 套件转而钉住阶梯所组合的动词(终止前有界 `waitForExit` 返回假,终止后无界等待 managed range 退出),而非组合后的策略。 +买到的:Service Definition 少了一个方法和一个类型;Service Provider 只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止时间窗与最终的整树退出等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,Service Definition 套件转而钉住阶梯所组合的动词(升级前有界 `waitForExit` 返回假,升级后无界等待整棵进程树退出),而非组合后的策略。 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index b0f05e0482..870bf8ec05 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.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-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: bc73a6ebc6edfcbc2f4e4a932806217562d3e73d -2026-08-01-packaged-ripgrep-search.zh.md: 43e907bdc7114daa25ac56338c1be2999ed9c65a +2026-08-01-packaged-ripgrep-search.md: c401bc1e30df0c3443ad58b37f64523b1c10cb31 +2026-08-01-packaged-ripgrep-search.zh.md: 6a195e59f77aa73b9d0918461cef45360e617098 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index bc73a6ebc6..c401bc1e30 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -12,9 +12,9 @@ The `glob`/`grep` tools ran through the bash executor seam, which made a system ## Decision -`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The termination/output-drain grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. -Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-tool-call-timeout-policy` aborts `exec.signal`, the subprocess provider starts managed-range termination, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. +Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-tool-call-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged binary against a prepared workspace whose fixed mtimes pin the `--sort=modified` order, replacing the PATH-injected `rg` stand-in (POSIX-only, because the displayed paths carry `/` separators the session-log comparison cannot normalize). diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index 43e907bdc7..6a195e59f7 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。`rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止/输出排空宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。`rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 -退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-tool-call-timeout-policy` 中止 `exec.signal`,subprocess provider 启动 managed-range 终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 +退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-tool-call-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 `fs-glob-sampling` ACP(Agent Client Protocol)快照场景改为执行真实的打包二进制,作用于一个用固定 mtime 钉住 `--sort=modified` 顺序的预制工作区,取代 PATH 注入的 `rg` 替身(仅 POSIX:展示路径携带 `/` 分隔符,会话日志比较无法归一化)。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index 2f4dd87029..66fa66d233 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.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-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 7ea5a17fcdab23621b0017ede56e8bfb11507f35 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: 4623dde43d0e47cc0bc9bf5c6714c8572d1a5f01 +2026-08-10-product-subagent-providers-in-shared-host.md: 196e28c1263c4b6d71eaeb59b9ba8457b36f3ff4 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 1451890e1b250a2095e3366c59d6ce0873b55fe9 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index 7ea5a17fcd..196e28c126 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -14,13 +14,13 @@ The placement decision must preserve two independent facts. Loading a provider m Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider Bundle; its patch mounts the default instance, and the Profile may mount additional named instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry. -Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, managed-range lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. +Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its pinned Agent SDK select the private native executable. Neither provider consults or falls back to a host product command. Profile loading creates no product state, probes no version or authentication, and may supply each mounted Provider instance's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing platform payloads and product failures remain local to the attempted delegation. ## Verification -The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition installs both optional Bundles and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove each Bundle default and additional named instances register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove private platform-payload selection without host fallback, configuration isolation, failure, cancellation, and managed-range quiescence. +The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition installs both optional Bundles and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove each Bundle default and additional named instances register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove private platform-payload selection without host fallback, configuration isolation, failure, cancellation, and process-tree quiescence. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index 4623dde43d..1451890e1b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -14,13 +14,13 @@ Status: implemented 产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方 Bundle;其 patch 挂载默认实例,而 Profile 可以在 host plane 挂载更多命名实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.zh.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex` 与 `claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。 -每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、managed-range 生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.zh.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 +每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.zh.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapper,Claude Code 包则让锁定的 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令。加载 Profile 不会创建产品状态、探测版本或测试身份验证;它可以提供每个已挂载 Provider 实例的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。平台载荷缺失和产品故障仍局限于发生问题的那次委派。 ## 验证 -base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装会安装两个可选 Bundle,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个 Bundle 默认实例与额外命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明私有平台载荷选择与无宿主回退、配置隔离、失败、取消和 managed-range 完全停稳。 +base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装会安装两个可选 Bundle,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个 Bundle 默认实例与额外命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明私有平台载荷选择与无宿主回退、配置隔离、失败、取消和进程树完全停稳。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 5393e1c3ff..b3aae39a5d 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.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: 086bc83c2c146cc836d32c1b4b73991fa00f6fc0 -2026-08-19-shared-win32-process-primitives.zh.md: 62a519e4e327ad238eeb4fd861ea14646ef28b85 +2026-08-19-shared-win32-process-primitives.md: a3ab8ebcfac7c2ad3429bcea2993fb5281a9d2e0 +2026-08-19-shared-win32-process-primitives.zh.md: e64f7537cb54e9eb1aaeb3dcf3b37cf300721b36 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 086bc83c2c..a3ab8ebcfa 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked pipe, Job, wait, termination, and handle operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked pipe, Job, wait, polling, termination, and handle operations. 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 retains its process handle only until the parent opens a separate wait handle, while the subprocess parent owns direct-result polling and Job accounting, termination, and closure. +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. The package exports only operations used by the two production consumers. Exact `applicationName`, parent-stdio release, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time direct-exit reads, parent-side process opening, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time exit reads, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered @@ -28,7 +28,7 @@ The shared suite covers x64 ABI values, command-line quoting, binding extension, **Copy the Koffi implementation into each consumer.** Rejected because struct layouts, error capture, and partial-failure cleanup would have multiple owners. -**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, direct wait/poll, and Job controls were added only with their runner and parent consumers. +**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, polling, and Job controls were added only with their runner consumer. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 62a519e4e3..e64f7537cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 pipe、Job、wait、termination 与 handle 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 pipe、Job、wait、polling、termination 与 handle 操作。 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 只把自己的 process handle 保留到 parent 打开独立 wait handle,而 subprocess parent 拥有 direct-result polling 以及 Job accounting、termination 与 closure。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独轮询 direct process,并只在 Job 为空后关闭它。 该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-stdio release、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time direct-exit 读取、parent-side process opening、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered @@ -28,7 +28,7 @@ shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF **为每个 consumer 复制 Koffi 实现。** 拒绝,因为 struct layout、错误捕获与局部失败清理会出现多个 owner。 -**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、direct wait/poll 与 Job control 只随实际 runner 和 parent consumer 一起加入。 +**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、polling 与 Job control 只随实际 runner consumer 一起加入。 ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 4b9440bce5..216e14e98c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.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: 8e0e9b338f1c0f5f2aa0d6a7acde5807025f82ad -2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 723d93c9bfb6898563b10c05ac2705a3dee50646 +2026-08-11-synchronous-subprocess-exit-cleanup.md: 517b7e82f00ea16ec6d9f8731be67963a46035da +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: f9555bba3d7fb2dc9971aef21f8a2c8c17d25398 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index 8e0e9b338f..517b7e82f0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -6,7 +6,7 @@ English | [中文](2026-08-11-synchronous-subprocess-exit-cleanup.zh.md) ## Problem -The local subprocess provider owns ordinary managed ranges and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and staged-termination timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. +The local subprocess provider owns ordinary detached process trees and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and escalation timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. The public subprocess seam correctly promises awaited quiescence during normal disposal. The defect is a separate final host-exit path below that seam, not a reason to weaken the normal lifecycle or duplicate process ownership in every launcher. @@ -16,15 +16,15 @@ 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 parent-held Job when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID /T /F` on Windows. +- 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 /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. -Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: each ordinary handle runs its provider-owned termination procedure, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS range is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. +Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: ordinary trees receive TERM, the configured grace, then KILL, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS tree is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. | Host path | Local provider action | Completion evidence | | --- | --- | --- | -| Normal Cordis disposal | Provider-owned termination and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | +| Normal Cordis disposal | Cooperative termination, bounded escalation, and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | | `process.exit()`, default uncaught exception, or default unhandled rejection | Synchronous final signals against the service's current live sets | External observation after the host exits | | Default termination for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`; `SIGKILL`; fatal OOM; `process.abort()`; native crash; or power loss | No in-process action can run | External supervisor, container, or OS ownership is required unless the application installs a signal handler that performs disposal or calls `process.exit()` | @@ -32,7 +32,7 @@ Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subpr A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree. -Unit evidence pins synchronous native-owner and fallback delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, staged POSIX and immediate Windows disposal, live-set retention during pending disposal, and listener removal after disposal. +Unit evidence pins synchronous native-owner and fallback delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index 723d93c9bf..f9555bba3d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -本地 subprocess provider拥有普通 managed range和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.zh.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与分阶段终止 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 +本地 subprocess provider拥有普通 detached进程树和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.zh.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与升级 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 公共 subprocess seam在正常 dispose期间承诺等待完全停稳,这项承诺是正确的。缺陷属于 seam之下另一条最终宿主退出路径,不应削弱正常生命周期,也不应让每个 launcher重复保存进程所有权。 @@ -16,15 +16,15 @@ Status: implemented 该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: -- 普通 handle在可用时同步向绑定的 native scope 或 parent-held Job 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID /T /F`。 +- 普通 handle在可用时同步向绑定的 native scope 或 Job runner 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID /T /F`。 - Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 - 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 -正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:每个普通 handle 执行其 provider-owned termination procedure,并等待每个普通或 terminal 清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS range 已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 +正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:普通进程树先接收 TERM,经过配置的宽限期后再接收 KILL,并等待每个普通或 terminal清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS进程树已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 | 宿主路径 | 本地 provider动作 | 完成证据 | | --- | --- | --- | -| 正常 Cordis dispose | provider-owned termination,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | +| 正常 Cordis dispose | 协作式终止、有界升级,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | | `process.exit()`、默认未捕获异常或默认未处理 rejection | 对服务当前存活集合发送同步最终信号 | 宿主退出后的外部观察 | | 未安装 handler 时由 `SIGTERM`、`SIGINT` 或 `SIGHUP` 默认终止;`SIGKILL`;fatal OOM;`process.abort()`;native crash;或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS 所有权负责;应用安装执行 dispose 或调用 `process.exit()` 的信号 handler 时除外 | @@ -32,7 +32,7 @@ Status: implemented 父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。 -单元证据固定同步 native-owner 与 fallback 投递、PTY root终止前后的 terminal扫描、重复最终清理、POSIX 分阶段与 Windows 立即终止、dispose等待期间保留存活集合,以及 dispose后移除 listener。 +单元证据固定同步 native-owner 与 fallback 投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index 0f4a4eb1f4..a3cf978af0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 708de5d2ce4431222a7f36981d2fb4a3a801c5c0 -2026-08-20-subprocess-native-containment.zh.md: 883cf84f79e5ce00afe3fa9d00097923f6b65afa +2026-08-20-subprocess-native-containment.md: d5a405cddaa2878ca67e7b057d78c79297de26ca +2026-08-20-subprocess-native-containment.zh.md: abd450fe840e4e1e2f388cf40cc24f6b5b7b8860 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 708de5d2ce..d5a405cdda 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.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 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. +`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. -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 runner report target spawn and exit facts and lets the Windows runner report target startup or failure 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. +The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. -Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. Windows target descendants inherit the parent-owned Job by default. The parent owns private named-pipe endpoints for every non-inherited standard stream. The runner opens only their target-side handles, opens the Job for suspended create, assignment, and resume, and closes its copies before publishing the target pid. After the parent opens its own direct-process wait handle, it releases the runner through their private IPC channel. The parent then observes target exit, stream EOF, Job termination, and `ActiveProcesses` through separate owned resources. Host exit closes the parent's Job handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members. +Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default; after target creation, the runner releases its own standard-handle copies before publishing startup, so pipe EOF follows the target and descendants that actually inherited the stream. The runner remains until the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit. When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback. @@ -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. The Windows runner remains only until the parent acquires direct-process observation, and it closes its target-side named-pipe handles before release; the Linux runner remains until the direct target result, while the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux event-file reads use asynchronous 100 ms polling, Windows direct-process state uses 10 ms polling, and Linux scope state uses 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry, short-lived private files, and per-spawn pipe names 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 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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 883cf84f79..abd450fe84 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.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 创建并持有 named kill-on-close Job;由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 +`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。 -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 runner 报告 target spawn 与 exit facts,并让 Windows runner 报告 target startup 或 failure,两者都不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 +common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 -Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;如果 scope KILL 阻止最终 target event 写入,`.done` 会拒绝而不是虚构结果。Windows target descendant 默认继承 parent-owned Job。parent 为每条非 inherit 标准流持有 private named-pipe endpoint。runner 只打开其 target 端 handle,并打开 Job 完成 suspended create、assignment 与 resume;它在发布 target pid 前关闭自己的 pipe handle 副本。parent 打开自己的 direct-process wait handle 后,通过双方的 private IPC channel 释放 runner。随后由 parent 分别观察 target exit、stream EOF、Job termination 与 `ActiveProcesses`。host exit 会关闭 parent 的 Job handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。 +Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;target 创建后,runner 会在发布启动事实前释放自身持有的标准句柄副本,因此 pipe EOF 取决于 target 与实际继承该流的 descendant。runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 @@ -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 秒。Windows runner 只保留到 parent 取得 direct-process observation,且它的 target 端 named-pipe handle 会在释放前关闭;Linux runner 则保留到 direct target result,后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux event file 每 100 ms、Windows direct-process state 每 10 ms、Linux scope state 每 200 ms 异步轮询,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry、短期 private files 与 per-spawn pipe name,但不增加公共配置或 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 秒,每个 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 宿主继续可用,但保证会被明确削弱。 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index 76d7c3faa7..a9a33f4ebf 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.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/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md -2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: f26c92fbda4fafe9e5bdc469dae7ed0ab530b0b9 -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: e72aff91daa7f9e96007a09c0ced25e56881bf2e +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 84314eaf5827464767666b1b9c65e105ea4e869a +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: a822aac655ea3577660f09b2f2a2986f2a780d7a diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md index f26c92fbda..84314eaf58 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -15,7 +15,7 @@ Three packages, layered exactly like the existing Python stack, plus one Service - **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-sdk-jsonrpc-server` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). - **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). - **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`. -- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, managed-range teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. +- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. `dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); `dsh-jsonrpc-agent-pkg` (the Python runtime closure) gains the `dsh-sdk-protocol` dependency line. diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index e72aff91da..a822aac655 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -15,7 +15,7 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[ - **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-sdk-jsonrpc-server` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 - **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 - **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 -- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、managed-range 拆卸)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 +- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 `dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);`dsh-jsonrpc-agent-pkg`(Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index e5b9b1d0f5..30ab9e2b09 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 3c77321d3f5252a26f484fc78f586eb172e6d465 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f9b675735fa3e15693b558d8dfd0eaea38f33e88 +2026-08-04-claude-code-and-codex-subagent-backends.md: 9b47fcf49d47d2c3561245fa1e16ff8c5da0a35c +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 8ba4d259872558ced89083bb53f9228f46c3d45c diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 3c77321d3f..9b47fcf49d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -8,19 +8,19 @@ English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) The named [`ctx.subagents`](2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. Each route must hand the product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or managed ranges. Required evidence therefore separates three facts: a keyless real-product test proves the official integration, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier. +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required evidence therefore separates three facts: a keyless real-product test proves the official integration, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier. ## Decision The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions, and the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns version-pinned product categories, lifecycle stages, and process outcomes exposed through the same diagnostic. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration. -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, managed-range termination, and quiescence observation. +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. ```text configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process foreground <- final product outcome background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls - both -> provider disposal -> dsh-subprocess -> managed-range exit + both -> provider disposal -> dsh-subprocess -> whole-tree exit ``` ### Ownership and lifecycle @@ -30,7 +30,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro | Delegation lifecycle | `ctx.subagents` | Resolve the named provider request and pair lifecycle events around the published `SubagentRun` | Unsupported context or malformed input fails before a run is published; start and terminal events remain paired | | Scheduling and adaptation | `dsh-tool-subagent` | Interpret `run_in_background`, choose foreground collection or one-shot Job registration, and map the shared stop reason | Foreground returns the product outcome; background returns a Job id after registration | | Job state and control | `ctx.jobs` and `dsh-tool-jobs` | Own Job state, output, cancellation, owner cleanup, completion notices, and model-facing controls | The exact parent can collect, list, or stop background work and receives its completion notice | -| Native run and teardown | Product provider and `dsh-subprocess` | Produce one native result, close the product protocol, request best-effort native cancellation, and prove managed-range exit | Foreground return and Job settlement both wait for idempotent disposal and managed-range exit | +| Native run and teardown | Product provider and `dsh-subprocess` | Produce one native result, close the product protocol, request best-effort native cancellation, and prove process-tree exit | Foreground return and Job settlement both wait for idempotent disposal and whole-tree exit | ## Codex provider @@ -42,7 +42,7 @@ Before publication, the provider validates a non-empty text-only task, starts th For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. -An unpublished startup failure closes the wire, invokes termination for any acquired managed range, waits for it to empty, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the subprocess provider's termination procedure, waits for managed-range exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal. +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal. Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. @@ -54,7 +54,7 @@ The public configuration contains a non-empty `providerName`, an explicit `env` The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns every non-success category, stage, process outcome, and its ordering with a contributing permission decision. Local cancellation wins and becomes `aborted` without either diagnostic fact. -Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke the subprocess provider's termination procedure, and wait for managed-range exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's quiescence proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. +Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract directly: the runtime-only DeepSeek key becomes `ANTHROPIC_AUTH_TOKEN`, the fixed official base gains `/anthropic`, and the main and subagent model variables select the documented DeepSeek models. It starts the production provider and real SDK/CLI, requires one random nonce as the complete answer, persists no credential in settings, and waits for every managed handle to exit. @@ -62,13 +62,13 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native managed-range exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence. +The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and managed-range exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. +The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. -The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves managed-range exit without calling the Messages API directly from the test. +The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. The project owner's distribution authorization is scoped to the official `@anthropic-ai/claude-agent-sdk` identity and the official Claude Code CLI/platform payloads each SDK version declares through `optionalDependencies`. [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) derives and discloses the current payload set without reclassifying its declared terms as permissive. Version, license-field, and payload-set changes still undergo ordinary dependency, lockfile, compatibility, terms, and notices review; unrelated non-permissive runtime packages continue to fail closed. @@ -76,7 +76,7 @@ The project owner's distribution authorization is scoped to the official `@anthr **Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead. -**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and managed-range concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly. +**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly. **A model-visible product selector.** Product availability, instance configuration, and authentication are deployment facts. Profile-bound tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. @@ -88,7 +88,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and managed-range quiescence. +Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic containing provider-owned permission facts or version-pinned structured failure facts. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Both products use Bundle-pinned platform CLIs plus native account and workspace settings and the selected Provider permission mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index f9b675735f..8ba4d25987 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -8,19 +8,19 @@ Status: implemented 命名的 [`ctx.subagents`](2026-06-21-subagent-capability-seam.zh.md) 注册表让父 agent(智能体)无需了解子 agent 的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。每条路径都必须向产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 -产品集成不得成为任务文本、cwd、取消、结果结算或 managed range 的第二责任方。因此,所需证据要区分三个事实:无密钥真实产品测试证明官方集成、原生身份验证形态、确定性答案与资源清理;Loader 组合测试证明公开包和文档所示的工具配置无需启动产品即可加载;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务取得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代上述任一产品运行层级;手工挂载插件无法取代 Loader 层级。 +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,所需证据要区分三个事实:无密钥真实产品测试证明官方集成、原生身份验证形态、确定性答案与资源清理;Loader 组合测试证明公开包和文档所示的工具配置无需启动产品即可加载;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务取得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代上述任一产品运行层级;手工挂载插件无法取代 Loader 层级。 ## 决策 harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 模式选择与安全权限决定,[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)则负责通过同一诊断公开锁定产品版本的类别、生命周期阶段与进程结果。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。 -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、managed-range 终止与完全停稳观测。 +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 ```text configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process foreground <- final product outcome background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls - both -> provider disposal -> dsh-subprocess -> managed-range exit + both -> provider disposal -> dsh-subprocess -> whole-tree exit ``` ### 归属与生命周期 @@ -30,7 +30,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro | 委派生命周期 | `ctx.subagents` | 解析具名提供方请求,并为已发布的 `SubagentRun` 配对生命周期事件 | 不受支持的上下文或格式错误的输入会在发布运行前报错;启动与终态事件保持成对 | | 调度与适配 | `dsh-tool-subagent` | 解释 `run_in_background`,选择前台收集或 one-shot Job 登记,并映射共享停止原因 | 前台返回产品结果;后台在登记完成后返回 Job id | | Job 状态与控制 | `ctx.jobs` 与 `dsh-tool-jobs` | 负责 Job 状态、输出、取消、owner 清理、完成通知与面向模型的控制工具 | 准确父级可以收集、列出或停止后台工作,并收到完成通知 | -| 原生运行与清理 | 产品提供方与 `dsh-subprocess` | 产生一个原生结果、关闭产品协议、请求尽力而为的原生取消,并证明 managed range 退出 | 前台返回与 Job 结算都会等待幂等资源释放和 managed range 退出 | +| 原生运行与清理 | 产品提供方与 `dsh-subprocess` | 产生一个原生结果、关闭产品协议、请求尽力而为的原生取消,并证明进程树退出 | 前台返回与 Job 结算都会等待幂等资源释放和整棵进程树退出 | ## Codex 提供方 @@ -42,7 +42,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 -若启动在发布前失败,提供方会关闭协议连接、请求终止所有已取得的 managed range、等待范围为空、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用 subprocess provider 的终止过程,等待 managed range 退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。 +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。 Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 @@ -54,7 +54,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责所有非成功类别、阶段、进程结果,以及它们与参与失败的权限决定之间的顺序。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。 -启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用 subprocess provider 的终止过程,并等待 managed range 退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的完全停稳证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 +启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 带密钥 Claude Code e2e 直接使用官方 DeepSeek Claude Code 约定:仅在运行时提供的 DeepSeek 密钥会映射为 `ANTHROPIC_AUTH_TOKEN`,固定的官方基础 URL 会追加 `/anthropic`,主模型与 subagent 模型变量会选择文档所示的 DeepSeek 模型。该测试会启动生产提供方与真实 SDK 和 CLI,要求一个随机数作为完整答案,不会把任何凭据持久化到设置中,并等待所有受管句柄退出。 @@ -62,13 +62,13 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生 managed range 退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责 schema、失败、进程结果与最终呈现证据。 +Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责 schema、失败、进程结果与最终呈现证据。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及 managed range 退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 +Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 -带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明 managed range 退出,且测试不会直接调用 Messages API。 +带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 项目所有者的分发授权范围限定为官方 `@anthropic-ai/claude-agent-sdk` 身份,以及每个 SDK 版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 会推导并披露当前载荷集合,但不会将其声明条款重新归类为宽松条款。版本、许可证字段和载荷集合发生变化时,仍须经过常规的依赖、锁文件、兼容性、条款和声明评审;无关的非宽松运行时包继续以默认拒绝方式失败。 @@ -76,7 +76,7 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SD **直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展集成接口,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都改用相应的官方产品集成。 -**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和 managed range 的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。 +**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。 **面向模型的产品选择器。** 产品可用性、实例配置和身份验证属于部署事实。由 Profile 绑定的工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 @@ -88,7 +88,7 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SD ## 后果 -用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与 managed-range 完全停稳的责任。 +用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断,其中包含由提供方拥有的权限事实,或锁定版本产品提供的结构化失败事实。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。两个产品都使用 Bundle 锁定的平台 CLI,并保留原生账户与工作区设置以及所选提供方权限模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index 434fd6f19a..48455ab7b7 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: 8fc35ad82d75114dccd39a1176996665d0c28376 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 3c6bb848688f891b29a94683573f3479e50da6e2 +2026-08-12-product-subagent-one-shot-background-tasks.md: 5e9522f6fac6eadb874ba2d1d4f45100f962b9e2 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: fdf7fa58e49f6aa9b6fe7a21eb21e6208e56a86f diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index 8fc35ad82d..5e9522f6fa 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -16,7 +16,7 @@ Production `dsh` does not install the optional product providers. A Profile that The [named-instance decision](2026-08-18-product-subagent-named-instances.md) allows multiple rows for either product. Each additional host provider row has its own `providerName`, and each exposed preset tool row binds that exact name through `provider` while keeping a unique `toolName`; the foreground/background scheduling choice does not constrain the number of instances. -The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and managed-range quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. +The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run. @@ -39,7 +39,7 @@ product tool call | Product selection and exposure | Agent Preset | Bind one fixed tool name to one fixed provider | Enabling one row exposes only that product tool | | Foreground or background choice | `dsh-tool-subagent` | Resolve `run_in_background` under `one-shot` policy | Omission is foreground; explicit `true` returns a Job id | | Job id, state, output, cancellation, and notice | `ctx.jobs` and `dsh-tool-jobs` | Register and present the existing one-shot run | Generic job tools collect or stop the run for the exact parent | -| Native result, optional diagnostic, and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one managed range | Job settlement and foreground return consume the same result and both wait for disposal | +| Native result, optional diagnostic, and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one process tree | Job settlement and foreground return consume the same result and both wait for disposal | ## Published composition @@ -69,4 +69,4 @@ The Web composition test explicitly mounts both optional providers from the repo Agents can continue useful work while Codex or Claude Code handles an independent one-shot task, then collect the final answer or cancel it through the same Job controls used by other background producers. Foreground and one-shot background consumers present the same safe Provider diagnostic when a failed result supplies one. -Every product delegation still starts a fresh native process or query, produces final assistant text as its only assistant payload, and ends with provider disposal and managed-range exit. A failed result may separately carry a safe diagnostic. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. +Every product delegation still starts a fresh native process or query, produces final assistant text as its only assistant payload, and ends with provider disposal and whole-tree exit. A failed result may separately carry a safe diagnostic. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index 3c6bb84868..fdf7fa58e4 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -16,7 +16,7 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 [命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)允许两个产品分别拥有多个配置项。每个新增宿主提供方配置项都有独立的 `providerName`,每个公开的 preset 工具配置项都通过 `provider` 绑定该名称并保持唯一的 `toolName`;前台或后台调度选择不会限制实例数量。 -[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.zh.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.zh.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.zh.md)继续负责原生协议、答案选择、本地取消与 managed-range 完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 配置与诊断生产。 +[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.zh.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.zh.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.zh.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 配置与诊断生产。 本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。 @@ -39,7 +39,7 @@ product tool call | 产品选择与公开 | Agent Preset | 把一个固定工具名绑定到一个固定提供方 | 启用一行只会公开对应产品工具 | | 前台或后台选择 | `dsh-tool-subagent` | 按 `one-shot` 策略解析 `run_in_background` | 省略参数时在前台运行;显式传入 `true` 时返回 Job id | | Job id、状态、输出、取消与通知 | `ctx.jobs` 与 `dsh-tool-jobs` | 登记并展示现有 one-shot 运行 | 通用作业工具为准确父级收集或停止运行 | -| 原生结果、可选诊断与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一个 managed range | Job 结算与前台返回消费同一结果,且都会等待资源释放 | +| 原生结果、可选诊断与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一棵进程树 | Job 结算与前台返回消费同一结果,且都会等待资源释放 | ## 发布组装 @@ -69,4 +69,4 @@ Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供 agent 可以在 Codex 或 Claude Code 处理独立 one-shot 任务时继续推进其他工作,随后通过其他后台 producer 共用的 Job 控制工具收集最终回答或取消运行。若失败结果提供了安全的提供方诊断,前台与一次性后台消费方会呈现同一内容。 -每次产品委托仍会启动一个全新的原生进程或 query,把最终 assistant 文本作为唯一 assistant 载荷,并以提供方资源释放和 managed-range 退出结束。失败结果可以另行携带安全诊断。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 +每次产品委托仍会启动一个全新的原生进程或 query,把最终 assistant 文本作为唯一 assistant 载荷,并以提供方资源释放和整棵进程树退出结束。失败结果可以另行携带安全诊断。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 44f4718609..89bfdef747 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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/feature/2026-08-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: aa69d1a296f55df01db2632b105511d24ad6b067 -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 8525969f189dcb74dec55d19fc869c47f3a1e992 +2026-08-15-product-subagent-noninteractive-permissions.md: 8788fba3492e08090dd038fc3e7377f6bd1e29cd +2026-08-15-product-subagent-noninteractive-permissions.zh.md: cbf3c3cd14fcecd2c24e335a8b71cc3b5370e247 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index aa69d1a296..8788fba349 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -59,11 +59,11 @@ The foreground consumer presents the stop-reason headline, then the optional dia | Interaction decisions and safe diagnostic | One product run | Concurrent runs keep independent mode, protocol, and diagnostic state. | | Diagnostic type and byte limit | `dsh-subagent` | Consumers receive a bounded optional field separate from assistant output. | | Foreground and Job presentation | `dsh-tool-subagent` and the generic Job runtime | Scheduling choice does not change the underlying failure fact. | -| Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent managed-range disposal. | +| Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent whole-tree disposal. | ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and managed-range quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native managed range exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 8525969f18..cbf3c3cd14 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -59,11 +59,11 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 | 交互决定与安全诊断 | 单次产品运行 | 并发运行分别拥有独立的模式、协议与诊断状态。 | | 诊断类型与字节上限 | `dsh-subagent` | 消费方收到与 assistant 输出分离的有界可选字段。 | | 前台与 Job 呈现 | `dsh-tool-subagent` 和通用 Job 运行时 | 调度选择不会改变底层失败事实。 | -| 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的 managed-range 资源释放。 | +| 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的完整进程树资源释放。 | ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与 managed-range 完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native managed range 会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml index 2bace94aa2..dca365d854 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.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/feature/2026-08-18-product-subagent-failure-facts.md -2026-08-18-product-subagent-failure-facts.md: 79947c57a267b23e9f73dd595ebf89e209fed066 -2026-08-18-product-subagent-failure-facts.zh.md: 01a88322081b048ee1bf044ffb40ad16e88a1693 +2026-08-18-product-subagent-failure-facts.md: 50d8e918f288a6b8a9b90474499b2ed20731643f +2026-08-18-product-subagent-failure-facts.zh.md: abdbf8c0ebddb1fb30cef3c7e80fcf7040e45d2d diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md index 79947c57a2..50d8e918f2 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md @@ -35,7 +35,7 @@ Agent SDK 0.3.220 defines four error subtypes: `error_during_execution`, `error_ | `query-start` | SDK query construction, native platform-payload startup, and unpublished rollback | `start()` rejects with fixed safe facts and any process outcome observed before rollback | | `query-run` | Published SDK message iteration and strict terminal-result validation | The run resolves as `error` with the exact known subtype or a fixed result category | | `process` | Managed CLI exits before the SDK supplies a terminal result | The run resolves as `error` with `process-exit` and the available exit code and signal | -| `teardown` | Query close and managed-range release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | +| `teardown` | Query close and managed process-tree release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | ### Codex facts @@ -48,7 +48,7 @@ Codex app-server 0.147.0 defines eleven string categories and five object varian | `turn-start` | Published `turn/start` request, provisional ids, and early frames | The run resolves as `error` with a safe unknown fallback when no structured category exists | | `turn` | Terminal notification, final-answer selection, and error-info mapping | The complete category and optional HTTP status reach the non-completed result | | `process` | Managed app-server exits before another terminal path settles | The run resolves as `error` with `process-exit` and any available code and signal | -| `teardown` | Wire close and managed-range release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines | +| `teardown` | Wire close and process-tree release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines | `contextWindowExceeded` remains `max-tokens`; every other known or unknown Codex category remains `error`, and `cyberPolicy` does not become `refusal`. @@ -64,7 +64,7 @@ Codex app-server 0.147.0 defines eleven string categories and five object varian ## Verification -Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and managed-range quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`. +Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and whole-tree quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md index 01a8832208..abdbf8c0eb 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md @@ -35,7 +35,7 @@ Agent SDK 0.3.220 定义四种错误子类型:`error_during_execution`、`erro | `query-start` | SDK query 构造、原生平台载荷启动与未发布回滚 | `start()` 以固定安全事实和回滚前已观测到的进程结果拒绝 | | `query-run` | 已发布 SDK 消息迭代与严格终态结果校验 | 运行以 `error` 兑现,并携带准确已知子类型或固定结果类别 | | `process` | SDK 提供终态结果之前受管 CLI 已退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码和信号 | -| `teardown` | Query 关闭与 managed-range 释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | +| `teardown` | Query 关闭与受管进程树释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | ### Codex 事实 @@ -48,7 +48,7 @@ Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant | `turn-start` | 已发布 `turn/start` 请求、暂定 id 与早到 frame | 没有结构化类别时,运行以 `error` 和安全 unknown 回退兑现 | | `turn` | 终态通知、最终答案选择与 error-info 映射 | 完整类别与可选 HTTP status 进入非完成结果 | | `process` | 受管 app-server 在另一终态路径结算前退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码与信号 | -| `teardown` | Wire 关闭与 managed-range 释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 | +| `teardown` | Wire 关闭与进程树释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 | `contextWindowExceeded` 仍是 `max-tokens`;其他所有已知或未知 Codex 类别仍是 `error`,`cyberPolicy` 不会变成 `refusal`。 @@ -64,7 +64,7 @@ Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant ## Verification -Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与 managed-range 完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。 +Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml index 2387488610..614f036a6a 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.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/feature/2026-08-18-product-subagent-named-instances.md -2026-08-18-product-subagent-named-instances.md: 997c555699e8de8c47be9a68bea729a596dbda10 -2026-08-18-product-subagent-named-instances.zh.md: 96b2bc34c082bbc372e7af43f86b1ee76289e83e +2026-08-18-product-subagent-named-instances.md: 759d3941ff8404138954c409f0fd4949e357200e +2026-08-18-product-subagent-named-instances.zh.md: 6faf0e70f639cbc6528e27b800b8e5f99f0d6c86 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md index 997c555699..759d3941ff 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md @@ -29,7 +29,7 @@ Removing one provider row blocks new starts and removes only tools bound to that ## Verification -Both product packages pin their default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official product loopback tests run two named instances in one Host against separate model fixtures and prove independent unload and managed-range quiescence. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of a dynamic provider parameter. +Both product packages pin their default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official product loopback tests run two named instances in one Host against separate model fixtures and prove independent unload and process-tree quiescence. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of a dynamic provider parameter. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md index 96b2bc34c0..6faf0e70f6 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md @@ -29,7 +29,7 @@ Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Codex 与 ## 验证 -两个产品包测试都会固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方产品回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与 managed-range 完全停稳。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方参数。 +两个产品包测试都会固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方产品回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与进程树完全停稳。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方参数。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 5338856f63..5fb38dbf24 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 8c3e7b0788a8816143b15aad148eb927e66297d0 -2026-08-08-native-windows-pull-request-ci.zh.md: 57fa551291adde0c98eb198b344aef85186f7b7e +2026-08-08-native-windows-pull-request-ci.md: d4883cf1363a33a444f1172829149c0c41f21c10 +2026-08-08-native-windows-pull-request-ci.zh.md: 0ba9629e873d82aba33a2cbad9e46e264ffe4312 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 8c3e7b0788..d4883cf136 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -32,7 +32,7 @@ Windows durable JSONL paths keep drive roots in native spelling and apply the ex Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. -Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and managed-range exit on each host. +Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 57fa551291..0ba9629e87 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -32,7 +32,7 @@ Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并 启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 -Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和 managed-range 退出。 +Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 ## 曾考虑的替代方案 diff --git a/AGENTS.md b/AGENTS.md index 2024b12749..a66d6cdc54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// llm/ LLM capability: Service Definition/Consumer + DeepSeek providers e2b/ E2B POC: sandbox + FS/subprocess adapters shell/ bash capability: Service Definition + local/pwsh providers + shell Consumers - subprocess/ subprocess capability + local managed-range provider + shared Win32 library + subprocess/ subprocess capability + local process-tree provider + shared Win32 library terminal/ persistent sessions fs/ filesystem capability + policy lsp/ language-server capability diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 55343b9635..dbafe8c766 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 263e870061467b89961f7e7026e567962acbfdf4 -capability-seams.zh.md: d5def25b4dc5b6a3c6bba7b419c0512cf51552ea +capability-seams.md: 9e99ebbdc0e3af22f9939c690ead479d4d20b00c +capability-seams.zh.md: 611902310e3472e05eaa92985011eb852bbeee6d diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 263e870061..9e99ebbdc0 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -460,7 +460,7 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, managed-range/session lifetime, stdio dispositions, terminal mechanics, and provider-defined termination. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation. | | `ctx.shell` | `seam` | [`shell`](../packages/shell/shell) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`pwsh-local`](../packages/shell/pwsh-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. | | `ctx.shellEnv` | `core` | [`shell-env`](../packages/shell/shell-env) | - | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. | | `ctx.terminals` | `seam` | [`terminal`](../packages/terminal/terminal) | [`terminal-bash`](../packages/terminal/terminal-bash) | [`tool-terminal`](../packages/terminal/tool-terminal) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-terminal exposes the owner-scoped model tools. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index d5def25b4d..611902310e 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -462,7 +462,7 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、managed-range/session 生命周期、stdio 处置、终端机制和 provider-defined termination。 | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、进程树/会话生命周期、stdio 处置、终端机制和 kill 升级。 | | `ctx.shell` | `seam` | [`shell`](../packages/shell/shell) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`pwsh-local`](../packages/shell/pwsh-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | - | 面向模型的 shell 工具和钩子桥接消费此 seam;沙箱、远程或 PowerShell 执行器可以替换 bash-local,而无需改动这些消费方。 | | `ctx.shellEnv` | `core` | [`shell-env`](../packages/shell/shell-env) | - | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh) | - | 插件声明限定于 effect 作用域的 DSH_* 事实;每个 shell 工具在每次执行时收集一份可信快照,其执行器据此重建命名空间。 | | `ctx.terminals` | `seam` | [`terminal`](../packages/terminal/terminal) | [`terminal-bash`](../packages/terminal/terminal-bash) | [`tool-terminal`](../packages/terminal/tool-terminal) | - | 注册表负责精确到 Agent 的会话身份和清理;后端负责终端机制,tool-terminal 则提供限定于所有者作用域的模型接口。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a124170716..432da9e27f 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 879484a1f5b6641cd22eef0a95ff46228b8e6d14 -config-catalog.zh.md: 65c9e1f9041d30e0e0ace6ef2da8fe999a60cc7f +config-catalog.md: 3595bc650153be25a4156f103af4980c9452f4f0 +config-catalog.zh.md: 66f35350640d5fcea7c9cb90d65bf0878ac68806 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 879484a1f5..3595bc6501 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -361,7 +361,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } ``` @@ -1323,9 +1323,9 @@ export interface LspLocalServerConfig { maxStderrBytes?: number /** Largest source file this host will open (bytes). Default 4000000. */ maxDocumentBytes?: number - /** Graceful `shutdown`/`exit` budget before provider termination (ms). Default 5000. */ + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** Grace supplied to subprocess termination and output draining (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } ``` @@ -1520,7 +1520,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -2171,11 +2171,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent invokes provider termination. Must not exceed + * before the parent escalates to a signal. Must not exceed * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Grace for subprocess termination and output draining (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -2208,7 +2208,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code termination and output draining. */ + /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } @@ -2236,7 +2236,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server termination and output draining. */ + /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } @@ -2553,7 +2553,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Grace for subprocess termination and output draining (ms), bounded by `MAX_TIMER_DELAY_MS`. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 65c9e1f904..66f3535064 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -363,7 +363,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } ``` @@ -1325,9 +1325,9 @@ export interface LspLocalServerConfig { maxStderrBytes?: number /** Largest source file this host will open (bytes). Default 4000000. */ maxDocumentBytes?: number - /** Graceful `shutdown`/`exit` budget before provider termination (ms). Default 5000. */ + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** Grace supplied to subprocess termination and output draining (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } ``` @@ -1523,7 +1523,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -2174,11 +2174,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent invokes provider termination. Must not exceed + * before the parent escalates to a signal. Must not exceed * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Grace for subprocess termination and output draining (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -2211,7 +2211,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code termination and output draining. */ + /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } @@ -2239,7 +2239,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server termination and output draining. */ + /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } @@ -2556,7 +2556,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Grace for subprocess termination and output draining (ms), bounded by `MAX_TIMER_DELAY_MS`. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 8966af73e1..8b51742982 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subprocess.md -subprocess.md: 1cc704a30eeb7587c784886d31b7ca65e085ad93 -subprocess.zh.md: b861d99742956f5cf469851f8f78bd235706ebc8 +subprocess.md: 701d70629587c202c50047fb88158f39179920d1 +subprocess.zh.md: 643998ae0210e0fa2cdf8d5140809df07beaa27b diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 1cc704a30e..701d706295 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -114,9 +114,9 @@ interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the provider's termination procedure on the managed - * range when it fires. The caller owns deadlines and cause classification; - * this seam only reacts to the abort. + * Abort signal — starts the terminate escalation on the managed range when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. */ signal?: AbortSignal | undefined /** @@ -284,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so consumer teardown can await real quiescence; each provider documents its identity, signalling, and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index b861d99742..643998ae02 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -114,9 +114,9 @@ interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the provider's termination procedure on the managed - * range when it fires. The caller owns deadlines and cause classification; - * this seam only reacts to the abort. + * Abort signal — starts the terminate escalation on the managed range when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. */ signal?: AbortSignal | undefined /** @@ -284,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so consumer teardown can await real quiescence; each provider documents its identity, signalling, and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 8697b9cfbb..8129d42c0a 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 287e59a54dcd088894bcfe3946727169749220bd -README.zh.md: 6407754a10d1ca441df4c6138125efa4e6356fdc +README.md: ad88e895cec423b71dbc3c0e961ddb8901e0f66d +README.zh.md: 81557ab3b9f3d7a00af6a5ad45a9e04cc7ea063b diff --git a/packages/README.md b/packages/README.md index 287e59a54d..ad88e895ce 100644 --- a/packages/README.md +++ b/packages/README.md @@ -19,7 +19,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`identity/`](identity/README.md) | Shared anonymous identity | Product — stable API | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API | | [`e2b/`](e2b/README.md) | E2B providers | POC | -| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition, local managed-range provider, and shared Win32 process library | Product — stable API | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition, local process-tree provider, and shared Win32 process library | Product — stable API | | [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable API | | [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable API | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable API | diff --git a/packages/README.zh.md b/packages/README.zh.md index 6407754a10..81557ab3b9 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -19,7 +19,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`identity/`](identity/README.zh.md) | 共享匿名身份 | 产品:稳定 API | | [`llm/`](llm/README.zh.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定 API | | [`e2b/`](e2b/README.zh.md) | E2B 提供方 | POC | -| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition、本地 managed-range 提供方与共享 Win32 进程库 | 产品:稳定 API | +| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition、本地进程树提供方与共享 Win32 进程库 | 产品:稳定 API | | [`shell/`](shell/README.zh.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定 API | | [`terminal/`](terminal/README.zh.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现和面向模型的工具 | 产品:稳定 API | | [`code-runtime/`](code-runtime/README.zh.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定 API | diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 432015ade2..488c2d756c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'subprocess', summary: 'Abstract subprocess service.', - description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so consumer teardown can await real quiescence; each provider documents its identity, signalling, and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 9976f2a8b6..b1a2080662 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/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/fs/tool-fs-search/README.md -README.md: ae232afa161f7b8f0d4b28f4da5c0992643a5f4f -README.zh.md: 4b9372bbce129259a6e01694f2964a5528c6c19e +README.md: 84a3adc31f9c1580b90c038b0902e88b050f0340 +README.zh.md: d13581d7e584f97e0779eb232a47d2d119404647 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index ae232afa16..84a3adc31f 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -12,7 +12,7 @@ await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, managed-range termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background job — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails. +Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, process-tree termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background job — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails. ## Deployment requirement: no host rg, co-located workdir/filesystem @@ -29,8 +29,8 @@ Node deployments receive the `@vscode/ripgrep` platform package on supported mac | `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. | | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | -| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` through `exec.signal`; abort starts the subprocess provider's termination procedure. | -| `graceMs` | `3000` | Positive grace supplied to subprocess termination and output draining; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | +| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. | +| `graceMs` | `3000` | Positive terminate-escalation grace the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | | `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). | ## Tools diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 4b9372bbce..d13581d7e5 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -12,7 +12,7 @@ await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、managed-range 终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。 +采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、进程树终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。 ## 部署要求:无需宿主 rg,但工作目录与文件系统需共置 @@ -29,8 +29,8 @@ Node 部署在受支持的 macOS、Linux 与 Windows x64/arm64 目标上获得 ` | `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 | | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | -| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-tool-call-timeout-policy` 通过 `exec.signal` 强制执行;中止会启动 subprocess provider 的终止过程。 | -| `graceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | +| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-tool-call-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 | +| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期须为正值;超过后搜索以 `SEARCH_ABORTED` 失败;该宽限期不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | | `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 | ## 工具 diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 60ff5b4d80..871dc16ae8 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -47,7 +47,7 @@ export interface GlobToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number - /** Grace for subprocess termination and output draining (ms). */ + /** Terminate-escalation grace period (ms) for the search process. */ graceMs: number /** Cap on the retained stderr diagnostic tail. */ stderrMaxBytes: number diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 439dde2310..38f54aa2a5 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -45,7 +45,7 @@ export interface GrepToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number - /** Grace for subprocess termination and output draining (ms). */ + /** Terminate-escalation grace period (ms) for the search process. */ graceMs: number /** Cap on the retained stderr diagnostic tail. */ stderrMaxBytes: number diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index fabd12df43..1765d1b781 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -13,7 +13,7 @@ * ({@link module:@deepseek-ai/dsh-tool-fs-search/glob} / * {@link module:@deepseek-ai/dsh-tool-fs-search/grep}), result parsing, * retention, formatted-result spill, and timeout declaration; the subprocess - * seam owns spawn execution, managed-range termination, environment scrubbing, + * seam owns spawn execution, process-tree termination, environment scrubbing, * and raw output capture. The package injects `tools`, `systemPrompt`, and * `subprocess` — deliberately NOT `fs`, and `ctx.spillStore` is read * opportunistically with `ctx.get()` because formatted-result spill is optional. @@ -83,7 +83,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Grace for subprocess termination and output draining (ms), bounded by `MAX_TIMER_DELAY_MS`. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 0852cbb36d..5ac5521033 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -182,8 +182,8 @@ export function resolveRgPath(): Promise { * complete raw stdout. The working directory is the calling agent's session * cwd (`exec.agent.session.header.cwd`) when available, else * `process.cwd()`. `exec.signal` is forwarded so the cooperative tool timeout - * (`@deepseek-ai/dsh-tool-call-timeout-policy`) and caller cancellation start the - * provider's managed-range termination procedure. + * (`@deepseek-ai/dsh-tool-call-timeout-policy`) and caller cancellation terminate the + * process tree. * * The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config` * is prepended: a host `RIPGREP_CONFIG_PATH` (or `rg.conf` next to the @@ -208,7 +208,7 @@ export function resolveRgPath(): Promise { * @param toolName - `glob` or `grep`, used in error messages. * @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists). * @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse. - * @param graceMs - grace supplied to subprocess termination and output draining. + * @param graceMs - the seam's terminate-escalation grace period. * @param stderrMaxBytes - cap on the retained stderr diagnostic tail. * @returns the complete stdout, the zero-result flag, and the resolved workdir. */ diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 3a237e648d..abb7e75773 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -93,7 +93,7 @@ class FakeReader implements SubprocessOutputReader { * A scriptable subprocess handle: `done` resolves with the scripted outcome * (or rejects with the scripted error), `terminate()` records the call, and * the spec's abort signal marks the handle terminated — mirroring the seam's - * abort-triggered provider termination. + * abort→terminate escalation. */ class FakeHandle implements SubprocessHandle { readonly pid = 4242 @@ -434,8 +434,8 @@ describe('workdir derivation and signal forwarding', () => { it('reports an abort fired during the run as SEARCH_ABORTED', async () => { // The cooperative tool timeout or caller cancellation aborts exec.signal; - // the subprocess provider then starts managed-range termination. The tool - // classifies the first cause it owns: the abort. + // the subprocess seam then kills the process tree. The tool classifies + // the first cause it owns: the abort. const { ctx, subprocess } = await setup() const controller = new AbortController() subprocess.handler = () => { diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index e08a01a19f..6ca3618083 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/lsp/lsp-stdio/README.md -README.md: 5b5e805f2e48d17aeed3cd5f4dce47928b0c9dc5 -README.zh.md: 6dd690a536cd96af889c8c13f2b62282a1cc9328 +README.md: beadc34ec738ae3f6511cae764240c0e9d8c61d7 +README.zh.md: 2d6516a25147fb7593e8d45d843315098cdff7d3 diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index 5b5e805f2e..beadc34ec7 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -12,7 +12,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Lazily single-flights one server process per `(server id, canonical workspace target)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process. - Uses a compatibility-first **transient-open** sequence per query: resolve and byte-bound the source while streaming it through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. Provider disposal aborts filesystem and protocol work, awaits workspace lookups that have not entered a queue, then drains every queue and server. -- After protocol shutdown fails, invokes the subprocess provider's termination procedure and awaits the same managed range through `waitForExit()`. The provider owns signal delivery and observation failures; the LSP host owns only protocol-first teardown and the final quiescence wait. +- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome. - Resolves the server executable, cwd, process, and protocol streams through `ctx.subprocess`; `initialize.processId` is `null` because another machine or PID namespace must not monitor the harness process. - Uses `ctx.fs` canonical containment, file URIs, and streamed text validation, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. @@ -32,7 +32,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v | `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. | | `maxDocumentBytes` | `4000000` | Largest source file this host will open. | | `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | -| `killGraceMs` | `2000` | Grace supplied to subprocess termination and output draining. | +| `killGraceMs` | `2000` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. | `servers` must contain at least one entry, and every id must be non-empty. Timer budgets must be positive integers no greater than Node's `2_147_483_647` ms timer limit. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index 6dd690a536..2d6516a251 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -12,7 +12,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) - 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。服务器仍存活时返回的错误不会触发重试;如果选中的池化传输在只读查询之前或期间发生故障,提供方会等待其 dispose(资源释放)完成,并在新进程上重试该查询一次。 - 每次查询都使用兼容性优先的**临时打开**序列:通过 `ctx.fs` 流式读取源文件,同时解析并限制其字节数;随后执行 `textDocument/didOpen`(版本 1、完整文本)、所请求操作,再执行位于 `finally` 中的 `textDocument/didClose`。写入 `didOpen` 失败或取消时,会在池复用该实例前将其终止。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。 - 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。提供方 dispose 会中止文件系统与协议工作,等待尚未进入队列的 Workspace 查找完成,随后排空每条队列与每个服务器。 -- 协议 shutdown 失败后,调用 subprocess provider 的终止过程,并通过 `waitForExit()` 等待同一个 managed range。信号投递与观察失败归 provider 所有;LSP Host 只拥有协议优先的拆卸过程与最终完全停稳等待。 +- 协议 shutdown 失败后,经由子进程 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。 - 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程和协议流;`initialize.processId` 为 `null`,因为另一台机器或 PID namespace 不得监视 harness 进程。 - 使用 `ctx.fs` 提供的规范化包含关系、文件 URI 与流式文本验证,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。 @@ -32,7 +32,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) | `maxStderrBytes` | `1000000` | 为诊断保留的 stderr 尾部最大大小。 | | `maxDocumentBytes` | `4000000` | 该主机可打开的源文件大小上限。 | | `shutdownTimeoutMs` | `5000` | 升级前用于优雅 `shutdown`/`exit` 的预算。 | -| `killGraceMs` | `2000` | 提供给 subprocess 终止与输出排空的宽限期。 | +| `killGraceMs` | `2000` | 请求取消及 SIGTERM→SIGKILL 升级的宽限期。 | `servers` 必须至少包含一个配置项,每个 id 都必须非空。定时器预算必须是正整数,且不超过 Node 的 `2_147_483_647` ms 定时器上限。所有可执行文件都会在清理 credential 后于加载时解析;后面的坏配置项会阻止所有提供方注册。进程在第一次匹配查询时惰性启动。 diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index 55af39341a..5cfcf10b0c 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -4,8 +4,8 @@ * server→client requests: it answers `workspace/configuration` from static * config, and rejects `workspace/applyEdit` (this host never applies edits or * runs commands). It caps stderr, surfaces framing/decoder failures as a - * fatal close, and exposes managed-range termination through the handle so the - * instance owns teardown; range mechanics live in the subprocess + * fatal close, and exposes tree-scoped termination through the handle so the + * instance owns teardown; group/tree mechanics live in the subprocess * Service Provider. * @module @deepseek-ai/dsh-lsp-stdio/connection */ @@ -29,9 +29,9 @@ export interface ConnectionSpec { /** Largest stderr tail retained for diagnostics. */ readonly maxStderrBytes: number /** - * The subprocess spec's `graceMs`: available to the provider's termination - * procedure and used to bound draining pipes a survivor still holds after - * the server exits. + * The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of + * {@link LspConnection.terminate}'s escalation, and the bound for draining + * pipes a surviving helper still holds after the server exits. */ readonly killGraceMs: number /** Static answer to every `workspace/configuration` item. */ @@ -88,7 +88,7 @@ export class LspConnection { this.decoder = new MessageDecoder(spec.maxMessageBytes) // stdin/stdout are piped protocol streams this endpoint frames itself; // stderr is a collected diagnostic tail (no spill — the bounded tail IS - // the contract). The seam owns managed-range signalling and quiescence. + // the contract). The seam owns detachment and tree-scoped signalling. this.handle = spawner({ argv: [spec.command, ...spec.args], cwd: spec.cwd, @@ -209,15 +209,15 @@ export class LspConnection { return this.nextId } - /** Start the provider's idempotent termination procedure for the server's managed range. */ + /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */ terminate(): void { this.handle.terminate() } /** - * Wait until the provider-managed range is empty. + * Wait until the owned process tree has exited. * @param signal - optional bound for the wait. - * @returns `true` when the range is empty, or `false` when the signal aborted first. + * @returns `true` when the tree exited, or `false` when the signal aborted first. */ async waitForProcessTreeExit(signal?: AbortSignal): Promise { return await this.handle.waitForExit(signal) diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index abd7ab35c5..ebc54702da 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -72,9 +72,9 @@ export interface LspLocalServerConfig { maxStderrBytes?: number /** Largest source file this host will open (bytes). Default 4000000. */ maxDocumentBytes?: number - /** Graceful `shutdown`/`exit` budget before provider termination (ms). Default 5000. */ + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** Grace supplied to subprocess termination and output draining (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index 88e80b88b0..2028129de7 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -269,8 +269,8 @@ export class LspInstance { } /** - * Reject queued work, attempt graceful `shutdown`/`exit`, then request the - * provider's termination procedure and await process close and range quiescence. + * Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting + * process close so nothing outlives disposal. */ async dispose(): Promise { await this.startTeardown() @@ -288,7 +288,7 @@ export class LspInstance { try { await this.gracefulShutdown(shutdownDeadline.signal) } catch { - // Graceful shutdown failed or timed out; managed-range cleanup below remains authoritative. + // Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative. } finally { shutdownDeadline[Symbol.dispose]() } @@ -303,9 +303,10 @@ export class LspInstance { } /** - * Start the provider's termination procedure, then await command transport - * and managed-range exit. The awaits are unbounded on purpose: quiescence, - * not another consumer timer, is the postcondition disposal owes its callers. + * Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL), + * then await leader and helper exit. The awaits are unbounded on purpose: + * the seam's escalation already committed to SIGKILL, so quiescence — not + * another timer — is the postcondition disposal owes its callers. */ private async forceTerminate(): Promise { this.connection.terminate() diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index a2dcebcc8f..9efc091919 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -312,7 +312,7 @@ describe('LspInstance disposal', () => { await expect(instance.dispose()).resolves.toBeUndefined() }) - it('awaits a surviving managed-range helper on every concurrent dispose', async () => { + it('awaits a surviving process-tree helper on every concurrent dispose', async () => { const marker = join(root, 'helper.pid') const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 0e50bf4392..983f5953f8 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -28,6 +28,7 @@ const PPVOID: Ptr = koffi.pointer(PVOID) /** ACL/token calls composed with the generic Win32 process binding table. */ export interface Win32Bindings extends Win32ProcessBindings { + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number localAlloc(flags: number, bytes: number): NativePtr localFree(memory: NativePtr): NativePtr @@ -221,6 +222,7 @@ let cached: Win32Bindings | undefined function bindings(): Win32Bindings { if (cached !== undefined) return cached cached = extendWin32ProcessBindings(({ kernel32, advapi32, bind }) => ({ + openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]), localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), diff --git a/packages/shell/bash-local/README.i18n.yaml b/packages/shell/bash-local/README.i18n.yaml index 70141eb599..82a7387566 100644 --- a/packages/shell/bash-local/README.i18n.yaml +++ b/packages/shell/bash-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/bash-local/README.md -README.md: 051b8aba0c66d2970f42ada9d3437265c29cd839 -README.zh.md: ff5a0178f8dedaf2f4bea5eb603e51bd2ffa74bc +README.md: 5e62ea24676f3bedf32b1b48f8618d703d5fb462 +README.zh.md: 92f842a777aebec2e2cb6a8c54966e46202531f1 diff --git a/packages/shell/bash-local/README.md b/packages/shell/bash-local/README.md index 051b8aba0c..5e62ea2467 100644 --- a/packages/shell/bash-local/README.md +++ b/packages/shell/bash-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Local Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call in a provider-managed range through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Managed-range mechanics (bounded spill-backed output, credential scrub, termination, disposal) are the subprocess service's. +Local Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`. @@ -17,14 +17,14 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # subprocess termination and post-exit pipe-drain grace + graceMs: 3000 # kill escalation and post-exit pipe-drain grace ``` ## Behavior - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files. - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs. -- **Configured budgets over managed ranges** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Provider-owned termination, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). - **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. 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). - **Background processes** — `start()` returns a live `ShellProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Job ids, ownership, polling, and notices belong to the generic [`ctx.jobs` runtime](../../jobs/jobs/README.md), which the tool layer registers the handle with. @@ -41,7 +41,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. -- **POSIX-only** — the `bash` binary is hardcoded, so this executor is not composed on Windows. -- **A background failure note is single-delivery** — when `done` rejects and no real stderr is available, the executor injects one diagnostic into exactly one `readOutput()` delta. A Node-shaped rejection that identifies `argv[0]` uses `spawn failed: …`; a provider failure without proof that the target never started uses `subprocess failed: …`. +- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/shell/bash-local/README.zh.md b/packages/shell/bash-local/README.zh.md index ff5a0178f8..92f842a777 100644 --- a/packages/shell/bash-local/README.zh.md +++ b/packages/shell/bash-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-shell` 执行器 seam 的本地 Service Provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 在 provider-managed range 中 spawn `bash -c `,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、终止和 dispose(资源释放)等 managed-range 机制则由 subprocess 服务负责。 +`@deepseek-ai/dsh-shell` 执行器 seam 的本地 Service Provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。 包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 @@ -17,14 +17,14 @@ maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # subprocess termination and post-exit pipe-drain grace + graceMs: 3000 # kill escalation and post-exit pipe-drain grace ``` ## 行为 - **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。 - **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.zh.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。 -- **在 managed range 之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。Provider-owned termination、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md))。 - **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [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)。 - **后台进程**:`start()` 会立即返回活动的 `ShellProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。job id、所有权、轮询和通知属于通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.zh.md),工具层会在其中注册该句柄。 @@ -41,7 +41,7 @@ - **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要隔离的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.zh.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。 -- **仅支持 POSIX**:`bash` 二进制已硬编码,因此本执行器不会在 Windows 上组装。 -- **后台失败提示只交付一次**:`done` 拒绝且没有真实 stderr 时,执行器会把一条诊断注入恰好一个 `readOutput()` 增量。能以 Node-shaped 字段确认 `argv[0]` 未启动的拒绝使用 `spawn failed: …`;无法证明目标未启动的 provider failure 使用 `subprocess failed: …`。 +- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 +- **后台 spawn 失败提示只交付一次**:subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 记录;这些机制归它所有。 diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 171ab0e4aa..6c37c5b794 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -31,7 +31,7 @@ export const ENV_OVERRIDES = { GIT_PAGER: 'cat', } as const -/** Default subprocess termination and output-drain grace (the `graceMs` config; matches OpenCode's 3s). */ +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ const DEFAULT_GRACE_MS = 3_000 /** Default per-stream spill cap (the `maxSpillBytes` config). */ @@ -49,7 +49,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } @@ -66,15 +66,6 @@ function finalOutput(reader: SubprocessOutputReader): CollectedOutput { } } -/** Whether a rejection carries direct evidence that argv[0] never started. */ -function isSpawnFailure(error: unknown, program: string): boolean { - if (typeof error !== 'object' || error === null) return false - const { path, syscall } = error as { path?: unknown; syscall?: unknown } - if (typeof syscall !== 'string') return false - if (syscall !== 'spawn' && syscall !== `spawn ${program}`) return false - return path === undefined || path === program -} - function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`bash-local: ${name} must be a positive finite number`) @@ -103,8 +94,8 @@ export function assertServiceableBashConfig(config: Config): void { /** * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and - * provider-owned managed-range termination are the subprocess service's - * mechanics; this executor supplies the configured budgets per spawn, so a + * process-group SIGTERM→SIGKILL escalation are the subprocess service's + * mechanics; this executor supplies their configured budgets per spawn, so a * still-running background process stays managed (killed and joined at * composition teardown) even across an executor reload. */ @@ -254,7 +245,7 @@ export class LocalBashExecutor extends ShellExecutor { /** * Start an explicit argv with the background lifecycle, environment, output, - * cancellation, and managed-range ownership semantics of this executor. + * cancellation, and process-tree ownership semantics of this executor. * Subclasses use this after replacing the public command's shell argv at an * execution boundary. * @param spec - resolved execution settings and caller-owned command metadata. @@ -266,12 +257,12 @@ export class LocalBashExecutor extends ShellExecutor { const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal)) const collected = LocalBashExecutor.collected(running) - // A rejected subprocess result has no settled outcome. Its diagnostic is - // delivered exactly once through the read path. - let failureNote: string | undefined - const consumeFailure = (): string => { - const note = failureNote ?? '' - failureNote = undefined + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined return note } @@ -290,10 +281,10 @@ export class LocalBashExecutor extends ShellExecutor { proc.signal = outcome.signal this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { - const spawnFailed = running.pid <= 0 && isSpawnFailure(error, argv[0] as string) + // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' - failureNote = `${spawnFailed ? 'spawn' : 'subprocess'} failed: ${String(error)}` - this.onProcessDone(proc, failureNote, spawnFailed, error) + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): ShellProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -301,9 +292,9 @@ export class LocalBashExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - // A rejected subprocess may have no process output; its synthetic note - // is used only when no real stderr is available. - const errText = err.text.length > 0 ? err.text : consumeFailure() + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' @@ -328,13 +319,13 @@ export class LocalBashExecutor extends ShellExecutor { /** * Settlement hook for subclasses that attach execution facts to a process. - * Called after exit facts or rejection output are stamped and before + * Called after exit facts or spawn-failure output are stamped and before * {@link ShellProcess.done} resolves. The base implementation is intentionally * empty. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. * @param _spawnFailed - whether the subprocess promise rejected before a process started. - * @param _spawnError - the original subprocess rejection when settlement failed; it may itself be undefined. + * @param _spawnError - the original spawn rejection reason, which may itself be undefined. */ protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index 11299b0ed9..c081ad7078 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -4,11 +4,9 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import SubprocessRuntime from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) @@ -22,43 +20,6 @@ async function setup(config: ConstructorParameters[1] return { ctx, bash } } -class RejectingSubprocessRuntime extends SubprocessRuntime { - private readonly reader: SubprocessOutputReader = { - readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), - } - - constructor(ctx: Context, private readonly failure: unknown, private readonly processId = 123) { - super(ctx) - } - - override async resolveExecutable(command: string): Promise { return command } - override spawnTerminal(): Promise { throw new Error('bash spawns pipes, never terminals') } - override spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { - return { - pid: this.processId, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: { stdout: this.reader, stderr: this.reader }, - done: Promise.resolve().then(() => { throw this.failure }), - terminate: () => {}, - waitForExit: async () => true, - } - } -} - -class ObservingBashExecutor extends LocalBashExecutor { - spawnFailed: boolean | undefined - - protected override onProcessDone( - _proc: ShellProcess, - _stderr: string, - spawnFailed: boolean, - ): void { - this.spawnFailed = spawnFailed - } -} - /** * Poll a handle's consuming readOutput until the ACCUMULATED delta contains * `expected`; returns the accumulation (reads never re-deliver, so the caller @@ -335,39 +296,6 @@ describe('LocalBashExecutor.start (background process handles)', () => { expect(proc.status).toBe('killed') expect(proc.readOutput().delta).toContain('spawn failed:') }) - - it('does not label a post-start provider rejection as a spawn failure', async () => { - const ctx = new Context() - const failure = Object.assign(new Error('managed owner became unreadable'), { - code: 'ENOENT', - syscall: 'spawn bash', - path: 'bash', - }) - new RejectingSubprocessRuntime(ctx, failure) - await ctx.plugin(ObservingBashExecutor) - const bash = ctx.shell as ObservingBashExecutor - const proc = bash.start(bash.resolve({ command: 'true' })) - await proc.done - expect(proc.readOutput().delta).toContain('subprocess failed:') - expect(proc.readOutput().delta).toBe('') - expect(bash.spawnFailed).toBe(false) - }) - - it.each([ - ['non-object rejection', undefined, 'subprocess failed:', false], - ['non-string syscall', { syscall: 1 }, 'subprocess failed:', false], - ['non-spawn syscall', { syscall: 'kill', path: 'bash' }, 'subprocess failed:', false], - ['matching syscall without path', { syscall: 'spawn bash' }, 'spawn failed:', true], - ])('classifies a pre-start %s from structured evidence', async (_label, failure, note, spawnFailed) => { - const ctx = new Context() - new RejectingSubprocessRuntime(ctx, failure, -1) - await ctx.plugin(ObservingBashExecutor) - const bash = ctx.shell as ObservingBashExecutor - const proc = bash.start(bash.resolve({ command: 'true' })) - await proc.done - expect(proc.readOutput().delta).toContain(note) - expect(bash.spawnFailed).toBe(spawnFailed) - }) }) describe('process lifecycle ownership (the subprocess service, not the executor)', () => { diff --git a/packages/shell/bash-sandbox/src/index.ts b/packages/shell/bash-sandbox/src/index.ts index 2f6b92f772..be9c6647ec 100644 --- a/packages/shell/bash-sandbox/src/index.ts +++ b/packages/shell/bash-sandbox/src/index.ts @@ -151,9 +151,8 @@ export class SandboxBashExecutor extends LocalBashExecutor { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // A definite spawn rejection never started the confined launch. A - // settled runner failure outranks denial because its diagnostics may - // contain denial terms; unclassified provider rejection proves neither. + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. const runnerFailed = spawnFailed ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index b17277bc77..750fb870c3 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -558,7 +558,7 @@ describe('background sandbox facts', () => { } }) - it('does not invent spawn or runner evidence for an unstructured subprocess rejection', async () => { + it('does not invent runner evidence when a spawn rejection has no structured reason', async () => { const { ctx, bash } = await setup() const emptyReader: SubprocessOutputReader = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), @@ -579,7 +579,7 @@ describe('background sandbox facts', () => { const task = bash.start(bash.resolve({ command: 'true' })) await task.done - expect(task.readOutput().delta).toContain('subprocess failed: undefined') + expect(task.readOutput().delta).toContain('spawn failed: undefined') expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, diff --git a/packages/shell/pwsh-local/README.i18n.yaml b/packages/shell/pwsh-local/README.i18n.yaml index beff18aafd..a7712c1df6 100644 --- a/packages/shell/pwsh-local/README.i18n.yaml +++ b/packages/shell/pwsh-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/pwsh-local/README.md -README.md: 44ea2f3d9face0499f0b7088a257a391a7370225 -README.zh.md: 81e46ad52f0c00817f31f0ba51379f2f1cbbc840 +README.md: 2eccc59b919d1f729eef52a42581f4da0f1d9e60 +README.zh.md: b7072ebd9be4e148cedd4edd6c20b12536814856 diff --git a/packages/shell/pwsh-local/README.md b/packages/shell/pwsh-local/README.md index 44ea2f3d9f..2eccc59b91 100644 --- a/packages/shell/pwsh-local/README.md +++ b/packages/shell/pwsh-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Local PowerShell Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command ` per call in a provider-managed range through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Managed-range mechanics (bounded spill-backed output, credential scrub, termination, disposal) are the subprocess service's. +Local PowerShell Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command ` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged. @@ -19,7 +19,7 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its ` maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # subprocess termination and post-exit pipe-drain grace + graceMs: 3000 # kill escalation and post-exit pipe-drain grace pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH ``` @@ -31,7 +31,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.shell`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section. - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. - **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking each candidate with an lstat probe that accepts a real file or a link-shaped reparse point (a Store app execution alias stat-fails against its target's ACL, but lstat sees the alias itself); elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. -- **Configured budgets over managed ranges** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Provider-owned termination, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. - **Background processes** — `start()` returns a live `ShellProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.jobs` runtime](../../jobs/jobs/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. @@ -49,7 +49,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`. - **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. -- **A background failure note is single-delivery** — when `done` rejects and no real stderr is available, the executor injects one diagnostic into exactly one `readOutput()` delta. A Node-shaped rejection that identifies `argv[0]` uses `spawn failed: …`; a provider failure without proof that the target never started uses `subprocess failed: …`. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. - **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. - **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead. - **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected. diff --git a/packages/shell/pwsh-local/README.zh.md b/packages/shell/pwsh-local/README.zh.md index 81e46ad52f..b7072ebd9b 100644 --- a/packages/shell/pwsh-local/README.zh.md +++ b/packages/shell/pwsh-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-shell` 执行器 seam 的本地 PowerShell Service Provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务:`PwshLocalExecutor` 每次调用都通过 `ctx.subprocess` 在 provider-managed range 中 spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并负责所有 PowerShell 相关事项——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。Managed-range 机制(有界 spill 输出、凭据清理、终止、dispose(资源释放))属于 subprocess 服务。 +`@deepseek-ai/dsh-shell` 执行器 seam 的本地 PowerShell Service Provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并负责所有 PowerShell 相关事项——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、dispose(资源释放))属于 subprocess 服务。 命令字符串作为单个 argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(这里不存在与 `bash -c` 字符串域对应的层)。原生 Win32 路径(`C:\...`)原样通过。 @@ -19,7 +19,7 @@ maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # subprocess termination and post-exit pipe-drain grace + graceMs: 3000 # kill escalation and post-exit pipe-drain grace pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH ``` @@ -31,7 +31,7 @@ - **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.zh.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.shell` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess 收集器以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 - **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一用 lstat 探测检查(接受真实文件或链接形态的重解析点:Store 的 app execution alias 对其目标 stat 会因 ACL 失败,但 lstat 能看到别名本身);其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 -- **Managed range 之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。Provider-owned termination、退出后管道排空、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 +- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 - **后台进程**——`start()` 立即返回存活的 `ShellProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为一条按分段标记、通过消费游标推进的增量。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务 dispose(被终止并 join)。一切任务相关职责(job id、所有权、轮询、通知)都在通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.zh.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 @@ -49,7 +49,7 @@ - **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要隔离的部署应组合启用沙箱的 bash 执行器或策略。 - **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`。 - **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 -- **后台失败提示只投递一次**——`done` 拒绝且没有真实 stderr 时,执行器只把一条诊断注入一次 `readOutput()` 增量。能以 Node-shaped 字段确认 `argv[0]` 未启动的拒绝使用 `spawn failed: …`;无法证明目标未启动的 provider failure 使用 `subprocess failed: …`。 +- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 - **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接标记为 `killed`。 - **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。 - **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。 diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index 8867245e33..b7a2d9f915 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -48,7 +48,7 @@ export const ENV_OVERRIDES = { export const ENCODING_PREAMBLE = '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' -/** Default subprocess termination and output-drain grace (the `graceMs` config). */ +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */ const DEFAULT_GRACE_MS = 3_000 /** Default per-stream spill cap (the `maxSpillBytes` config). */ @@ -66,7 +66,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -94,15 +94,6 @@ function finalOutput(reader: SubprocessOutputReader): CollectedOutput { } } -/** Whether a rejection carries direct evidence that argv[0] never started. */ -function isSpawnFailure(error: unknown, program: string): boolean { - if (typeof error !== 'object' || error === null) return false - const { path, syscall } = error as { path?: unknown; syscall?: unknown } - if (typeof syscall !== 'string') return false - if (syscall !== 'spawn' && syscall !== `spawn ${program}`) return false - return path === undefined || path === program -} - function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`pwsh-local: ${name} must be a positive finite number`) @@ -131,7 +122,7 @@ export function assertServiceablePwshConfig(config: Config): void { /** * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill - * files, and managed-range termination are the subprocess service's mechanics; + * files, and process-tree termination are the subprocess service's mechanics; * this executor supplies their configured budgets per spawn. */ export class PwshLocalExecutor extends ShellExecutor { @@ -295,12 +286,12 @@ export class PwshLocalExecutor extends ShellExecutor { const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv)) const collected = PwshLocalExecutor.collected(running) - // A rejected subprocess result has no settled outcome. Its diagnostic is - // delivered exactly once through the read path. - let failureNote: string | undefined - const consumeFailure = (): string => { - const note = failureNote ?? '' - failureNote = undefined + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined return note } @@ -319,10 +310,10 @@ export class PwshLocalExecutor extends ShellExecutor { proc.signal = outcome.signal this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { - const spawnFailed = running.pid <= 0 && isSpawnFailure(error, argv[0] as string) + // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' - failureNote = `${spawnFailed ? 'spawn' : 'subprocess'} failed: ${String(error)}` - this.onProcessDone(proc, failureNote, spawnFailed, error) + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): ShellProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -330,9 +321,9 @@ export class PwshLocalExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - // A rejected subprocess may have no process output; its synthetic note - // is used only when no real stderr is available. - const errText = err.text.length > 0 ? err.text : consumeFailure() + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' @@ -363,7 +354,7 @@ export class PwshLocalExecutor extends ShellExecutor { * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. * @param _spawnFailed - whether the spawn rejected before any process existed. - * @param _spawnError - the original subprocess rejection when settlement failed; it may itself be undefined. + * @param _spawnError - the spawn rejection, when `_spawnFailed`. */ protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 2938ac63f9..e26211b268 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -2,7 +2,7 @@ * Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess * service plus a REAL pwsh executable, exercised through the executor seam * (`resolve` → `run`/`start`). These verify the world — actual PowerShell - * runs, output capture, truncation and spill, deadlines, termination, and + * runs, output capture, truncation and spill, deadlines, kill escalation, and * the background-handle contract. The suite self-skips when no usable `pwsh` * resolves (a CI accommodation for hosts without PowerShell); the pure unit tests * (config validation, executable resolution) run on every platform. PowerShell @@ -175,43 +175,6 @@ describe('spawn construction (pure, every platform)', () => { } } - class RejectingSubprocessRuntime extends SubprocessRuntime { - private readonly reader: SubprocessOutputReader = { - readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), - } - - constructor(ctx: Context, private readonly failure: unknown) { - super(ctx) - } - - override async resolveExecutable(command: string): Promise { return command } - override spawnTerminal(): Promise { throw new Error('pwsh spawns pipes, never terminals') } - override spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { - return { - pid: 123, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: { stdout: this.reader, stderr: this.reader }, - done: Promise.resolve().then(() => { throw this.failure }), - terminate: () => {}, - waitForExit: async () => true, - } - } - } - - class ObservingPwshExecutor extends PwshLocalExecutor { - spawnFailed: boolean | undefined - - protected override onProcessDone( - _proc: ShellProcess, - _stderr: string, - spawnFailed: boolean, - ): void { - this.spawnFailed = spawnFailed - } - } - it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => { const ctx = new Context() const subprocess = new CapturingSubprocessRuntime(ctx) @@ -224,23 +187,6 @@ describe('spawn construction (pure, every platform)', () => { expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding') expect(ENCODING_PREAMBLE).toContain('$OutputEncoding') }) - - it('does not label a post-start provider rejection as a spawn failure', async () => { - const ctx = new Context() - const failure = Object.assign(new Error('managed owner became unreadable'), { - code: 'ENOENT', - syscall: 'spawn pwsh', - path: 'pwsh', - }) - new RejectingSubprocessRuntime(ctx, failure) - await ctx.plugin(ObservingPwshExecutor, { pwshPath: 'pwsh' }) - const pwsh = ctx.shell as ObservingPwshExecutor - const proc = pwsh.start(pwsh.resolve({ command: 'Write-Output ok' })) - await proc.done - expect(proc.readOutput().delta).toContain('subprocess failed:') - expect(proc.readOutput().delta).toBe('') - expect(pwsh.spawnFailed).toBe(false) - }) }) describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { @@ -454,7 +400,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' expect(lf(read.delta)).toContain('[stderr]') }) - it('kill() terminates the managed range: true once, false after settlement', async () => { + it('kill() terminates the process tree: true once, false after settlement', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) expect(proc.kill()).toBe(true) diff --git a/packages/shell/pwsh-sandbox/src/index.ts b/packages/shell/pwsh-sandbox/src/index.ts index ec3e8fb98d..66bc73bcf0 100644 --- a/packages/shell/pwsh-sandbox/src/index.ts +++ b/packages/shell/pwsh-sandbox/src/index.ts @@ -157,9 +157,8 @@ export class SandboxPwshExecutor extends PwshLocalExecutor { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // A definite spawn rejection never started the confined launch. A - // settled runner failure outranks denial because its diagnostics may - // contain denial terms; unclassified provider rejection proves neither. + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. const runnerFailed = spawnFailed ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 0a82434228..4aaf1b7552 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-acp/README.md -README.md: eeeaa9350103761b1507c54d3bb48de97d735906 -README.zh.md: d70e30f7b85fbb052101ef7cc43adc2970630295 +README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669 +README.zh.md: 7ae89ece0ce4282ad5b9a20142a2ba9b111f6d88 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index eeeaa93501..3bccddbca0 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's provider-owned `terminate()` procedure and await managed-range exit. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context @@ -31,7 +31,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first `allow_once` or `allow_always` option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | | `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | -| `disposeGraceMs` | `3000` | Positive grace supplied to subprocess termination and output draining; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | +| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess provider's termination procedure and managed-range join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess-owned SIGTERM→SIGKILL escalation and whole-tree join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index d70e30f7b8..7ae89ece0c 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。 -`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后使用该 seam 定义的操作运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄由 provider 拥有的 `terminate()` 过程并等待 managed range 退出。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后使用该 seam 定义的操作运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 @@ -31,7 +31,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | -| `disposeGraceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | +| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由 subprocess provider 执行其终止过程并等待 managed range 停稳。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)。 diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 0b4ad14baa..4b526279ba 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -55,11 +55,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent invokes provider termination. Must not exceed + * before the parent escalates to a signal. Must not exceed * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Grace for subprocess termination and output draining (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 688dc3c8a4..5ba7bc1718 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -58,18 +58,19 @@ export interface AcpRunSpec { /** * Grace period (ms) for the child's EOF-driven quiesce in * {@link SubagentRun.dispose} — the window to flush persistence and tear down - * its OWN nested subprocesses before the parent invokes provider termination. The + * its OWN nested subprocesses before the parent escalates to a signal. The * plugin fills this from its `disposeEofGraceMs` config. */ disposeEofGraceMs: number /** - * Grace supplied to subprocess termination and output draining in - * {@link SubagentRun.dispose}. The plugin fills it from `disposeGraceMs`. + * Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX + * waits this long after `SIGTERM` before `SIGKILL`, while Windows + * force-terminates directly. The plugin fills it from `disposeGraceMs`. */ disposeGraceMs: number /** * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the - * child rides the shared scrub, managed-range teardown, and service-owned + * child rides the shared scrub, tree-scoped teardown, and service-owned * lifetime instead of a package-local child_process path. */ spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -84,13 +85,13 @@ export interface AcpRunSpec { onError?: (error: Error, stopReason: SubagentStopReason) => void } -/** EOF grace for child flush and nested-process teardown; wider than the termination grace below. */ +/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 -/** Default subprocess termination and output-drain grace (the `disposeGraceMs` config). */ +/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Bounded managed-range wait: polls the handle until its owned range exits or `ms` elapses. */ +/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { const controller = new AbortController() const timer = setTimeout(() => { controller.abort() }, ms) @@ -103,20 +104,24 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { - // Observe the direct result independently. A non-positive pid does not prove - // that a native owner has no range left to terminate or await. - void child.done.catch(() => {}) + // A spawn failure has no process to tear down; observe the rejection so + // disposal in a finally block cannot surface it as unhandled. + if (child.pid <= 0) { + await child.done.catch(() => {}) + return + } child.stdin?.end() if (await treeExitsWithin(child, eofGraceMs)) return - // terminate() owns the provider-specific procedure. Its unbounded wait is - // the range owner's exit proof, not a second derived grace that can overflow. + // terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is + // the process owner's exit proof, not a second derived grace that can overflow. child.terminate() await child.waitForExit() } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index a05ea83630..26ec4cd0b3 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -196,29 +196,6 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', await expect(disposeAcpChild(child, 1_000)).resolves.toBeUndefined() await expect(child.done).rejects.toThrow() }) - - it('still asks an unpublished native owner to terminate and settle', async () => { - const terminate = vi.fn() - const waitForExit = vi.fn() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true) - const done = Promise.reject(new Error('runner failed before publishing target pid')) - void done.catch(() => {}) - const child: SubprocessHandle = { - pid: -1, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: {}, - done, - terminate, - waitForExit, - } - - await expect(disposeAcpChild(child, 100)).resolves.toBeUndefined() - expect(terminate).toHaveBeenCalledOnce() - expect(waitForExit).toHaveBeenCalledTimes(2) - }) }) describe('cwd resolution', () => { diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 61fcdc56f4..ff129c60e2 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: df7eadd15d6c15b93b0cb888f740bbbbf47161d5 -README.zh.md: aba699d520f541a69c2f9a96d85a0ee789361f51 +README.md: 0260d9c82dee82541e5b60ff7fe2c323cf9b7331 +README.zh.md: 484241f1ca23f2b8ee843b6f412d0f779832d400 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index df7eadd15d..0260d9c82d 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -6,11 +6,11 @@ This package registers a Profile-named Claude Code subagent provider whose defau ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired managed range, waits for it to exit, and rejects `start()`. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`. The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every failure still maps to `error`: the four error subtypes in Agent SDK 0.3.220 retain their exact category, an error-marked or blank success becomes `invalid-success`, a missing result becomes `missing-result`, an unclassified query failure becomes `unknown`, and an early CLI exit becomes `process-exit`. The diagnostic also names the current `query-start`, `query-run`, `process`, or `teardown` stage and independently includes an observed exit code and signal. The provider produces neither `max-tokens` nor `refusal`. -Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the subprocess provider's termination procedure, and waits for managed-range exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains on the internal cause chain and in the Provider's Host log. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains on the internal cause chain and in the Provider's Host log. Result failure and independent teardown failure remain separate. ## Native settings and interaction @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `providerName` | `claude-code` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. | | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `permissionMode` | `dontAsk` | Native non-interactive permission policy fixed for every run from this Provider instance. | -| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), supplied to subprocess termination and output draining; disposal then waits for managed-range exit. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | | `permissionMode` value | Native behavior | |---|---| diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index aba699d520..484241f1ca 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -6,11 +6,11 @@ ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的 managed range 并等待其退出,然后拒绝 `start()` 调用。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。 SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有失败仍映射为 `error`:Agent SDK 0.3.220 的四种错误子类型保留准确类别;标记为错误或内容空白的成功消息成为 `invalid-success`;缺失结果成为 `missing-result`;未分类的 query 失败成为 `unknown`;CLI 提前退出成为 `process-exit`。诊断还会注明当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,并分别保留已观测到的退出码与信号。该提供方不会产生 `max-tokens` 或 `refusal`。 -本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用 subprocess provider 的终止过程,并等待 managed range 退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链与提供方的 Host 日志中。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链与提供方的 Host 日志中。结果失败与独立的清理失败仍彼此分离。 ## 原生设置与交互 @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `providerName` | `claude-code` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `permissionMode` | `dontAsk` | 为该提供方实例的每次运行固定原生非交互权限策略。 | -| `disposeGraceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待 managed range 退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待整棵进程树退出。 | | `permissionMode` 值 | 原生行为 | |---|---| diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 1bbfaead73..741f93a6fb 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -50,7 +50,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code termination and output draining. */ + /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-claude-code/src/invariant.ts b/packages/subagent/subagent-claude-code/src/invariant.ts index e61a4ee735..44fa400e16 100644 --- a/packages/subagent/subagent-claude-code/src/invariant.ts +++ b/packages/subagent/subagent-claude-code/src/invariant.ts @@ -17,7 +17,7 @@ export const inject = ['invariants'] /** * No runtime invariant: lifecycle pairing belongs to the shared subagent - * service and managed-range ownership belongs to the subprocess service. + * service and process-tree ownership belongs to the subprocess service. */ const install: InvariantInstaller = () => {} diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index a01b4c560f..e9c3d81156 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -40,7 +40,7 @@ export function sdkEnvironmentOverlay( /** * Translate one official SDK spawn request to the shared process owner. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. - * @param graceMs - subprocess termination and output-drain grace. + * @param graceMs - process-tree termination grace. * @returns the fully explicit shared subprocess request. */ export function claudeSpawnSpec( @@ -73,7 +73,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { /** * Project a managed process with piped stdin and stdout. - * @param child - shared handle that remains the managed-range authority. + * @param child - shared handle that remains the process-tree authority. */ constructor(private readonly child: SubprocessHandle) { this.stdin = child.stdin as NonNullable @@ -93,7 +93,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { ) } - /** Whether the SDK has requested managed-range termination. */ + /** Whether the SDK has requested managed tree termination. */ get killed(): boolean { return this.killRequested } @@ -114,8 +114,8 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { } /** - * Route the SDK's termination request to the managed-range owner. - * @param _signal - SDK-selected signal; the provider owns its termination procedure. + * Route the SDK's termination request to the tree-scoped process owner. + * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder. * @returns false only after exit or a previous termination request. */ kill(_signal: NodeJS.Signals): boolean { diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index dfcc9cdd31..3b0a19073d 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -1,7 +1,7 @@ /** * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its * real CLI process under the shared subprocess owner, map only strict SDK - * success to completion, and dispose to managed-range quiescence. + * success to completion, and dispose to whole-tree quiescence. * * @module @deepseek-ai/dsh-subagent-claude-code/run */ @@ -36,7 +36,7 @@ import { ManagedClaudeCodeProcess, } from './process.ts' -/** Default subprocess termination and output-drain grace. */ +/** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 /** Claude Code permission modes that cannot wait for a human response. */ @@ -154,7 +154,7 @@ export interface ClaudeCodeRunSpec { readonly permissionMode: ClaudeCodePermissionMode /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record - /** Grace passed to the shared subprocess owner. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -259,10 +259,10 @@ export async function consumeClaudeQuery( } /** - * Close the official query, start managed-range termination, and wait for the - * subprocess owner to prove the range is empty. + * Close the official query, terminate the managed process tree, and wait for + * the subprocess owner to prove it is gone. * @param query - official SDK query, when creation reached that point. - * @param child - live shared-service handle that owns the CLI managed range; + * @param child - live shared-service handle that owns the CLI process tree; * spawn-failed handles settle at the startup boundary instead. */ export async function disposeClaudeCodeChild( @@ -454,30 +454,26 @@ export async function startClaudeCodeRun( ) requestCancel() if (child !== undefined && child.pid <= 0) { - let spawnError = thrown(error) - void child.done.catch((childError: unknown) => { spawnError = thrown(childError) }) - const cleanupErrors: Error[] = [] + let closeError: Error | undefined try { query?.close() } catch (disposeError: unknown) { - cleanupErrors.push(thrown(disposeError)) + closeError = thrown(disposeError) } - child.terminate() - try { - await child.waitForExit() - } catch (disposeError: unknown) { - cleanupErrors.push(thrown(disposeError)) - } - await Promise.resolve() - if (cleanupErrors.length > 0) { + let spawnError = thrown(error) + try { + await child.done + } catch (childError: unknown) { + spawnError = thrown(childError) + } + + if (closeError !== undefined) { const failure = startupFailure(spawnError) const cleanupFailure = new ClaudeCodeFailure({ stage: 'teardown', category: 'unknown', - }, cleanupErrors.length === 1 - ? cleanupErrors[0] - : new AggregateError(cleanupErrors, 'Claude Code teardown failures')) + }, closeError) const aggregate = new AggregateError( [failure, cleanupFailure], `${failure.message}; ${cleanupFailure.message}`, diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 44455f3d65..3298e07037 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1449,8 +1449,8 @@ describe('run publication, cancellation, and settlement', () => { await expect(failedStartup).rejects.not.toThrow('spawn /sdk/claude EACCES') await expect(failedStartup).rejects.toMatchObject({ cause: spawnError }) expect(failed.close).toHaveBeenCalledOnce() - expect(failedSpawn.terminate).toHaveBeenCalledOnce() - expect(failedSpawn.waitForExit).toHaveBeenCalledOnce() + expect(failedSpawn.terminate).not.toHaveBeenCalled() + expect(failedSpawn.waitForExit).not.toHaveBeenCalled() const failedSpawnAbort = new AbortController() const cancelledFailedSpawn = fakeChild({ @@ -1505,12 +1505,10 @@ describe('run publication, cancellation, and settlement', () => { expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce() const failedSpawnCloseError = new Error('query close failed') - const failedSpawnWaitError = new Error('managed range wait failed') const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) const failedSpawnWithCloseFailure = fakeChild({ pid: -1, doneError: spawnError, - waitForExitError: failedSpawnWaitError, }) queryMock.mockImplementationOnce(({ options }) => { options.spawnClaudeCodeProcess!(sdkSpawnOptions()) @@ -1520,18 +1518,17 @@ describe('run publication, cancellation, and settlement', () => { ...unused.spec, spawn: () => failedSpawnWithCloseFailure.handle, }) - const failedWithCloseError = await failedWithCloseFailure.catch((error: unknown) => error) - expect(failedWithCloseError).toBeInstanceOf(AggregateError) - expect(String(failedWithCloseError)).toContain(expectedFailureDiagnostic('query-start', 'unknown')) - expect(String(failedWithCloseError)).not.toContain('spawn /sdk/claude EACCES') - const failures = (failedWithCloseError as AggregateError).errors as unknown[] - expect(failures[0]).toMatchObject({ cause: spawnError }) - const cleanupCause = errorCause(failures[1]) - expect(cleanupCause).toBeInstanceOf(AggregateError) - expect((cleanupCause as AggregateError).errors).toEqual([ - failedSpawnCloseError, - failedSpawnWaitError, - ]) + await expect(failedWithCloseFailure) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(failedWithCloseFailure) + .rejects.not.toThrow('spawn /sdk/claude EACCES') + await expect(failedWithCloseFailure).rejects.toMatchObject({ + message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + errors: [ + expect.objectContaining({ cause: spawnError }), + expect.objectContaining({ cause: failedSpawnCloseError }), + ], + }) const cleanupError = new Error('live child cleanup failed') const constructionError = new Error( diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 6345a1581e..0f8ed31eab 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md -README.md: a0ae6a2f614e20dce0b20220706f63b5a993a8cf -README.zh.md: 4562285158283ba239182642565f83479331e343 +README.md: 975f353b9f1bc6fab61a4c0eb40ebaf50c436623 +README.zh.md: 2ea256afb3bb8fdfe57fd555b6db10522a709a56 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index a0ae6a2f61..975f353b9f 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -6,7 +6,7 @@ This package registers a Profile-named Codex subagent provider whose default nam ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, invokes managed-range termination, waits for the range to empty, and rejects `start()`. Non-cancellation rejections expose only the fixed `initialize` or `thread-start` stage plus an already observed process outcome; raw product and Host errors remain on internal cause chains. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. Non-cancellation rejections expose only the fixed `initialize` or `thread-start` stage plus an already observed process outcome; raw product and Host errors remain on internal cause chains. The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. @@ -14,7 +14,7 @@ For command and file approvals, the unattended provider selects a non-approval d Local cancellation wins the result race and maps to `aborted`. For failed turns, the diagnostic preserves all eleven string and five object variants in the Codex 0.147.0 `codexErrorInfo` union; the four connection/stream variants retain a numeric `httpStatusCode` when supplied, while `activeTurnNotSteerable` does not expose `turnKind`. The diagnostic also names `turn-start`, `turn`, or `process`, independently includes available exit code and signal, and uses `unknown` for unrecognized or malformed values without copying raw fields. `contextWindowExceeded` remains `max-tokens`; every other remote interruption or failure remains `error`, and the provider produces no `refusal`. A contributing permission decision follows the structured failure line. Successful and locally cancelled runs omit both facts. -`dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the subprocess provider's termination procedure, waits for managed-range exit, and detaches the stderr observer. Independent cleanup rejection uses the fixed `teardown` stage and any available process outcome. When startup and rollback both fail, the top-level aggregate message preserves both safe stage lines while the raw failures remain internal. +`dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Independent cleanup rejection uses the fixed `teardown` stage and any available process outcome. When startup and rollback both fail, the top-level aggregate message preserves both safe stage lines while the raw failures remain internal. ## Capabilities and context @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `providerName` | `codex` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. | | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | | `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this Provider instance. | -| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), supplied to subprocess termination and output draining; disposal then waits for managed-range exit. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | | `permissionMode` value | `thread/start` fields | Native behavior | |---|---|---| diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 4562285158..2ea256afb3 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -6,7 +6,7 @@ ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、启动 managed-range 终止并等待该范围为空,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 @@ -14,7 +14,7 @@ 本地取消会在结果竞态中胜出并映射为 `aborted`。对于失败轮次,诊断会保留 Codex 0.147.0 `codexErrorInfo` 联合中的全部十一种字符串与五种对象 variant;四种连接/stream variant 会在上游提供时保留数值 `httpStatusCode`,而 `activeTurnNotSteerable` 不公开 `turnKind`。诊断还会注明 `turn-start`、`turn` 或 `process`,分别包含可用的退出码与信号,并对无法识别或格式错误的值使用 `unknown`,且不复制原始字段。`contextWindowExceeded` 仍映射为 `max-tokens`;其他任何远端中断或失败仍映射为 `error`,且该提供方不会产生 `refusal`。参与失败的权限决定会跟在结构化失败行之后。成功与本地取消都不附带这两类事实。 -`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用 subprocess provider 的终止过程,等待 managed range 退出,并移除 stderr observer。独立清理拒绝使用固定的 `teardown` 阶段与可用进程结果。当启动与回滚同时失败时,顶层聚合消息会保留两条安全阶段说明,而原始失败仍只在内部可见。 +`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。独立清理拒绝使用固定的 `teardown` 阶段与可用进程结果。当启动与回滚同时失败时,顶层聚合消息会保留两条安全阶段说明,而原始失败仍只在内部可见。 ## 能力与上下文 @@ -27,7 +27,7 @@ | `providerName` | `codex` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | | `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 | -| `disposeGraceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待 managed range 退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待整棵进程树退出。 | | `permissionMode` 值 | `thread/start` 字段 | 原生行为 | |---|---|---| diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 6089a69d75..79fbdab078 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -43,7 +43,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server termination and output draining. */ + /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts index 0c350fffa7..ec9a6302c4 100644 --- a/packages/subagent/subagent-codex/src/invariant.ts +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: lifecycle pairing belongs to the shared subagent - * service and managed-range ownership belongs to the subprocess service. + * service and process-tree ownership belongs to the subprocess service. */ const install: InvariantInstaller = () => {} diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 55382e7e55..9db5d7086c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -1,7 +1,7 @@ /** * One-shot Codex child lifecycle: spawn the real app-server through the * subprocess seam, publish only after initialization and ephemeral thread - * creation, flatten post-publication failures, and dispose to managed-range + * creation, flatten post-publication failures, and dispose to whole-tree * quiescence. * * @module @deepseek-ai/dsh-subagent-codex/run @@ -31,7 +31,7 @@ import { type CodexWireFailureFacts, } from './wire.ts' -/** Default subprocess termination and output-drain grace. */ +/** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 interface CodexPackageManifest { @@ -141,7 +141,7 @@ export interface CodexRunSpec { readonly permissionMode: CodexPermissionMode /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record - /** Grace passed to the shared subprocess owner. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -177,10 +177,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { } /** - * Close the private wire, start managed-range termination, and wait for the - * subprocess owner to prove the range is empty. + * Close the private wire, terminate the managed process tree, and wait for the + * subprocess owner to prove it is gone. * @param wire - private app-server protocol connection. - * @param child - shared-service handle that owns the managed range. + * @param child - shared-service handle that owns the process tree. */ export async function disposeCodexChild( wire: CodexAppServerWire, @@ -188,32 +188,32 @@ export async function disposeCodexChild( ): Promise { wire.close() - const targetPublished = child.pid > 0 - let outcome: SubprocessOutcome | undefined - if (targetPublished) { + if (child.pid > 0) { + let outcome: SubprocessOutcome | undefined void child.done.then( (value) => { outcome = value }, /* v8 ignore next -- a positive pid excludes spawn-level done rejection. */ () => {}, ) + try { + child.stdin?.end() + } catch { + // A concurrently closed stdin does not change tree ownership below. + } + child.terminate() + try { + await child.waitForExit() + } catch (error: unknown) { + throw new CodexRunFailure({ + stage: 'teardown', + category: 'unknown', + outcome, + }, thrown(error)) + } + await child.done + } else { + await child.done.catch(() => {}) } - try { - child.stdin?.end() - } catch { - // A concurrently closed stdin does not change range ownership below. - } - child.terminate() - try { - await child.waitForExit() - } catch (error: unknown) { - throw new CodexRunFailure({ - stage: 'teardown', - category: 'unknown', - outcome, - }, thrown(error)) - } - if (targetPublished) await child.done - else await child.done.catch(() => {}) } /** diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index bdd5ee4aeb..38c93dee23 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1922,8 +1922,7 @@ describe('run lifecycle and quiescence', () => { await expect(asyncSpawnFailure) .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) await expect(asyncSpawnFailure).rejects.not.toThrow('SECRET_TOKEN') - expect(asyncSpawnFailureChild.terminate).toHaveBeenCalledOnce() - expect(asyncSpawnFailureChild.waitForExit).toHaveBeenCalledOnce() + expect(asyncSpawnFailureChild.terminate).not.toHaveBeenCalled() const child = fakeChild() const starting = startCodexRun(request(), runSpec(child)) @@ -2297,7 +2296,7 @@ describe('disposeCodexChild', () => { .resolves.toBeUndefined() }) - it('asks an unpublished owner to settle after a spawn-level failure', async () => { + it('handles a spawn-level failure with no process tree', async () => { const child = fakeChild({ pid: -1, doneError: new Error('spawn failed'), @@ -2305,8 +2304,8 @@ describe('disposeCodexChild', () => { const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() - expect(child.terminate).toHaveBeenCalledOnce() - expect(child.waitForExit).toHaveBeenCalledOnce() + expect(child.terminate).not.toHaveBeenCalled() + expect(child.waitForExit).not.toHaveBeenCalled() }) it('reports tree-wait failure with safe teardown facts', async () => { diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index edaf0286ef..abb6dd50e7 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -5,7 +5,7 @@ * working-directory resolution (config override, else the delegating parent * session's workspace), the never-reject result settlement, and the standard * run-handle publication. Backends compose these with their own wire drivers; - * the process machinery itself (spawn, env scrub, managed-range teardown) + * the process machinery itself (spawn, env scrub, tree-scoped teardown) * belongs to the `dsh-subprocess` seam. * * @module @deepseek-ai/dsh-subagent/out-of-process diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index a240b04ac4..cb27192356 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/README.md -README.md: dfb5763d3ac867ddd5b941d5619bf88a2da73cda -README.zh.md: 3f0e7f930133468dcb83ef47e5ec35c67f44ce63 +README.md: 1b516c36d81a51fc2e0023d69f748cb4b46f36a4 +README.zh.md: cb83d0a9d417114f20e8fbd970f57c5d917f736f diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index dfb5763d3a..1b516c36d8 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -2,12 +2,12 @@ English | [中文](README.zh.md) -The shared process substrate for one execution world: executable lookup, fully specified provider-managed ranges with raw or collected stdio, and one deep terminal-process primitive that owns PTY allocation, foreground groups, and provider-observable session cleanup. Command defaulting, shell semantics, deadlines, protocol framing, readiness, and presentation stay with consumers — the [bash executors](../shell/README.md), [LSP host](../lsp/README.md), [PTY shell backend](../terminal/README.md), and [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +The shared process substrate for one execution world: executable lookup, fully-specified managed child-process trees with raw or collected stdio, and one deep terminal-process primitive that owns PTY allocation, foreground groups, and provider-observable session cleanup. Command defaulting, shell semantics, deadlines, protocol framing, readiness, and presentation stay with consumers — the [bash executors](../shell/README.md), [LSP host](../lsp/README.md), [PTY shell backend](../terminal/README.md), and [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). | Package | ctx key | Role | |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | -| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: native and fallback managed ranges, bounded collection/spill, `node-pty`, foreground/session inspection, signalling, and terminate-and-join disposal | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | | [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for sandbox and ordinary process creation, inherited/anonymous-pipe stdio, suspended Job assignment, polling, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 3f0e7f9301..cb83d0a9d4 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -这里集中提供一个执行世界的共享进程基底:可执行文件查找、具有原始或收集式 stdio 的完整 provider-managed range,以及一项底层终端进程原语,负责 PTY 分配、前台进程组和 provider 仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../shell/README.zh.md)、[LSP 主机](../lsp/README.zh.md)、[PTY shell 后端](../terminal/README.zh.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.zh.md)。参见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)。 +这里集中提供一个执行世界的共享进程基底:可执行文件查找、具有原始或收集式 stdio 的完全明确指定的受管子进程树,以及一项底层终端进程原语,负责 PTY 分配、前台进程组和提供方仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../shell/README.zh.md)、[LSP 主机](../lsp/README.zh.md)、[PTY shell 后端](../terminal/README.zh.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.zh.md)。参见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)。 | 包 | ctx 键 | 角色 | |---|---|---| | [`subprocess`](subprocess/README.zh.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | -| [`subprocess-local`](subprocess-local/README.zh.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:native 与 fallback managed range、有界收集/spill、`node-pty`、前台/会话检查、信号发送,以及先终止再等待退出的 dispose(资源释放) | +| [`subprocess-local`](subprocess-local/README.zh.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | | [`win32-process`](win32-process/README.zh.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:sandbox 与 ordinary process creation、继承/匿名管道 stdio、suspended Job 分配、polling、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 3289991e58..18ed126f0e 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/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: 12604fa74cc645340ca79b5734c34c7027b988eb -README.zh.md: e484b98df4b8411a8b78d2710c97067ac5a2bec6 +README.md: af971f2f9706117d9a7be8fdeb29eea4d33f72e9 +README.zh.md: e829dce28d2e70d75665aa0de25611f445a49e6a diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 12604fa74c..af971f2f97 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. Windows creates a parent-owned kill-on-close Job; its runner opens that Job, connects the target side of private parent-owned stdio pipes, creates the target suspended, assigns it, and resumes it. The parent opens its own direct-process wait handle before releasing the runner, so raw pipe EOF and stdin lifetime follow the target-side handles rather than the runner process. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure, while the parent observes the Windows target exit separately from range lifetime; only collected pipes retain the existing bounded drain grace. +- **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. - **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 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). +- **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). ## 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. The Windows runner is released as soon as the parent acquires direct-process observation; the Linux runner remains until the direct target result, while the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux runner events and Windows direct-process state are polled asynchronously every 100 ms and 10 ms respectively, while Linux scope state is polled every 200 ms. +- **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. - **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. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index e484b98df4..e829dce28d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job、连接由 parent 持有的 private stdio pipe 的 target 端,以 suspended 状态创建目标、完成分配后再恢复。parent 会先打开自己的 direct-process wait handle,再释放 runner,因此 raw pipe EOF 与 stdin 生命周期取决于 target 端 handle,而不取决于 runner process。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 报告目标启动失败,而 parent 独立观察 Windows target exit,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 +- **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 保留既有有界排空宽限期。 - **明确披露较弱 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 请求;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)。 +- **同步宿主退出最终清理**:服务 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)。 ## 模型体验 @@ -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。Windows runner 会在 parent 取得 direct-process observation 后立即释放;Linux runner 保留到 direct target result,而后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux runner event 与 Windows direct-process state 分别每 100 ms 和 10 ms 异步轮询,Linux scope state 每 200 ms 轮询。 +- **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 异步轮询。 - **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` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 14bd6c3bda..5d30fa50ba 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -94,13 +94,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { const pending: Promise[] = [] for (const handle of this.live) { handle.terminate() - // Direct result and range observation are independent. Start both now so - // an unreadable owner cannot hide behind a target that termination failed - // to stop; direct-result rejection itself remains non-fatal to disposal. - pending.push(Promise.all([ - handle.done.catch(() => {}), - handle.waitForExit(), - ]).then(() => undefined)) + // Spawn-failure rejections already settled and left the live set. + pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) } for (const terminal of this.terminals) { pending.push(terminal.terminate()) @@ -170,9 +165,10 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { handle = bindManagedProcess(spec, launch, binding) } this.live.add(handle) - // Release ownership only once the managed range is empty, not at spawned- - // command settlement. A surviving helper remains owned until provider - // termination and observation reach quiescence. + // Release ownership only once the whole TREE is gone, not at direct-child + // settlement — a TERM-trapping helper that outlives the leader must stay + // owned so teardown can still escalate it. For the common no-survivor + // case waitForExit resolves immediately after settlement. const release = (): Promise => handle.waitForExit().then(() => { this.live.delete(handle) }) void handle.done.then(release, release).catch(() => {}) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index a6ca284a22..24f7bb947b 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -38,7 +38,7 @@ const SCOPE_POLL_INTERVAL_MS = 200 const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu function systemctlEnv(): NodeJS.ProcessEnv { - return childEnv({ LC_ALL: 'C' }) + return { ...process.env, LC_ALL: 'C' } } function querySystemctl(command: string, args: readonly string[]): Promise { @@ -106,6 +106,7 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { class SystemdScopeOwner implements BoundProcessOwner { private stopped = false private observation: Promise | undefined + private killConfirmed = false private killFailure: Error | undefined constructor( @@ -126,6 +127,7 @@ class SystemdScopeOwner implements BoundProcessOwner { this.unit, ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS }) if (result.error === undefined && result.status === 0) { + if (signal === 'SIGKILL') this.killConfirmed = true return } if (signal === 'SIGKILL') { @@ -172,6 +174,9 @@ class SystemdScopeOwner implements BoundProcessOwner { return waitWithAbort(this.observation, signal) } + forcedOutcome(): { exitCode: null; signal: 'SIGKILL' } | undefined { + return this.killConfirmed ? { exitCode: null, signal: 'SIGKILL' } : undefined + } } /** @@ -213,15 +218,7 @@ export function launchLinuxScope( }) const closed = observeChildClose(child) const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, query, child) - const result = runnerDirectResult(child, files, closed) + const result = runnerDirectResult(child, files, closed, () => owner.forcedOutcome()) cleanupAfterRunner(files, result.direct, closed) - return { - stdin: child.stdin, - stdout: child.stdout, - stderr: child.stderr, - pid: result.pid, - direct: result.direct, - closed, - owner, - } + return { child, pid: result.pid, direct: result.direct, closed, owner } } diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index b37f66d137..945701dbe8 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -1,7 +1,6 @@ /** Minimal managed-range ownership bound to one ordinary subprocess handle. */ import type { ChildProcess } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' /** Platform owner used by termination and whole-range settlement. */ @@ -14,9 +13,7 @@ export interface BoundProcessOwner { /** Platform launch facts consumed by the common stdio and result lifecycle. */ export interface ManagedProcessLaunch { - stdin: Writable | null - stdout: Readable | null - stderr: Readable | null + child: ChildProcess pid: number direct: Promise closed: Promise diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index e4f98e3fb6..f09da5adca 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -41,14 +41,17 @@ export function spawnRunnerInvocation(): string[] { /** * Build wrapper stdio corresponding to the public target dispositions. * @param spec - target stdio request. + * @param ipc - append a Node IPC channel for the Windows runner. * @returns child-process stdio configuration. */ -export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions { - return [ +export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions { + const stdio: StdioOptions = [ spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', ] + if (ipc) stdio.push('ipc') + return stdio } /** @@ -69,11 +72,6 @@ interface RunnerHandshake { events: RunnerEvent[] } -/** Startup-only result for launchers that observe the direct process elsewhere. */ -export type RunnerStartResult = - | { ok: true; pid: number; events: RunnerEvent[] } - | { ok: false; pid: -1; error: Error } - /** Observe wrapper death without waiting for Node's blocked event loop to emit close. */ function runnerExited(child: ChildProcess, pid: number): boolean { if (child.exitCode !== null || child.signalCode !== null) return true @@ -104,9 +102,7 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner const events = readRunnerEvents(files.eventsPath) const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error') if (terminal?.type === 'started') return { pid: terminal.pid, events } - if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') { - throw deserializeSpawnError(terminal.error) - } + if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events } if (child.pid === undefined) throw new Error('native subprocess runner failed to start') if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start') Atomics.wait(handshakeWait, 0, 0, 5) @@ -114,30 +110,11 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`) } -/** - * Read only the native runner's startup result. - * @param child - native wrapper process. - * @param files - private request and result paths. - * @returns target pid after publication, otherwise the launch failure. - */ -export function runnerStart(child: ChildProcess, files: RunnerFiles): RunnerStartResult { - try { - const handshake = waitForRunnerHandshake(child, files) - return { ok: true, pid: handshake.pid, events: handshake.events } - } catch (error) { - cleanupRunnerFiles(files) - return { - ok: false, - pid: -1, - error: error as Error, - } - } -} - async function waitForDirectResult( files: RunnerFiles, initial: RunnerEvent[], closed: Promise, + missingResult?: () => SubprocessOutcome | undefined, ): Promise { let seen = 0 const wrapperState = { closed: false } @@ -154,6 +131,8 @@ async function waitForDirectResult( } seen = Math.max(seen, events.length, initial.length) if (closedBeforeRead) { + const known = missingResult?.() + if (known !== undefined) return known throw new Error('native subprocess runner exited without a direct-command result') } await sleepMs(RUNNER_EVENT_POLL_MS) @@ -165,21 +144,28 @@ async function waitForDirectResult( * @param child - native wrapper process. * @param files - private request and result paths. * @param closed - wrapper close observation attached before the start handshake. + * @param missingResult - authoritative outcome available when force-kill prevents a final event. * @returns target pid and direct result promise. */ export function runnerDirectResult( child: ChildProcess, files: RunnerFiles, closed: Promise, + missingResult?: () => SubprocessOutcome | undefined, ): { pid: number direct: Promise } { - const start = runnerStart(child, files) - if (!start.ok) return { pid: -1, direct: Promise.resolve().then(() => { throw start.error }) } + let handshake: RunnerHandshake + try { + handshake = waitForRunnerHandshake(child, files) + } catch (error) { + cleanupRunnerFiles(files) + return { pid: -1, direct: Promise.resolve().then(() => { throw error }) } + } return { - pid: start.pid, - direct: waitForDirectResult(files, start.events, closed), + pid: handshake.pid, + direct: waitForDirectResult(files, handshake.events, closed, missingResult), } } diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index 73762bf2dc..0eab839b9f 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -2,10 +2,9 @@ import { appendFileSync, - lstatSync, mkdtempSync, readFileSync, - rmdirSync, + rmSync, unlinkSync, writeFileSync, } from 'node:fs' @@ -219,18 +218,7 @@ export function deserializeSpawnError(serialized: SerializedSpawnError): Error { */ export function cleanupRunnerFiles(files: RunnerFiles): void { try { - if (lstatSync(files.directory).isSymbolicLink()) { - unlinkSync(files.directory) - return - } - for (const file of [files.requestPath, files.eventsPath]) { - try { - unlinkSync(file) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - } - rmdirSync(files.directory) + rmSync(files.directory, { recursive: true, force: true }) } catch { // A crash residue remains private and is not reused by later spawns. } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 54d850081f..1636ce0fe5 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -1,15 +1,17 @@ /** Native managed-range runner for ordinary local subprocesses. */ import { spawn } from 'node:child_process' +import { closeSync } from 'node:fs' import { closeHandleChecked, + isJobEmpty, loadWin32ProcessBindings, - openNamedPipeForStdio, - openJobForAssignment, - spawnOrdinaryProcessInJob, + pollProcessExit, + spawnOrdinaryJobProcess, + terminateJob, Win32Error, } from '@deepseek-ai/dsh-win32-process' -import type { ChildStdioHandles, NativePtr } from '@deepseek-ai/dsh-win32-process' +import type { NativePtr } from '@deepseek-ai/dsh-win32-process' import { appendRunnerEvent, consumeRunnerRequest, @@ -20,73 +22,40 @@ import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts' type RunnerArgs = | { mode: 'probe-node' } | { mode: 'probe-win32' } - | { mode: 'node'; requestPath: string; eventsPath: string } - | { - mode: 'win32' - requestPath: string - eventsPath: string - jobName: string - stdinPipe?: string - stdoutPipe?: string - stderrPipe?: string - } + | { mode: 'node' | 'win32'; requestPath: string; eventsPath: string } function parseArgs(argv: string[]): RunnerArgs { let mode: string | undefined - let jobName: string | undefined let requestPath: string | undefined let eventsPath: string | undefined - let stdinPipe: string | undefined - let stdoutPipe: string | undefined - let stderrPipe: string | undefined for (let index = 0; index < argv.length; index += 2) { const key = argv[index] 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 if (key === '--stdin-pipe') stdinPipe = value - else if (key === '--stdout-pipe') stdoutPipe = value - else if (key === '--stderr-pipe') stderrPipe = value else throw new Error(`subprocess runner unknown argument: ${String(key)}`) } 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, - ...stdinPipe === undefined ? {} : { stdinPipe }, - ...stdoutPipe === undefined ? {} : { stdoutPipe }, - ...stderrPipe === undefined ? {} : { stderrPipe }, - } - } return { mode, requestPath, eventsPath } } function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpawnError { - const serialized = serializeSpawnError(error) - const code = error instanceof Win32Error - ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 - ? 'ENOENT' - : error.win32Code === 5 - ? 'EPERM' - : error.win32Code === 193 - ? 'EFTYPE' - : 'UNKNOWN' - : serialized.code - if (code === undefined) return serialized + if (!(error instanceof Win32Error)) return serializeSpawnError(error) + const code = error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 + ? 'ENOENT' + : error.win32Code === 5 + ? 'EPERM' + : error.win32Code === 193 + ? 'EFTYPE' + : 'UNKNOWN' const program = request.argv[0] as string return { - ...serialized, - name: serialized.name, - message: `spawn ${program} ${code}: ${serialized.message}`, + name: 'Error', + message: `spawn ${program} ${code}: ${error.message}`, code, syscall: `spawn ${program}`, path: program, @@ -128,81 +97,78 @@ function replaceEnvironment(env: Record): void { Object.assign(process.env, env) } -function closeStdioHandles( - api: ReturnType, - handles: Array<{ handle: NativePtr; label: string }>, - reportFailure: boolean, -): void { - let failure: Error | undefined - for (const { handle, label } of handles.splice(0)) { +/** Release the runner's copies after the Windows target inherits its standard handles. */ +function releaseRunnerStdio(): void { + for (const fd of [0, 1, 2]) { try { - closeHandleChecked(api, handle, label) + closeSync(fd) } catch (error) { - failure ??= error instanceof Error ? error : new Error(String(error)) + if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error } } - if (reportFailure && failure !== undefined) throw failure } -async function runWin32( - request: RunnerRequest, - eventsPath: string, - jobName: string, - pipes: Pick, 'stdinPipe' | 'stdoutPipe' | 'stderrPipe'>, -): Promise { +async function runWin32(request: RunnerRequest, eventsPath: string): Promise { replaceEnvironment(request.env) const api = loadWin32ProcessBindings() let processHandle: NativePtr | undefined let jobHandle: NativePtr | undefined let targetStarted = false - let targetCreationAttempted = false - const openedStdio: Array<{ handle: NativePtr; label: string }> = [] try { - if (!process.connected) throw new Error('Windows subprocess runner requires a parent IPC channel') - const released = new Promise((resolve) => { process.once('disconnect', resolve) }) - jobHandle = openJobForAssignment(api, jobName) - const stdio: ChildStdioHandles = {} - for (const [key, path] of [ - ['stdin', pipes.stdinPipe], - ['stdout', pipes.stdoutPipe], - ['stderr', pipes.stderrPipe], - ] as const) { - if (path === undefined) continue - const handle = openNamedPipeForStdio(api, path) - stdio[key] = handle - openedStdio.push({ handle, label: `ordinary target ${key} pipe` }) + let spawned + try { + 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 } - // Node attributes an invalid cwd to the attempted target spawn rather - // than exposing the launcher's internal chdir operation. - targetCreationAttempted = true - process.chdir(request.cwd) - const [command, ...args] = request.argv - const spawned = spawnOrdinaryProcessInJob( - api, - { command: command as string, args, cwd: process.cwd() }, - jobHandle, - stdio, - ) processHandle = spawned.process + jobHandle = spawned.job targetStarted = true - closeStdioHandles(api, openedStdio, true) - closeHandleChecked(api, jobHandle, 'ordinary process Job assignment') - jobHandle = undefined + 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() appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) - await released - const directProcess = processHandle - processHandle = undefined - closeHandleChecked(api, directProcess, 'ordinary direct process handoff') + + await new Promise((resolve, reject) => { + const timer = setInterval(() => { + try { + if (processHandle !== undefined) { + const exitCode = pollProcessExit(api, processHandle) + if (exitCode !== undefined) { + appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null }) + closeHandleChecked(api, processHandle, 'ordinary direct process') + processHandle = undefined + } + } + 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))) + } + }, 10) + }) } catch (error) { appendRunnerEvent(eventsPath, { type: targetStarted ? 'runner-error' : 'spawn-error', - error: targetStarted || !targetCreationAttempted - ? serializeSpawnError(error) - : win32SpawnError(error, request), + error: targetStarted ? serializeSpawnError(error) : win32SpawnError(error, request), }) process.exitCode = 127 } finally { - closeStdioHandles(api, openedStdio, false) if (processHandle !== undefined) { try { closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ } } @@ -223,7 +189,7 @@ async function main(): Promise { if (args.mode === 'node') runNode(request, args.eventsPath) else { try { - await runWin32(request, args.eventsPath, args.jobName, args) + await runWin32(request, args.eventsPath) } finally { if (process.connected) process.disconnect() } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index a4071c7a42..b579fb022a 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,6 +1,6 @@ /** - * Process plumbing for the local subprocess service: ordinary process launch - * with per-stream stdio dispositions, tail-keep collection with spill + * Process plumbing for the local subprocess service: detached process-tree + * spawn with per-stream stdio dispositions, tail-keep collection with spill * files, provider-owned range signalling, and common termination scheduling. * POSIX owners stage TERM before KILL; Windows owners terminate immediately. * This layer reacts to an abort signal; callers own deadlines, teardown @@ -420,7 +420,7 @@ function fallbackOwner( } /** - * Bind platform launch facts to the existing stdio, outcome, abort, and termination lifecycle. + * Bind platform launch facts to the existing stdio, outcome, abort, and escalation lifecycle. * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. * @param launch - platform child streams, direct outcome, and managed-range owner. * @param internals - test-only spill-directory override. @@ -433,7 +433,7 @@ export function bindManagedProcess( ): LocalSubprocessHandle { validateSubprocessSpec(spec) const { spillDir } = prepareManagedProcessBinding(internals) - const { stdin, stdout, stderr } = launch + const child = launch.child const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect => mode !== 'pipe' && mode !== 'inherit' @@ -447,14 +447,8 @@ export function bindManagedProcess( stream.on('data', (chunk: Buffer) => { collector.push(chunk) }) return collector } - const stdoutCollector = collectStream(outMode, stdout, 'stdout') - const stderrCollector = collectStream(errMode, stderr, 'stderr') - const stopCollectors = (): void => { - if (stdoutCollector !== undefined) stdout?.destroy() - if (stderrCollector !== undefined) stderr?.destroy() - stdoutCollector?.seal() - stderrCollector?.seal() - } + const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') + const stderrCollector = collectStream(errMode, child.stderr, 'stderr') let graceTimer: ReturnType | undefined let rangeExitObserved = false @@ -501,9 +495,9 @@ export function bindManagedProcess( // Batch stdin is written and closed up front; process exit and captured // output remain authoritative, so write errors (EPIPE) are best-effort. - if (typeof stdinMode === 'object' && stdin !== null) { - stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - stdin.end(stdinMode.data) + if (typeof stdinMode === 'object' && child.stdin !== null) { + child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) + child.stdin.end(stdinMode.data) } const done = new Promise((resolve, reject) => { @@ -515,7 +509,10 @@ export function bindManagedProcess( settled = true // Only harness-collected pipes are force-closed at the drain boundary; // a 'pipe'-mode stream belongs to the caller and closes with the child. - stopCollectors() + if (stdoutCollector !== undefined) child.stdout?.destroy() + if (stderrCollector !== undefined) child.stderr?.destroy() + stdoutCollector?.seal() + stderrCollector?.seal() cleanup() resolve(outcome) } @@ -532,7 +529,8 @@ export function bindManagedProcess( if (settled) return settled = true terminate() - stopCollectors() + stdoutCollector?.seal() + stderrCollector?.seal() cleanup() reject(error instanceof Error ? error : new Error(String(error))) }) @@ -541,8 +539,8 @@ export function bindManagedProcess( if (directOutcome !== undefined) settle(directOutcome) }) function cleanup(): void { - // graceTimer deliberately NOT cleared: the forced termination call must - // still reach range survivors after the spawned command settles. + // graceTimer deliberately NOT cleared: the SIGKILL escalation must be + // able to reach tree survivors after the direct child settles. if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) } }) @@ -554,11 +552,10 @@ export function bindManagedProcess( return { pid: launch.pid, - /* v8 ignore start -- pipe-mode streams exist on every conforming launch; - the null-coalesces guard an internal adapter defect only. */ - stdin: stdinMode === 'pipe' ? stdin ?? undefined : undefined, - stdout: outMode === 'pipe' ? stdout ?? undefined : undefined, - stderr: errMode === 'pipe' ? stderr ?? undefined : undefined, + /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ + stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined, + stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined, + stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined, /* v8 ignore stop */ collected: { ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {}, @@ -603,13 +600,5 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers, direct, ) - return bindManagedProcess(spec, { - stdin: child.stdin, - stdout: child.stdout, - stderr: child.stderr, - pid, - direct, - closed, - owner, - }, binding) + return bindManagedProcess(spec, { child, pid, direct, closed, owner }, binding) } diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 88f3497a72..3de46915da 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -1,140 +1,23 @@ /** Windows Job runner launch and managed-range ownership. */ import { spawn, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import type { Readable } from 'node:stream' -import { setTimeout as sleepMs } from 'node:timers/promises' -import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { - closeHandleChecked, - createKillOnCloseJob, - isJobEmpty, - loadWin32ProcessBindings, - openProcessForWait, - pollProcessExit, - terminateJob, -} from '@deepseek-ai/dsh-win32-process' -import type { NativePtr } from '@deepseek-ai/dsh-win32-process' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' -import { waitWithAbort } from './managed-owner.ts' +import { observeChildClose, waitWithAbort } from './managed-owner.ts' import { childEnv } from './spawn.ts' import { + cleanupAfterRunner, + runnerDirectResult, runnerFiles, - runnerStart, + runnerStdio, spawnRunnerInvocation, } from './runner-launch.ts' -import { cleanupRunnerFiles } from './runner-protocol.ts' -import { createWindowsStdioBridge } from './windows-stdio.ts' - -const JOB_POLL_INTERVAL_MS = 10 -const PROCESS_POLL_INTERVAL_MS = 10 - -/** Parent-side operations for one Windows managed launch. */ -export interface WindowsProcessOperations { - create(name: string): NativePtr - openProcess(pid: number): NativePtr - pollProcess(process: NativePtr): number | undefined - empty(job: NativePtr): boolean - terminate(job: NativePtr): void - closeJob(job: NativePtr): void - closeProcess(process: NativePtr): void -} - -function nativeProcessOperations(): WindowsProcessOperations { - const api = loadWin32ProcessBindings() - return { - create: name => createKillOnCloseJob(api, name), - openProcess: pid => openProcessForWait(api, pid), - pollProcess: process => pollProcessExit(api, process), - empty: job => isJobEmpty(api, job), - terminate: (job) => { terminateJob(api, job, 1) }, - closeJob: (job) => { closeHandleChecked(api, job, 'ordinary process Job') }, - closeProcess: (process) => { closeHandleChecked(api, process, 'ordinary direct process') }, - } -} - -function releaseRunner(child: ReturnType): Error | undefined { - if (!child.connected) return undefined - try { - child.disconnect() - return undefined - } catch (error) { - try { child.kill() } catch { /* The direct process and Job remain parent-owned. */ } - return error instanceof Error ? error : new Error(String(error)) - } -} - -function stopFailedRunner(child: ReturnType): void { - if (child.connected) { - try { child.disconnect() } catch { /* A forced stop below remains authoritative. */ } - } - try { child.kill() } catch { /* The parent-owned Job remains responsible for any target. */ } -} - -function observeRunnerExit(child: ReturnType): Promise { - return new Promise((resolve) => { - child.once('error', () => { resolve() }) - child.once('exit', () => { resolve() }) - }) -} - -function observeCollectedStream( - mode: SubprocessSpawnSpec['stdio']['stdout'], - stream: Readable | null | undefined, -): Promise { - if (mode === 'pipe' || mode === 'inherit' || stream === null || stream === undefined - || stream.readableEnded || stream.destroyed) { - return Promise.resolve() - } - return new Promise((resolve) => { - const settle = (): void => { - stream.off('end', settle) - stream.off('close', settle) - stream.off('error', settle) - resolve() - } - stream.once('end', settle) - stream.once('close', settle) - stream.once('error', settle) - }) -} /** Test seams for the runner process. */ export interface WindowsJobInternals { spawn?: typeof spawn spawnSync?: typeof spawnSync runnerInvocation?: string[] - operations?: WindowsProcessOperations -} - -function observeDirectProcess( - pid: number, - operations: WindowsProcessOperations, -): Promise { - const processHandle = operations.openProcess(pid) - let closed = false - const close = (): void => { - if (closed) return - operations.closeProcess(processHandle) - closed = true - } - return new Promise((resolve, reject) => { - const poll = (): void => { - try { - const exitCode = operations.pollProcess(processHandle) - if (exitCode === undefined) { - setTimeout(poll, PROCESS_POLL_INTERVAL_MS) - return - } - close() - resolve({ exitCode, signal: null }) - } catch (error) { - try { close() } catch { /* Preserve the observation failure. */ } - reject(error instanceof Error ? error : new Error(String(error))) - } - } - poll() - }) } /** @@ -156,60 +39,42 @@ export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean { class WindowsJobOwner implements BoundProcessOwner { private stopped = false - private closed = false - private terminationRequested = false - private terminationFailure: Error | undefined - private observation: Promise | undefined + private readonly observation: Promise - constructor( - private readonly job: NativePtr, - private readonly operations: WindowsProcessOperations, - private readonly runnerExited: Promise, - ) {} + constructor(private readonly runner: ReturnType) { + this.observation = new Promise((resolve) => { + runner.once('close', () => { + this.stopped = true + resolve() + }) + }) + } signal(_signal: NodeJS.Signals): void { - if (this.stopped || this.terminationRequested) return - this.terminationRequested = true + if (this.stopped) return try { - this.operations.terminate(this.job) - } catch (error) { - this.terminationFailure = error instanceof Error ? error : new Error(String(error)) + if (this.runner.connected) { + this.runner.send({ type: 'terminate' }, (error) => { + if (error !== null) this.runner.kill() + }) + } else { + this.runner.kill() + } + } catch { + this.runner.kill() } } waitForExit(signal?: AbortSignal): Promise { - 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.runnerExited - } 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.closeJob(this.job) - this.closed = true + return this.stopped ? Promise.resolve(true) : waitWithAbort(this.observation, signal) } } /** - * Launch one direct command through a runner into a parent-owned Job. + * Launch one direct command through the Job-owning runner. * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. * @param internals - injected process runner used by tests. - * @returns parent-owned streams, target outcome, and the bound Job owner. + * @returns wrapper streams, target outcome, and the bound Job owner. */ export function launchWindowsJob( spec: SubprocessSpawnSpec, @@ -219,98 +84,22 @@ 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 operations = internals.operations ?? nativeProcessOperations() - const launchId = randomUUID() - const jobName = `Local\\dsh-subprocess-${launchId}` const files = runnerFiles(spec) - let job: NativePtr - try { - job = operations.create(jobName) - } catch (error) { - cleanupRunnerFiles(files) - throw error - } - let stdio: ReturnType - try { - stdio = createWindowsStdioBridge(spec, `\\\\.\\pipe\\dsh-subprocess-${String(process.pid)}-${launchId}`) - } catch (error) { - try { operations.closeJob(job) } catch { /* Preserve the stdio setup failure. */ } - cleanupRunnerFiles(files) - throw error - } - let child: ReturnType - try { - child = run(command, [ - ...prefix, - '--mode', - 'win32', - '--job', - jobName, - '--request', - files.requestPath, - '--events', - files.eventsPath, - ...stdio.runnerArgs, - ], { - env: childEnv(), - stdio: stdio.runnerStdio, - }) - } catch (error) { - try { operations.closeJob(job) } catch { /* Preserve the launch failure. */ } - stdio.dispose() - cleanupRunnerFiles(files) - throw error - } - const runnerExited = observeRunnerExit(child) - const closed = Promise.all([ - runnerExited, - observeCollectedStream(spec.stdio.stdout, stdio.stdout), - observeCollectedStream(spec.stdio.stderr, stdio.stderr), - ]).then(() => undefined) - const owner = new WindowsJobOwner(job, operations, runnerExited) - const start = runnerStart(child, files) - if (!start.ok) { - stopFailedRunner(child) - stdio.dispose() - const direct = Promise.resolve().then(() => { throw start.error }) - return { - stdin: stdio.stdin, - stdout: stdio.stdout, - stderr: stdio.stderr, - pid: start.pid, - direct, - closed, - owner, - } - } - // The launcher retains its original process handle until this process opens - // an independent one, preventing PID reuse during the ownership handoff. - // Windows direct settlement is owned by the handle below, not by continued - // runner event polling after the startup handoff. - let direct: Promise - try { - direct = observeDirectProcess(start.pid, operations) - } catch (error) { - direct = Promise.resolve().then(() => { throw error }) - } - const releaseFailure = releaseRunner(child) - if (releaseFailure !== undefined) { - void direct.catch(() => {}) - direct = Promise.resolve().then(() => { throw releaseFailure }) - } - void direct.then( - () => { stdio.closeInput() }, - () => { stdio.closeInput() }, - ) - void runnerExited.then(() => { cleanupRunnerFiles(files) }) - return { - stdin: stdio.stdin, - stdout: stdio.stdout, - stderr: stdio.stderr, - pid: start.pid, - direct, - closed, - owner, - } + const child = run(command, [ + ...prefix, + '--mode', + 'win32', + '--request', + files.requestPath, + '--events', + files.eventsPath, + ], { + env: childEnv(), + stdio: runnerStdio(spec, true), + }) + const closed = observeChildClose(child) + const owner = new WindowsJobOwner(child) + const result = runnerDirectResult(child, files, closed) + cleanupAfterRunner(files, result.direct, closed) + return { child, pid: result.pid, direct: result.direct, closed, owner } } diff --git a/packages/subprocess/subprocess-local/src/windows-stdio.ts b/packages/subprocess/subprocess-local/src/windows-stdio.ts deleted file mode 100644 index 912fcb8a8e..0000000000 --- a/packages/subprocess/subprocess-local/src/windows-stdio.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** Parent-owned named-pipe streams for one Windows native launch. */ - -import { createServer } from 'node:net' -import type { Server, Socket } from 'node:net' -import { PassThrough } from 'node:stream' -import type { Readable, Writable } from 'node:stream' -import type { StdioOptions } from 'node:child_process' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' - -interface PipeEndpoint { - readonly path: string - readonly stream: PassThrough - dispose(): void -} - -/** Streams and runner arguments for one Windows launch. */ -export interface WindowsStdioBridge { - readonly stdin: Writable | null - readonly stdout: Readable | null - readonly stderr: Readable | null - readonly runnerArgs: string[] - readonly runnerStdio: StdioOptions - closeInput(): void - dispose(): void -} - -function closeServer(server: Server): void { - try { - server.close() - } catch { - // A listen failure or an already-accepted connection can close first. - } -} - -function createEndpoint(path: string, direction: 'input' | 'output'): PipeEndpoint { - const stream = new PassThrough() - let socket: Socket | undefined - let disposed = false - const server = createServer({ allowHalfOpen: true }) - // Direct-result failure remains authoritative for setup errors. Keep the - // stream error observable without allowing an early server failure to become - // an unhandled process-level exception before bindManagedProcess attaches. - stream.on('error', () => {}) - server.once('error', (error) => { stream.destroy(error) }) - server.once('connection', (connection) => { - if (disposed) { - connection.destroy() - return - } - socket = connection - closeServer(server) - connection.once('error', (error) => { stream.destroy(error) }) - stream.once('close', () => { connection.destroy() }) - if (direction === 'output') { - connection.once('end', () => { connection.end() }) - connection.pipe(stream) - } else { - connection.resume() - stream.pipe(connection) - connection.once('end', () => { - stream.unpipe(connection) - connection.end() - stream.destroy() - }) - connection.once('close', () => { stream.destroy() }) - } - }) - try { - server.listen(path) - } catch (error) { - stream.destroy() - closeServer(server) - throw error - } - return { - path, - stream, - dispose() { - disposed = true - closeServer(server) - socket?.destroy() - stream.destroy() - }, - } -} - -/** - * Create private parent-owned streams whose peer handles are opened by the Windows runner. - * @param spec - target stdio dispositions. - * @param basePath - unique named-pipe base chosen by the launch owner. - * @returns public streams, runner arguments, and cleanup for pre-start failure. - */ -export function createWindowsStdioBridge( - spec: SubprocessSpawnSpec, - basePath: string, -): WindowsStdioBridge { - const endpoints: PipeEndpoint[] = [] - let stdin: PipeEndpoint | undefined - let stdout: PipeEndpoint | undefined - let stderr: PipeEndpoint | undefined - try { - if (spec.stdio.stdin !== 'ignore') { - stdin = createEndpoint(`${basePath}-stdin`, 'input') - endpoints.push(stdin) - } - if (spec.stdio.stdout !== 'inherit') { - stdout = createEndpoint(`${basePath}-stdout`, 'output') - endpoints.push(stdout) - } - if (spec.stdio.stderr !== 'inherit') { - stderr = createEndpoint(`${basePath}-stderr`, 'output') - endpoints.push(stderr) - } - } catch (error) { - for (const endpoint of endpoints) endpoint.dispose() - throw error - } - return { - stdin: stdin?.stream ?? null, - stdout: stdout?.stream ?? null, - stderr: stderr?.stream ?? null, - runnerArgs: [ - ...stdin === undefined ? [] : ['--stdin-pipe', stdin.path], - ...stdout === undefined ? [] : ['--stdout-pipe', stdout.path], - ...stderr === undefined ? [] : ['--stderr-pipe', stderr.path], - ], - runnerStdio: [ - 'ignore', - spec.stdio.stdout === 'inherit' ? 'inherit' : 'ignore', - spec.stdio.stderr === 'inherit' ? 'inherit' : 'ignore', - 'ipc', - ], - closeInput() { - stdin?.dispose() - }, - dispose() { - for (const endpoint of endpoints) endpoint.dispose() - }, - } -} diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts index c1ee1c07dc..c3971b5077 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts @@ -7,14 +7,24 @@ 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 work. if (Number.isSafeInteger(configuredExit)) { - const finish = (): void => { + setTimeout(() => { appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null }) - process.exit(0) - } - if (process.connected) process.once('disconnect', finish) - else setTimeout(finish, 10) + process.exitCode = configuredExit + }, 10) } else { - setInterval(() => {}, 1_000) + 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 = 1 + } + process.on('message', (message: unknown) => { + if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() + }) + process.on('disconnect', terminate) } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 714b82053d..ffbc047518 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -28,9 +28,6 @@ function asyncQuery(runSync: typeof spawnSync) { describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () => { it('requires a readable user manager and literal-argument systemd support', () => { - const secretName = 'DSH_SCOPE_TEST_TOKEN' - const previousSecret = process.env[secretName] - process.env[secretName] = 'secret' const calls: string[][] = [] const environments: Array = [] const runSync = vi.fn((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => { @@ -39,24 +36,18 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () return { status: 0, error: undefined } }) as unknown as typeof spawnSync const runnerInvocation = ['node-runtime', 'runner-entry.js'] - try { - expect(probeLinuxScope({ - spawnSync: runSync, - systemdRun: 'systemd-run', - systemctl: 'systemctl', - runnerInvocation, - })).toBe(true) - expect(calls[1]).toContain('--expand-environment=no') - expect(calls[1]).not.toContain('--pipe') - expect(calls[1]).not.toContain('--wait') - const separator = calls[1]?.indexOf('--') ?? -1 - expect(calls[1]?.slice(separator + 1)).toEqual([...runnerInvocation, '--mode', 'probe-node']) - expect(environments[0]?.LC_ALL).toBe('C') - expect(environments[0]).not.toHaveProperty(secretName) - } finally { - if (previousSecret === undefined) Reflect.deleteProperty(process.env, secretName) - else process.env[secretName] = previousSecret - } + expect(probeLinuxScope({ + spawnSync: runSync, + systemdRun: 'systemd-run', + systemctl: 'systemctl', + runnerInvocation, + })).toBe(true) + expect(calls[1]).toContain('--expand-environment=no') + expect(calls[1]).not.toContain('--pipe') + expect(calls[1]).not.toContain('--wait') + const separator = calls[1]?.indexOf('--') ?? -1 + expect(calls[1]?.slice(separator + 1)).toEqual([...runnerInvocation, '--mode', 'probe-node']) + expect(environments[0]?.LC_ALL).toBe('C') const oldSystemd = vi.fn((command: string) => ({ status: command === 'systemctl' ? 0 : 1, @@ -113,7 +104,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(systemdArgs).not.toContain('literal $VALUE') }) - it('still escalates after a missing-unit TERM response without fabricating a direct result', async () => { + it('still escalates after a missing-unit TERM response and uses the authoritative scope KILL', async () => { let wrapper: ReturnType | undefined const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { const separator = args.indexOf('--') @@ -142,7 +133,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }) launch.owner.signal('SIGTERM') launch.owner.signal('SIGKILL') - await expect(launch.direct).rejects.toThrow('exited without a direct-command result') + await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await expect(launch.owner.waitForExit()).resolves.toBe(true) }) @@ -264,7 +255,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }), runnerInvocation: spawnRunnerInvocation(), }) - expect(launch.pid).toBe(-1) + expect(launch.child.pid).toBeUndefined() await expect(launch.direct).rejects.toThrow('runner failed to start') await expect(launch.owner.waitForExit()).resolves.toBe(true) await launch.closed diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 37bcae6866..e7e24ca07b 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -87,41 +87,6 @@ describe('LocalSubprocessRuntime', () => { expect(process.listeners('exit')).not.toContain(listener) }) - it('observes range failure without waiting for a stuck direct result', async () => { - const before = new Set(process.listeners('exit')) - const ctx = new Context() - const disposalErrors: unknown[] = [] - ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error - const fiber = await ctx.plugin(LocalSubprocessRuntime) - const listener = process.listeners('exit').find(candidate => !before.has(candidate)) - const rangeFailure = new Error('managed range became unreadable') - const terminate = vi.fn() - const terminateForHostExit = vi.fn() - const live = (ctx.subprocess as unknown as { - live: Set<{ - done: Promise - terminate(): void - terminateForHostExit(): void - waitForExit(): Promise - }> - }).live - live.add({ - done: new Promise(() => {}), - terminate, - terminateForHostExit, - waitForExit: async () => { throw rangeFailure }, - }) - - await expect(Promise.race([ - fiber.dispose().then(() => 'disposed'), - new Promise(resolve => setTimeout(() => { resolve('timeout') }, 100)), - ])).resolves.toBe('disposed') - expect(terminate).toHaveBeenCalledOnce() - expect(terminateForHostExit).toHaveBeenCalledOnce() - expect(disposalErrors).toEqual([rangeFailure]) - expect(process.listeners('exit')).not.toContain(listener) - }) - it('contains each host-exit termination failure and continues with the other targets', async () => { const before = new Set(process.listeners('exit')) const ctx = new Context() diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 3e01cf8ca8..4ea1581fa3 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -62,9 +62,7 @@ describe('managed process binding', () => { }, } const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: 4242, direct: direct.promise, closed: observeChildClose(wrapper), @@ -88,9 +86,7 @@ describe('managed process binding', () => { const wrapper = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: ['ignore', 'pipe', 'pipe'] }) const signal = vi.fn() const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: 4242, direct: Promise.resolve({ exitCode: 0, signal: null }), closed: observeChildClose(wrapper), @@ -106,9 +102,7 @@ describe('managed process binding', () => { }) const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct: direct.promise, closed: Promise.resolve(), @@ -132,9 +126,7 @@ describe('managed process binding', () => { ...spec(1_000), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, }, { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct: direct.promise, closed: new Promise(() => {}), @@ -158,9 +150,7 @@ describe('managed process binding', () => { }) const failure = new Error('range observation failed') const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct: new Promise(() => {}), closed: new Promise(() => {}), @@ -183,9 +173,7 @@ describe('managed process binding', () => { const direct = Promise.resolve().then(() => { throw rejection }) const signal = vi.fn() const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct, closed: new Promise(() => {}), @@ -194,8 +182,6 @@ describe('managed process binding', () => { try { await expect(handle.done).rejects.toThrow('runner failed') expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM') - expect(wrapper.stdout?.destroyed).toBe(true) - expect(wrapper.stderr?.destroyed).toBe(true) } finally { wrapper.kill('SIGKILL') } @@ -214,9 +200,7 @@ describe('managed process binding', () => { }) const controller = new AbortController() const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct: direct.promise, closed: Promise.resolve(), diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index ab28d1affc..c89b46581a 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -1,5 +1,5 @@ import { spawn, spawnSync } from 'node:child_process' -import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' @@ -55,16 +55,14 @@ function cleanup(pid: number): void { spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) } -type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } - -function directSpawnFailure(argv: string[], cwd = scratch): Promise { +function directSpawnFailure(argv: string[]): Promise { return new Promise((resolve, reject) => { try { - const child = spawn(argv[0] as string, argv.slice(1), { cwd, stdio: 'ignore' }) + const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' }) child.once('error', resolve) child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) }) } catch (error) { - resolve(error as SpawnFailure) + resolve(error as NodeJS.ErrnoException) } }) } @@ -72,60 +70,6 @@ function directSpawnFailure(argv: string[], cwd = scratch): Promise { - it('keeps raw stdin writable after the launcher handoff', async () => { - const output = join(scratch, `stdin-${Date.now()}.txt`) - const script = ` - const { writeFileSync } = require('node:fs') - let input = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => { input += chunk }) - process.stdin.on('end', () => { writeFileSync(${JSON.stringify(output)}, input) }) - ` - const request = { - ...spec([process.execPath, '-e', script]), - stdio: { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' } as const, - } - const handle = bindManagedProcess(request, launchWindowsJob(request)) - if (handle.stdin === undefined) throw new Error('expected piped stdin') - await new Promise(resolve => setTimeout(resolve, 100)) - await new Promise((resolve, reject) => { - handle.stdin?.end('after-handoff', (error?: Error | null) => { - if (error !== undefined && error !== null) reject(error) - else resolve() - }) - }) - await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(handle.waitForExit()).resolves.toBe(true) - expect(readFileSync(output, 'utf8')).toBe('after-handoff') - }) - - it('releases raw stdout when the target closes it before exiting', async () => { - const request = { - ...spec([process.execPath, '-e', 'process.stdout.end(); setInterval(() => {}, 1000)']), - stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' } as const, - } - const handle = bindManagedProcess(request, launchWindowsJob(request)) - if (handle.stdout === undefined) throw new Error('expected piped stdout') - const stdoutEnded = new Promise((resolve, reject) => { - handle.stdout?.once('end', resolve) - handle.stdout?.once('error', reject) - }) - handle.stdout.resume() - let directSettled = false - void handle.done.then( - () => { directSettled = true }, - () => { directSettled = true }, - ) - await expect(Promise.race([ - stdoutEnded.then(() => true), - new Promise(resolve => setTimeout(() => { resolve(false) }, 5_000)), - ])).resolves.toBe(true) - expect(directSettled).toBe(false) - handle.terminate() - await handle.done - await expect(handle.waitForExit()).resolves.toBe(true) - }) - it('terminates the direct target and its default-inheritance descendant', async () => { const pidFile = join(scratch, `job-child-${Date.now()}.pid`) const script = ` @@ -170,14 +114,12 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { handle.stdout?.once('end', resolve) handle.stdout?.once('error', reject) }) - // A Readable reports `end` only after the consumer drains any buffered bytes. - handle.stdout.resume() const descendant = await waitForPid(pidFile) try { await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) await expect(Promise.race([ stdoutEnded.then(() => true), - new Promise(resolve => setTimeout(() => { resolve(false) }, 5_000)), + new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), ])).resolves.toBe(true) expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({ cwd: scratch, @@ -194,30 +136,9 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { }) it('preserves missing-target and invalid-executable rejection errors', async () => { - const relativeExecutable = `relative-node-${String(Date.now())}.exe` - copyFileSync(process.execPath, join(scratch, relativeExecutable)) - const relative = spec([relativeExecutable, '-e', 'process.exit(17)']) - const relativeHandle = bindManagedProcess(relative, launchWindowsJob(relative)) - await expect(relativeHandle.done).resolves.toEqual({ exitCode: 17, signal: null }) - await expect(relativeHandle.waitForExit()).resolves.toBe(true) - const missing = spec([`missing-native-target-${Date.now()}.exe`]) const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing)) await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) - await expect(missingHandle.waitForExit()).resolves.toBe(true) - - const missingCwd = join(scratch, `missing-cwd-${Date.now()}`) - const cwdArgv = [process.execPath, '-e', 'process.exit(0)'] - const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd) - const invalidCwd = { ...spec(cwdArgv), cwd: missingCwd } - const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd)) - await expect(invalidCwdHandle.done).rejects.toMatchObject({ - code: expectedCwd.code, - syscall: expectedCwd.syscall, - path: expectedCwd.path, - spawnargs: expectedCwd.spawnargs, - }) - await expect(invalidCwdHandle.waitForExit()).resolves.toBe(true) const invalidExecutable = join(scratch, `direct-${Date.now()}.exe`) writeFileSync(invalidExecutable, 'not a Windows executable\r\n') @@ -225,6 +146,5 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const invalid = spec([invalidExecutable]) const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid)) await expect(invalidHandle.done).rejects.toMatchObject({ code: directError.code }) - await expect(invalidHandle.waitForExit()).resolves.toBe(true) }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 335dbd4cda..c54d831c43 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,7 +1,6 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' @@ -85,11 +84,11 @@ describe('spawn runner transport', () => { expect(result.status).toBe(0) }) - it('maps every target stdio disposition', () => { + it('maps every target stdio disposition and optional IPC channel', () => { expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) expect(runnerStdio(spec({ stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' }, - }))).toEqual(['pipe', 'inherit', 'inherit']) + }), true)).toEqual(['pipe', 'inherit', 'inherit', 'ipc']) }) it('materializes and consumes the exact runner request once', () => { @@ -110,35 +109,6 @@ describe('spawn runner transport', () => { } }) - it('unlinks a substituted runner-directory link without traversing it', () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - const outside = mkdtempSync(join(tmpdir(), 'dsh-runner-outside-')) - const sentinel = join(outside, 'events.ndjson') - writeFileSync(sentinel, 'keep') - rmSync(files.directory, { recursive: true, force: true }) - symlinkSync(outside, files.directory, process.platform === 'win32' ? 'junction' : 'dir') - try { - cleanupRunnerFiles(files) - expect(existsSync(files.directory)).toBe(false) - expect(existsSync(sentinel)).toBe(true) - } finally { - rmSync(files.directory, { recursive: true, force: true }) - rmSync(outside, { recursive: true, force: true }) - } - }) - - it('contains an unexpected owned-path cleanup failure', () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - rmSync(files.requestPath, { force: true }) - mkdirSync(files.requestPath) - try { - expect(() => { cleanupRunnerFiles(files) }).not.toThrow() - expect(existsSync(files.directory)).toBe(true) - } finally { - rmSync(files.directory, { recursive: true, force: true }) - } - }) - it.each([ ['non-object request', null, 'no executable'], ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'], @@ -290,20 +260,6 @@ describe('spawn runner transport', () => { cleanupRunnerFiles(runnerFailure) } - const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 }) - appendRunnerEvent(afterStartFailure.eventsPath, { - type: 'runner-error', - error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' }, - }) - const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise(() => {})) - expect(result.pid).toBe(456) - await expect(result.direct).rejects.toMatchObject({ message: 'post-start runner failed', code: 'EIO' }) - } finally { - cleanupRunnerFiles(afterStartFailure) - } - const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 }) @@ -314,6 +270,19 @@ describe('spawn runner transport', () => { cleanupRunnerFiles(missing) } + const forced = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + appendRunnerEvent(forced.eventsPath, { type: 'started', pid: 789 }) + const result = runnerDirectResult( + fakeChild(123), + forced, + Promise.resolve(), + () => ({ exitCode: null, signal: 'SIGKILL' }), + ) + await expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) + } finally { + cleanupRunnerFiles(forced) + } }) it('requires an event snapshot started after wrapper close before reporting a missing result', async () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index c125393115..0d69a52331 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -4,10 +4,8 @@ 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 { WindowsProcessOperations } from '../src/windows-job.ts' const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url)) const invocation = [process.execPath, '--import', 'tsx/esm', fixture] @@ -16,53 +14,11 @@ function spec(argv: string[]): SubprocessSpawnSpec { return { argv, cwd: process.cwd(), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 100, } } -function fakeRunner(pid: number): { child: ChildProcess; disconnect: ReturnType } { - const child = new EventEmitter() as ChildProcess - const disconnect = vi.fn(() => { - Object.assign(child, { connected: false }) - queueMicrotask(() => { - child.emit('exit', 0, null) - child.emit('close', 0, null) - }) - }) - Object.assign(child, { pid, connected: true, disconnect, kill: vi.fn(() => true) }) - return { child, disconnect } -} - -function processOperations(overrides: Partial = {}): { - operations: WindowsProcessOperations - create: ReturnType - openProcess: ReturnType - pollProcess: ReturnType - empty: ReturnType - terminate: ReturnType - closeJob: ReturnType - closeProcess: ReturnType -} { - const create = vi.fn(() => 50n as NativePtr) - const openProcess = vi.fn(() => 60n as NativePtr) - const pollProcess = vi.fn(() => 0) - const empty = vi.fn(() => true) - const terminate = vi.fn() - const closeJob = vi.fn() - const closeProcess = vi.fn() - return { - operations: { create, openProcess, pollProcess, empty, terminate, closeJob, closeProcess, ...overrides }, - create, - openProcess, - pollProcess, - empty, - terminate, - closeJob, - closeProcess, - } -} - 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 @@ -84,264 +40,126 @@ describe('Windows Job runner adapter', () => { }) it('reports direct outcome separately from runner settlement', async () => { - const jobs = processOperations({ pollProcess: vi.fn(() => 7) }) - const request = { - ...spec(['fake-target', '7']), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' } as const, - } - const launch = launchWindowsJob(request, { + const launch = launchWindowsJob(spec(['fake-target', '7']), { spawn, runnerInvocation: invocation, - operations: jobs.operations, }) expect(launch.pid).toBeGreaterThan(0) await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(jobs.create).toHaveBeenCalledOnce() - expect(jobs.create.mock.calls[0]?.[0]).toMatch(/^Local\\dsh-subprocess-/u) - expect(jobs.openProcess).toHaveBeenCalledWith(launch.pid) - expect(jobs.closeProcess).toHaveBeenCalledExactlyOnceWith(60n) - expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) }) - it('signals and waits through the parent-owned Job', async () => { - const { child, disconnect } = fakeRunner(321) - let eventsPath = '' - const state = { empty: false, exitCode: undefined as number | undefined } - const terminate = vi.fn(() => { - state.empty = true - state.exitCode = 1 - }) - const jobs = processOperations({ - pollProcess: vi.fn(() => state.exitCode), - empty: vi.fn(() => state.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 + it('signals the Job runner and waits for its managed range to stop', async () => { const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, + spawn, + runnerInvocation: invocation, }) launch.owner.signal('SIGTERM') await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) launch.owner.signal('SIGKILL') - expect(disconnect).toHaveBeenCalledOnce() - expect(terminate).toHaveBeenCalledExactlyOnceWith(50n) - expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) }) - it('does not treat runner exit as proof that the Job is empty', async () => { - const { child, disconnect } = fakeRunner(432) - let eventsPath = '' - const state = { empty: false, exitCode: undefined as number | undefined } - const jobs = processOperations({ - pollProcess: vi.fn(() => state.exitCode), - empty: vi.fn(() => state.empty), - }) - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 432 }) - return child - }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.owner.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false) - state.exitCode = 0 - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - state.empty = true - await expect(launch.owner.waitForExit()).resolves.toBe(true) - launch.owner.signal('SIGKILL') - expect(disconnect).toHaveBeenCalledOnce() - expect(jobs.terminate).not.toHaveBeenCalled() - }) + it('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'] }) - it('reports Job termination failures through waitForExit', async () => { - const { child } = fakeRunner(654) + 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) + } + + 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 }) 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 failure = new Error('TerminateJobObject failed') - const jobs = processOperations({ empty: vi.fn(() => false), terminate: vi.fn(() => { throw failure }) }) - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - void launch.direct.catch(() => {}) + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) launch.owner.signal('SIGTERM') - await expect(launch.owner.waitForExit()).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).rejects.toBe(failure) - expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) + expect(send).toHaveBeenCalledOnce() + expect(kill).not.toHaveBeenCalled() + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await launch.direct + await launch.owner.waitForExit() }) - it('keeps a Job observation failure visible on repeated waits', async () => { - const { child } = fakeRunner(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 = processOperations({ empty: vi.fn(() => { throw failure }) }) - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - 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.closeJob).toHaveBeenCalledExactlyOnceWith(50n) - }) - - it('reports direct-process observation failure and closes its handle', async () => { - const { child } = fakeRunner(656) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 656 }) - return child - }) as unknown as typeof spawn - const failure = new Error('WaitForSingleObject failed') - const jobs = processOperations({ pollProcess: vi.fn(() => { throw failure }) }) - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.direct).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(jobs.closeProcess).toHaveBeenCalledExactlyOnceWith(60n) - }) - - it('releases the runner when the parent cannot open the direct process', async () => { - const { child, disconnect } = fakeRunner(659) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 659 }) - return child - }) as unknown as typeof spawn - const failure = new Error('OpenProcess failed') - const jobs = processOperations({ openProcess: vi.fn(() => { throw failure }) }) - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.direct).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(disconnect).toHaveBeenCalledOnce() - expect(jobs.closeProcess).not.toHaveBeenCalled() - }) - - it('reports a failed runner release after acquiring direct-process observation', async () => { - const child = new EventEmitter() as ChildProcess - const failure = new Error('IPC disconnect failed') - const kill = vi.fn(() => { - queueMicrotask(() => { child.emit('exit', 0, null) }) - return true - }) - Object.assign(child, { - pid: 657, - connected: true, - disconnect: vi.fn(() => { throw failure }), - kill, - }) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 657 }) - return child - }) as unknown as typeof spawn - const jobs = processOperations() - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.direct).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(kill).toHaveBeenCalledOnce() - }) - - it('closes the parent Job when spawning the runner throws synchronously', () => { - const failure = new Error('runner spawn failed') - const jobs = processOperations() - expect(() => launchWindowsJob(spec(['fake-target']), { - spawn: vi.fn(() => { throw failure }) as unknown as typeof spawn, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - })).toThrow(failure) - expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) - }) - - it('force-stops a runner that times out before publishing target startup', async () => { - const child = new EventEmitter() as ChildProcess - const disconnect = vi.fn(() => { Object.assign(child, { connected: false }) }) - const kill = vi.fn(() => { - queueMicrotask(() => { child.emit('exit', 1, null) }) - return true - }) - Object.assign(child, { pid: process.pid, exitCode: null, signalCode: null, connected: true, disconnect, kill }) - const jobs = processOperations() - const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001) - try { - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: vi.fn(() => child) as unknown as typeof spawn, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.direct).rejects.toThrow('did not report target start') - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(disconnect).toHaveBeenCalledOnce() - expect(kill).toHaveBeenCalledOnce() - } finally { - now.mockRestore() - } - }) - - it('passes a generated Job name to the runner and rejects an empty invocation', async () => { - const emptyJobs = processOperations() - expect(() => launchWindowsJob(spec(['fake-target']), { - runnerInvocation: [], - operations: emptyJobs.operations, - })) + it('uses production runner defaults and rejects an empty invocation', async () => { + expect(() => launchWindowsJob(spec(['fake-target']), { runnerInvocation: [] })) .toThrow('Windows runner invocation is empty') - expect(emptyJobs.create).not.toHaveBeenCalled() - const { child, disconnect } = fakeRunner(987) + const child = new EventEmitter() as ChildProcess + Object.assign(child, { + pid: 987, + connected: true, + kill: vi.fn(() => true), + send: vi.fn(), + }) 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 - }) as unknown as typeof spawn - const jobs = processOperations() - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, }) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(disconnect).toHaveBeenCalledOnce() - expect(jobName).toMatch(/^Local\\dsh-subprocess-/u) - expect(jobs.create).toHaveBeenCalledExactlyOnceWith(jobName) + const runSync = vi.fn(() => ({ status: 0, error: undefined })) + vi.resetModules() + vi.doMock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + 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() + } }) }) diff --git a/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts b/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts deleted file mode 100644 index 99df24b0d6..0000000000 --- a/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { spawnSync } from 'node:child_process' -import { once } from 'node:events' -import { connect } from 'node:net' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { createWindowsStdioBridge } from '../src/windows-stdio.ts' - -function pipeBase(): string { - return process.platform === 'win32' - ? `\\\\.\\pipe\\dsh-windows-stdio-test-${randomUUID()}` - : join('/tmp', `dsh-windows-stdio-${randomUUID()}`) -} - -function spec(): SubprocessSpawnSpec { - return { - argv: ['target'], - cwd: process.cwd(), - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1024 } }, - graceMs: 100, - } -} - -function pathAfter(args: readonly string[], key: string): string { - const path = args[args.indexOf(key) + 1] - if (path === undefined) throw new Error(`missing ${key}`) - return path -} - -describe('Windows parent-owned stdio bridge', () => { - it('binds before returning so a synchronously launched peer can connect', async () => { - const bridge = createWindowsStdioBridge({ - ...spec(), - stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' }, - }, pipeBase()) - const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') - const result = spawnSync(process.execPath, ['-e', ` - const { connect } = require('node:net') - const socket = connect(${JSON.stringify(stdoutPath)}) - socket.once('connect', () => { - socket.write('blocked-parent', () => { - socket.destroy() - process.exit(0) - }) - }) - socket.once('error', () => { process.exit(1) }) - setTimeout(() => { process.exit(2) }, 2000) - `], { timeout: 5_000 }) - expect(result.status).toBe(0) - - const chunks: Buffer[] = [] - bridge.stdout?.on('data', (chunk: Buffer) => { chunks.push(chunk) }) - await once(bridge.stdout as NodeJS.ReadableStream, 'end') - expect(Buffer.concat(chunks).toString()).toBe('blocked-parent') - bridge.dispose() - }) - - it('moves bytes in both directions and ends output with its target-side peer', async () => { - const bridge = createWindowsStdioBridge(spec(), pipeBase()) - const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') - const stderrPath = pathAfter(bridge.runnerArgs, '--stderr-pipe') - const stdinPath = pathAfter(bridge.runnerArgs, '--stdin-pipe') - expect(bridge.runnerStdio).toEqual(['ignore', 'ignore', 'ignore', 'ipc']) - await new Promise(resolve => setImmediate(resolve)) - - bridge.stdin?.end('in') - const stdoutPeer = connect(stdoutPath) - const stderrPeer = connect(stderrPath) - const stdinPeer = connect(stdinPath) - await Promise.all([once(stdoutPeer, 'connect'), once(stderrPeer, 'connect'), once(stdinPeer, 'connect')]) - - const stdoutChunks: Buffer[] = [] - const stderrChunks: Buffer[] = [] - const stdinChunks: Buffer[] = [] - bridge.stdout?.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk) }) - bridge.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk) }) - stdinPeer.on('data', (chunk: Buffer) => { stdinChunks.push(chunk) }) - const stdoutEnded = once(bridge.stdout as NodeJS.ReadableStream, 'end') - const stderrEnded = once(bridge.stderr as NodeJS.ReadableStream, 'end') - const stdinEnded = once(stdinPeer, 'end') - - stdoutPeer.end('out') - stderrPeer.end('err') - await Promise.all([stdoutEnded, stderrEnded, stdinEnded]) - - expect(Buffer.concat(stdoutChunks).toString()).toBe('out') - expect(Buffer.concat(stderrChunks).toString()).toBe('err') - expect(Buffer.concat(stdinChunks).toString()).toBe('in') - bridge.dispose() - }) - - it('uses inherited output directly and disposes unconnected endpoints', () => { - const inherited = createWindowsStdioBridge({ - ...spec(), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, - }, pipeBase()) - expect(inherited.stdin).toBeNull() - expect(inherited.stdout).toBeNull() - expect(inherited.stderr).toBeNull() - expect(inherited.runnerArgs).toEqual([]) - expect(inherited.runnerStdio).toEqual(['ignore', 'inherit', 'inherit', 'ipc']) - inherited.dispose() - - const pending = createWindowsStdioBridge(spec(), pipeBase()) - pending.closeInput() - expect(pending.stdin?.destroyed).toBe(true) - pending.dispose() - expect(pending.stdout?.destroyed).toBe(true) - expect(pending.stderr?.destroyed).toBe(true) - }) -}) diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index decbfdc80f..398e87e81c 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md -README.md: 20f2edea5d29e152ae01a968b81cbc79e9bb69e5 -README.zh.md: ea4801de670a90b6b158830a7f8b19ea936c6e5c +README.md: 61aec9024427a6530f4e877c2fd35cdf301af169 +README.zh.md: 4740bef7062162f444cdf7b713ed4a5c52fc02be diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 20f2edea5d..61aec90244 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination and waiting use one provider-managed range. `terminate()` — the only termination verb — starts the provider's documented procedure (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so consumer teardown can await real quiescence. Staged providers may use `graceMs` between graceful and forced steps; immediate providers do not delay. Each provider documents how it defines, signals, and observes the range, including weaker fallbacks; the [local provider](../subprocess-local/README.md) owns its systemd, Job, process-group, and `taskkill` details. The wait rejects when a selected owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). +- Termination and waiting use one provider-managed range. `terminate()` — the only termination verb — starts the provider's documented procedure (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so a consumer-owned teardown ladder holds each tier on real quiescence. Staged providers may use `graceMs` between graceful and forced steps; immediate providers do not delay. Each provider documents how it defines, signals, and observes the range, including weaker fallbacks; the [local provider](../subprocess-local/README.md) owns its systemd, Job, process-group, and `taskkill` details. The wait rejects when a selected owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches quiescence for every session member the provider can still observe and settles in-flight handle calls; providers document substrate-specific observability limits. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members; readiness, scrollback, and owner policy remain in the PTY consumer. - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index ea4801de67..4740bef706 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止与等待使用同一个 provider-managed range。`terminate()`(唯一的终止动词)启动 provider 记录的终止过程(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方拆卸可以等待真正完全停稳。分阶段 provider 可以用 `graceMs` 分隔温和与强制步骤,立即终止的 provider 不会等待。每个 provider 记录该范围的定义、信号与观察方式,包括较弱 fallback;[本地 provider](../subprocess-local/README.zh.md)拥有 systemd、Job、进程组与 `taskkill` 的具体说明。所选 owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 +- 终止与等待使用同一个 provider-managed range。`terminate()`(唯一的终止动词)启动 provider 记录的终止过程(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。分阶段 provider 可以用 `graceMs` 分隔温和与强制步骤,立即终止的 provider 不会等待。每个 provider 记录该范围的定义、信号与观察方式,包括较弱 fallback;[本地 provider](../subprocess-local/README.zh.md)拥有 systemd、Job、进程组与 `taskkill` 的具体说明。所选 owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,并结算在途句柄调用;提供方会记录执行基底特有的可观察性限制。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出时,输出流在已排队输出之后结束;仍处于活动状态的传输若发生故障,会使 `done` 拒绝。这些操作保留为一项执行基底原语,因为普通管道无法分配控制终端或清理终端会话成员;就绪状态、scrollback 和所有者策略仍归 PTY 消费方所有。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index c6a452bbec..8fc9773df7 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -90,9 +90,9 @@ declare module '@deepseek-ai/cordis' { * streams are handed to the caller raw and never buffered here. * - {@link SubprocessHandle.terminate} (and the spec's abort signal) starts the * provider's documented procedure against its managed range. - * {@link SubprocessHandle.waitForExit} observes that same range so consumer - * teardown can await real quiescence; each provider documents its identity, - * signalling, and observability limits. + * {@link SubprocessHandle.waitForExit} observes that same range so a + * consumer-owned teardown ladder can hold each tier on real quiescence; each + * provider documents its identity, signalling, and observability limits. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. * - {@link spawnTerminal} owns terminal allocation, text transport, diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 0ef3f5f981..c2b43f919f 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -89,9 +89,9 @@ export interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the provider's termination procedure on the managed - * range when it fires. The caller owns deadlines and cause classification; - * this seam only reacts to the abort. + * Abort signal — starts the terminate escalation on the managed range when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. */ signal?: AbortSignal | undefined /** diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 5c24705011..a8b66f662e 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/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: 5d2b6dc0f669fb14962183e6d761f80ef9ca7e2b -README.zh.md: 9799c781ec1362631801145f9b3ff3cdb096ed35 +README.md: 5d14ead5a8d9b6b5d00ee298f274a3d4a1a9aae8 +README.zh.md: faa2dc829db4e4772384bb8a58ca56cebb12dd5c diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 5d2b6dc0f6..5d14ead5a8 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job and private named-pipe endpoints, the runner opens their target-side handles, and `spawnOrdinaryProcessInJob()` applies explicit stdio, suspended creation, Job assignment, and resume through `CreateProcessW`. Before releasing the runner, the parent opens a separate process handle and polls its zero-time state for the direct result; Job accounting and parent-owned streams then continue independently until their own OS lifetimes end. -- **Explicit settlement ownership** — `waitForProcessExit()` waits for and closes a sandbox process handle; ordinary parent-side process polling and Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. +- **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. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 9799c781ec..faa2dc829d 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,8 +10,8 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job 与 private named-pipe endpoint,runner 打开它们的 target 端 handle,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用显式 stdio、suspended-create、Job-assignment 与 resume 生命周期。释放 runner 前,parent 会打开另一个 process handle,并轮询其 zero-time state 得到 direct result;Job accounting 与 parent-owned stream 随后各自持续到对应 OS 生命周期结束。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary parent-side process polling 与 Job accounting、termination、closure 保持独立。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 +- **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。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index d762021788..d3b2eafcb4 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,20 +6,10 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF -/** CreateProcess flag that prevents user code from running before resume. */ -export const CREATE_SUSPENDED = 0x4 /** WaitForSingleObject returned because a zero-time probe is not signalled. */ export const WAIT_TIMEOUT = 258 -/** OpenProcess right required to read limited process information. */ -export const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 -/** Standard right required to wait on a process handle. */ -export const SYNCHRONIZE = 0x00100000 -/** Read access requested for a private named-pipe client handle. */ -export const GENERIC_READ = 0x80000000 -/** Write access requested for a private named-pipe client handle. */ -export const GENERIC_WRITE = 0x40000000 -/** Open an existing named-pipe endpoint. */ -export const OPEN_EXISTING = 3 +/** CreateProcess flag that prevents user code from running before resume. */ +export const CREATE_SUSPENDED = 0x4 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ @@ -38,8 +28,6 @@ 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. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index d2f3bf1e85..b1adeff700 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -53,11 +53,10 @@ export interface ProcessInfoOutput { dwThreadId: number } -/** Generic Win32 calls consumed by sandbox and ordinary process operations. */ +/** Generic Win32 calls consumed by restricted-token sandbox process operations. */ export interface Win32ProcessBindings { closeHandle(handle: NativePtr): number getLastError(): number - openProcess(desiredAccess: number, inheritHandle: number, processId: number): NativePtr formatMessageW( flags: number, source: null, @@ -68,15 +67,6 @@ export interface Win32ProcessBindings { args: null, ): number createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number - createFileW( - path: string, - desiredAccess: number, - shareMode: number, - securityAttributes: null, - creationDisposition: number, - flagsAndAttributes: number, - templateFile: null, - ): NativePtr setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( token: NativePtr, @@ -114,8 +104,7 @@ export interface Win32ProcessBindings { ): number waitForSingleObject(handle: NativePtr, milliseconds: number): number getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number - createJobObjectW(attributes: null, name: string | null): NativePtr - openJobObjectW(desiredAccess: number, inheritHandle: number, name: string): NativePtr + createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number queryInformationJobObject( job: NativePtr, @@ -132,7 +121,7 @@ export interface Win32ProcessBindings { } /** Koffi STARTUPINFOW layout. */ -export const STARTUPINFOW = koffi.struct({ +export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', { cb: 'uint32', lpReserved: 'str16', lpDesktop: 'str16', @@ -154,7 +143,7 @@ export const STARTUPINFOW = koffi.struct({ }) /** Koffi PROCESS_INFORMATION layout. */ -export const PROCESS_INFORMATION = koffi.struct({ +export const PROCESS_INFORMATION = koffi.struct('DSH_PROCESS_INFORMATION', { hProcess: PVOID, hThread: PVOID, dwProcessId: 'uint32', @@ -263,14 +252,10 @@ function bindings(): Win32ProcessBindings { cached = { closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), getLastError: bind(kernel32, 'GetLastError', 'uint32', []), - openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', [ 'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, ]), createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), - createFileW: bind(kernel32, 'CreateFileW', PVOID, [ - 'str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID, - ]), setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', @@ -287,7 +272,6 @@ 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, diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index 45a81f2beb..b27a72690c 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -1,4 +1,4 @@ -/** Low-level Win32 process, stdio, and Job Object primitives shared by sandbox and ordinary subprocess paths. */ +/** Low-level Win32 process, stdio, and Job Object primitives used by the Windows ACL sandbox. */ export { ERROR_INSUFFICIENT_BUFFER } from './abi.ts' export * from './errors.ts' @@ -19,23 +19,17 @@ export type { } from './ffi.ts' export { closeHandleChecked, - createKillOnCloseJob, drainPipe, isJobEmpty, - openNamedPipeForStdio, - openJobForAssignment, - openProcessForWait, pollProcessExit, spawnInheritedJobProcess, - spawnOrdinaryProcessInJob, + spawnOrdinaryJobProcess, spawnPipedProcess, terminateJob, waitForProcessExit, } from './process.ts' export type { - ChildStdioHandles, OrdinaryProcessSpawnOptions, - SpawnedAssignedProcess, SpawnedJobProcess, SpawnedPipedProcess, } from './process.ts' diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 7b91efbef8..76260b5f4f 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -63,13 +63,6 @@ export interface OrdinaryProcessSpawnOptions { cwd: string } -/** Optional explicit target standard handles; omitted entries use the caller's standard handle. */ -export interface ChildStdioHandles { - stdin?: NativePtr - stdout?: NativePtr - stderr?: NativePtr -} - /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ export interface RestrictedProcessSpawnOptions extends OrdinaryProcessSpawnOptions { /** Restricted primary token supplied by sandbox policy. */ @@ -98,14 +91,6 @@ 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 @@ -314,14 +299,8 @@ export function waitForProcessExit(api: Win32ProcessBindings, process: NativePtr } } -/** - * 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) +function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { + const job = api.createJobObjectW(null, null) if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) information.writeUInt32LE( @@ -341,70 +320,24 @@ export function createKillOnCloseJob(api: Win32ProcessBindings, name: string | n 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 -} - -/** - * Open one private named-pipe client for target stdio. - * @param api - active binding table. - * @param path - unique parent-owned named-pipe path. - * @returns caller-owned connected pipe handle. - */ -export function openNamedPipeForStdio(api: Win32ProcessBindings, path: string): NativePtr { - const handle = api.createFileW( - path, - abi.GENERIC_READ + abi.GENERIC_WRITE, - 0, - null, - abi.OPEN_EXISTING, - 0, - null, - ) - if (isNullPtr(handle) || (handle as bigint) === -1n || (handle as bigint) === 0xFFFFFFFFFFFFFFFFn) { - throwLastError(api, 'CreateFileW', path) - } - return handle -} - -/** - * Open a process for non-blocking exit observation. - * @param api - active binding table. - * @param pid - direct process id published by the launcher. - * @returns caller-owned process handle with query and synchronize access. - */ -export function openProcessForWait(api: Win32ProcessBindings, pid: number): NativePtr { - const process = api.openProcess(abi.PROCESS_QUERY_LIMITED_INFORMATION | abi.SYNCHRONIZE, 0, pid) - if (isNullPtr(process)) throwLastError(api, 'OpenProcess', `pid ${String(pid)}`) - return process -} - /** Shared suspended-create, Job-assignment, and resume lifecycle. */ function spawnJobProcess( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, - job: NativePtr, - stdio: ChildStdioHandles, createName: 'CreateProcessAsUserW' | 'CreateProcessW', create: (startupInfo: NativePtr, processInfo: NativePtr) => number, -): SpawnedAssignedProcess { +): SpawnedJobProcess { + const job = createKillOnCloseJob(api) 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 = stdio.stdin ?? getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') - const stdOut = stdio.stdout ?? getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') - const stdErr = stdio.stderr ?? getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') + const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') + const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') + const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined @@ -434,6 +367,7 @@ function spawnJobProcess( if (created === 0) createFailureCode = api.getLastError() } catch (error) { freeNative(processInfo) + api.closeHandle(job) throw error } finally { freeNative(startupInfo) @@ -444,6 +378,7 @@ function spawnJobProcess( } if (created === 0) { freeNative(processInfo) + api.closeHandle(job) throwWin32( api, createName, @@ -459,6 +394,7 @@ 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})`) @@ -468,17 +404,18 @@ 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 } + return { pid: info.dwProcessId, process: info.hProcess, job } } /** @@ -495,43 +432,30 @@ export function spawnInheritedJobProcess( api: Win32ProcessBindings, options: RestrictedProcessSpawnOptions, ): SpawnedJobProcess { - const job = createKillOnCloseJob(api) const commandLine = buildCommandLine(options.command, options.args) - 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 - } + return spawnJobProcess(api, options, 'CreateProcessAsUserW', (startupInfo, processInfo) => + createRestrictedProcess( + api, + options, + commandLine, + abi.CREATE_SUSPENDED, + startupInfo, + processInfo, + )) } /** - * Spawn an ordinary process suspended, assign a caller-owned Job, then resume it. + * Spawn an ordinary process suspended, assign its Job, then resume it. * @param api - active binding table. * @param options - command, cwd, and argv. - * @param job - caller-owned Job handle that remains open after this call. - * @param stdio - optional explicit handles opened for this target. - * @returns caller-owned process handle after successful resume. + * @returns caller-owned process and Job handles after successful resume. */ -export function spawnOrdinaryProcessInJob( +export function spawnOrdinaryJobProcess( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, - job: NativePtr, - stdio: ChildStdioHandles = {}, -): SpawnedAssignedProcess { +): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, job, stdio, 'CreateProcessW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) => api.createProcessW( null, commandLine, @@ -547,7 +471,7 @@ export function spawnOrdinaryProcessInJob( } /** - * Poll one process handle without blocking the caller event loop. + * Poll one process handle without blocking the runner event loop. * @param api - active binding table. * @param process - caller-owned process handle. * @returns the direct exit code when signalled, or undefined while running. diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 6529b840d0..3c8f182e27 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -2,38 +2,25 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' import { closeHandleChecked, - createKillOnCloseJob, isJobEmpty, - openNamedPipeForStdio, - openJobForAssignment, - openProcessForWait, pollProcessExit, - spawnOrdinaryProcessInJob, + spawnOrdinaryJobProcess, terminateJob, Win32Error, } from '../src/index.ts' import { CREATE_SUSPENDED, - GENERIC_READ, - GENERIC_WRITE, - JOB_OBJECT_ASSIGN_PROCESS, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, - OPEN_EXISTING, - PROCESS_QUERY_LIMITED_INFORMATION, - SYNCHRONIZE, WAIT_TIMEOUT, } from '../src/abi.ts' -import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts' +import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' function api(overrides: Partial = {}): Win32ProcessBindings { return { createJobObjectW: vi.fn(() => 50n), - openJobObjectW: vi.fn(() => 55n), - openProcess: vi.fn(() => 60n), - createFileW: vi.fn(() => 70n), setInformationJobObject: vi.fn(() => 1), queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) @@ -69,7 +56,6 @@ function api(overrides: Partial = {}): 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, @@ -87,19 +73,16 @@ 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 }), }) - const job = createKillOnCloseJob(bindings, 'Local\\test-job') - expect(spawnOrdinaryProcessInJob(bindings, { + expect(spawnOrdinaryJobProcess(bindings, { command: 'probe.exe', args: ['literal $VALUE', 'a b'], cwd: 'C:\\work', - }, job)).toEqual({ pid: 1234, process: 60n }) - expect(createJobObjectW).toHaveBeenCalledWith(null, 'Local\\test-job') + })).toEqual({ pid: 1234, process: 60n, job: 50n }) expect(createProcessW).toHaveBeenCalledWith( null, 'probe.exe "literal $VALUE" "a b"', @@ -121,62 +104,14 @@ describe('ordinary Job process operations', () => { const bindings = api({ createProcessW: vi.fn(() => 0) }) let caught: unknown try { - spawnOrdinaryProcessInJob(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }, 50n as NativePtr) + spawnOrdinaryJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }) } catch (error) { caught = error } expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 }) }) - it('passes explicit target stdio handles without reading caller stdio', () => { - let startup: Record | undefined - const getStdHandle = vi.fn(() => 99n as NativePtr) - const bindings = api({ - getStdHandle, - createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => { - startup = koffi.decode(infoPtr, STARTUPINFOW) as Record - koffi.encode(processInfo, PROCESS_INFORMATION, { - hProcess: 60n, - hThread: 61n, - dwProcessId: 1234, - dwThreadId: 5678, - }) - return 1 - }), - }) - expect(spawnOrdinaryProcessInJob(bindings, { - command: 'probe.exe', - args: [], - cwd: 'C:\\work', - }, 50n as NativePtr, { - stdin: 71n as NativePtr, - stdout: 72n as NativePtr, - stderr: 73n as NativePtr, - })).toEqual({ pid: 1234, process: 60n }) - expect(getStdHandle).not.toHaveBeenCalled() - expect(startup).toMatchObject({ hStdInput: 71n, hStdOutput: 72n, hStdError: 73n }) - }) - - 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 a parent-owned process handle and reads Job emptiness without blocking', () => { + 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) return 1 @@ -194,12 +129,13 @@ describe('ordinary Job process operations', () => { JOBOBJECT_BASIC_ACCOUNTING_SIZE, null, ) + const exited = api() expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42) expect(isJobEmpty(exited, 50n as NativePtr)).toBe(true) }) - it('reports process wait, exit-code query, and Job accounting failures', () => { + it('reports wait and exit-code query failures', () => { const processWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) }) expect(() => pollProcessExit(processWait, 60n as NativePtr)).toThrow(Win32Error) @@ -223,42 +159,4 @@ 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) - }) - - it('opens a private named-pipe client for target stdio', () => { - const createFileW = vi.fn(() => 70n as NativePtr) - const bindings = api({ createFileW }) - expect(openNamedPipeForStdio(bindings, '\\\\.\\pipe\\dsh-test')).toBe(70n) - expect(createFileW).toHaveBeenCalledWith( - '\\\\.\\pipe\\dsh-test', - GENERIC_READ + GENERIC_WRITE, - 0, - null, - OPEN_EXISTING, - 0, - null, - ) - - const invalid = api({ createFileW: vi.fn(() => -1n as NativePtr) }) - expect(() => openNamedPipeForStdio(invalid, '\\\\.\\pipe\\missing')).toThrow(Win32Error) - }) - - it('opens a direct process for parent-side exit observation', () => { - const openProcess = vi.fn(() => 60n as NativePtr) - const bindings = api({ openProcess }) - expect(openProcessForWait(bindings, 1234)).toBe(60n) - expect(openProcess).toHaveBeenCalledWith(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, 1234) - - const missing = api({ openProcess: vi.fn(() => 0n as NativePtr) }) - expect(() => openProcessForWait(missing, 1234)).toThrow(Win32Error) - }) }) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index bcd8ae5c19..3cbb883ccf 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -21,12 +21,7 @@ int wmain() P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); - P(GENERIC_READ); - P(GENERIC_WRITE); - P(OPEN_EXISTING); P(WAIT_TIMEOUT); - P(PROCESS_QUERY_LIMITED_INFORMATION); - P(SYNCHRONIZE); P(STD_INPUT_HANDLE); P(STD_OUTPUT_HANDLE); P(STD_ERROR_HANDLE); @@ -48,12 +43,7 @@ int wmain() static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); - static_assert(GENERIC_READ == 0x80000000, "generic read access"); - static_assert(GENERIC_WRITE == 0x40000000, "generic write access"); - static_assert(OPEN_EXISTING == 3, "open existing disposition"); static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); - static_assert(PROCESS_QUERY_LIMITED_INFORMATION == 0x1000, "limited process query right"); - static_assert(SYNCHRONIZE == 0x100000, "synchronize right"); static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size"); static_assert(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses) == 40, "active process offset"); static_assert(JobObjectBasicAccountingInformation == 1, "basic accounting class"); diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index d5ebe108c6..5e19c20113 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -387,7 +387,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['subprocess-local', 'subprocess-e2b'], consumers: ['bash-local', 'bash-sandbox', 'terminal-bash', 'lsp-stdio', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'], - note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, managed-range/session lifetime, stdio dispositions, terminal mechanics, and provider-defined termination.', + note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation.', }, { key: 'shell', diff --git a/vitest.config.ts b/vitest.config.ts index 12dd238907..cce205c03a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,12 +57,11 @@ const windowsUnsupportedCoveragePackages = process.platform === 'win32' const windowsOnlyCoverageExclusions = process.platform !== 'win32' ? [ 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', - // The Win32 adapters (Koffi process inspection/Jobs and named-pipe - // streams) execute only on win32; their decisions are unit-pinned on - // every host through focused tests and injected operations. + // The koffi-backed Win32 table (Toolhelp32/GetProcessTimes/taskkill) + // executes only on win32; its decision logic is unit-pinned on every + // host through the injected-internals suites. 'packages/subprocess/subprocess-local/src/windows-inspector.ts', 'packages/subprocess/subprocess-local/src/windows-job.ts', - 'packages/subprocess/subprocess-local/src/windows-stdio.ts', ] : []