diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index b8bcc4246e..439f9af151 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cff72ae10eb82c65c499123cc559cc6ad7e440ab +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 9b2707b3298c0649b7b2aed94980c0b7acee5c5c +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f21e22cfe89106f24beedf22ff1904b5160201e8 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 40433d99e5..9b2707b329 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -28,7 +28,7 @@ Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's t The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin: - [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-server`): the pure protocol plugin; on apply it mounts `HarnessSdkJsonRpcServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). +- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Before config discovery, the packaged entry recognizes one reserved internal argument and dispatches it to the `dsh-subprocess-local` runner, allowing native subprocess containment to re-enter the same executable without assuming `process.execPath` is a general Node binary. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. @@ -44,7 +44,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg first invokes the private subprocess runner through the built single-file executable, then drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index cff72ae10e..f21e22cfe8 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -28,7 +28,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: - [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 +- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。在配置发现前,打包入口会识别一个保留的内部参数并转入 `dsh-subprocess-local` runner,使 native subprocess containment 可以重新进入同一个可执行文件,而不假定 `process.execPath` 是通用 Node 二进制。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 @@ -44,7 +44,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都会先通过构建出的单文件可执行程序调用 private subprocess runner,再使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 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 68b5090748..a71c9f0293 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: d51b675556715aa7fe213d72a5f8fd62536fdd69 -2026-08-20-subprocess-native-containment.zh.md: d556efa217e622f2b7123e07497f966dbef21ece +2026-08-20-subprocess-native-containment.md: 45183c2c381da24433bfc20052a603beefd0afc5 +2026-08-20-subprocess-native-containment.zh.md: 4ead6b59502306117203b834b9151fc0fe2018b0 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 d51b675556..45183c2c38 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 @@ -14,13 +14,13 @@ The local subprocess provider treated a POSIX process group or a Windows direct- The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, TERM-to-KILL escalation, and host-exit registration. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. -Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default, while the runner remains until both the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit. +Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default, while the runner remains until both the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit. 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. ## Verification -Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and both source and built runner entries. +Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries. ## Alternatives considered 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 d556efa217..4ead6b5950 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 @@ -14,13 +14,13 @@ Status: implemented common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、TERM-to-KILL 升级与 host-exit 注册。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 -Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。 +Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;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 重放。 ## Verification -Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source 和 built runner entry。 +Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。 ## Alternatives considered diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 895f8b39c2..159d9ace5f 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: a2b0a5575b28d3a0ba859fc56d0a2ae7be008175 -subprocess.zh.md: b923cad98bb3dd999c6f4ca8fa5c7e160cebe774 +subprocess.md: bb73f045cda88d3dd46443e9376e4a1991a08a42 +subprocess.zh.md: 063c125eb3270c5a493a4894919ea58c1a3de024 diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index a2b0a5575b..bb73f045cd 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -282,7 +282,7 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. +- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. - SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index b923cad98b..063c125eb3 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -282,7 +282,7 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. +- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. - SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 181ae8ab76..ed5ab27052 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -45,7 +45,8 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-app-boot": "workspace:^" + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/examples/jsonrpc-demo/src/packaged-bin.ts b/packages/examples/jsonrpc-demo/src/packaged-bin.ts index 4ca60cde4c..dea0bdb090 100644 --- a/packages/examples/jsonrpc-demo/src/packaged-bin.ts +++ b/packages/examples/jsonrpc-demo/src/packaged-bin.ts @@ -8,5 +8,13 @@ import { runJsonrpcAgent } from './runner.ts' -/* v8 ignore next -- exercised through the built Python runtime carriers */ -await runJsonrpcAgent(import.meta.url) +/* v8 ignore start -- exercised through the built Python runtime carriers */ +const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' + +if (process.argv[2] === PACKAGED_RUNNER_ARG) { + process.argv.splice(2, 1) + await import('@deepseek-ai/dsh-subprocess-local/spawn-runner') +} else { + await runJsonrpcAgent(import.meta.url) +} +/* v8 ignore stop */ diff --git a/packages/examples/jsonrpc-demo/tsconfig.json b/packages/examples/jsonrpc-demo/tsconfig.json index ffd1ce9e41..ba3265f756 100644 --- a/packages/examples/jsonrpc-demo/tsconfig.json +++ b/packages/examples/jsonrpc-demo/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../boot/app-boot" }, + { + "path": "../../subprocess/subprocess-local" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c821662e85..f3c6fd979c 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 immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index cd6f8d7deb..89ef9a9f73 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -35,10 +35,19 @@ interface SystemctlResult { const SYSTEMCTL_TIMEOUT_MS = 5_000 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 { ...process.env, LC_ALL: 'C' } +} function querySystemctl(command: string, args: readonly string[]): Promise { return new Promise((resolve) => { - execFile(command, [...args], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }, (error, stdout, stderr) => { + execFile(command, [...args], { + encoding: 'utf8', + env: systemctlEnv(), + timeout: SYSTEMCTL_TIMEOUT_MS, + }, (error, stdout, stderr) => { const code = error === null ? 0 : (error as Error & { code?: string | number }).code resolve({ status: typeof code === 'number' ? code : null, @@ -61,11 +70,15 @@ function unitStem(prefix: string): string { */ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { const runSync = internals.spawnSync ?? spawnSync + const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() + const [runnerCommand, ...runnerPrefix] = invocation + if (runnerCommand === undefined) return false const systemdRun = internals.systemdRun ?? 'systemd-run' const systemctl = internals.systemctl ?? 'systemctl' const timeout = 5_000 const manager = runSync(systemctl, ['--user', 'show-environment'], { encoding: 'utf8', + env: systemctlEnv(), stdio: 'ignore', timeout, }) @@ -78,9 +91,10 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { '--expand-environment=no', `--unit=${unitStem('dsh-subprocess-probe')}`, '--', - process.execPath, - '-e', - '', + runnerCommand, + ...runnerPrefix, + '--mode', + 'probe-node', ], { env: childEnv(), stdio: 'ignore', @@ -111,7 +125,7 @@ class SystemdScopeOwner implements BoundProcessOwner { '--kill-whom=all', `--signal=${signal}`, this.unit, - ], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }) + ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS }) if (result.error === undefined && result.status === 0) { if (signal === 'SIGKILL') this.killConfirmed = true return @@ -134,7 +148,7 @@ class SystemdScopeOwner implements BoundProcessOwner { ]) const output = `${result.stdout}\n${result.stderr}` if (result.status !== 0) { - if (/not found|could not be found|no such/iu.test(output)) { + if (MISSING_UNIT.test(output)) { if (this.runner.exitCode !== null || this.runner.signalCode !== null) return false } else { if (result.error !== undefined) throw result.error diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 084d9c1053..69f2d3880c 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -18,12 +18,14 @@ import { childEnv } from './spawn.ts' const handshakeWait = new Int32Array(new SharedArrayBuffer(4)) const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000 const RUNNER_EVENT_POLL_MS = 100 +const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' /** * Resolve the built runner in production or its source entry in repository execution. * @returns Node executable and runner argv prefix. */ export function spawnRunnerInvocation(): string[] { + if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG] const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) if (existsSync(builtEntry)) return [process.execPath, builtEntry] const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 99c9968796..59bcf15d05 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -19,6 +19,7 @@ import { import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts' type RunnerArgs = + | { mode: 'probe-node' } | { mode: 'probe-win32' } | { mode: 'node' | 'win32'; requestPath: string; eventsPath: string } @@ -35,7 +36,7 @@ function parseArgs(argv: string[]): RunnerArgs { else if (key === '--events') eventsPath = value else throw new Error(`subprocess runner unknown argument: ${String(key)}`) } - if (mode === 'probe-win32') return { mode } + 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') return { mode, requestPath, eventsPath } @@ -167,6 +168,7 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise { const args = parseArgs(process.argv.slice(2)) + if (args.mode === 'probe-node') return if (args.mode === 'probe-win32') { loadWin32ProcessBindings() return @@ -185,7 +187,7 @@ async function main(): Promise { main().catch((error: unknown) => { try { const args = parseArgs(process.argv.slice(2)) - if (args.mode !== 'probe-win32') { + if (args.mode !== 'probe-node' && args.mode !== 'probe-win32') { appendRunnerEvent(args.eventsPath, { type: 'runner-error', error: serializeSpawnError(error) }) } } catch { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index cc9c8d55a4..3865713c7a 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -29,14 +29,25 @@ 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 calls: string[][] = [] - const runSync = vi.fn((command: string, args: readonly string[]) => { + const environments: Array = [] + const runSync = vi.fn((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => { calls.push([command, ...args]) + environments.push(options?.env) return { status: 0, error: undefined } }) as unknown as typeof spawnSync - expect(probeLinuxScope({ spawnSync: runSync, systemdRun: 'systemd-run', systemctl: 'systemctl' })).toBe(true) + const runnerInvocation = ['node-runtime', 'runner-entry.js'] + 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, @@ -130,7 +141,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const runSync = vi.fn(() => ({ status: 1, stdout: '', - stderr: 'Failed to connect to bus', + stderr: 'Failed to connect to bus: No such file or directory', error: undefined, })) as unknown as typeof spawnSync const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { @@ -300,7 +311,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(runSync).toHaveBeenCalledWith( 'systemctl', expect.arrayContaining(['kill', '--kill-whom=all', '--signal=SIGKILL']), - expect.any(Object), + expect.objectContaining({ env: expect.objectContaining({ LC_ALL: 'C' }) }), ) } finally { wrapper?.kill('SIGKILL') @@ -313,6 +324,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const run = vi.fn() const runSync = vi.fn() const runAsync = vi.fn() + const queryEnvironments: Array = [] vi.resetModules() vi.doMock('node:child_process', async (importOriginal) => { const actual = await importOriginal() @@ -327,9 +339,10 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () runAsync.mockImplementation(( _command: string, args: readonly string[], - _options: unknown, + options: { env?: NodeJS.ProcessEnv }, callback: (error: Error | null, stdout: string, stderr: string) => void, ) => { + queryEnvironments.push(options.env) if (queryFailure !== undefined) { callback(queryFailure, '', '') return @@ -348,6 +361,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object)) expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object)) expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function)) + expect(queryEnvironments[0]?.LC_ALL).toBe('C') queryFailure = Object.assign(new Error('numeric systemctl failure'), { code: 17 }) const numericFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 7cd0baa027..135aa419d4 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -10,6 +10,7 @@ import { runnerDirectResult, runnerFiles, runnerStdio, + spawnRunnerInvocation, } from '../src/runner-launch.ts' import { appendRunnerEvent, @@ -85,6 +86,28 @@ describe('spawn runner transport', () => { } }) + it('re-enters a packaged executable through its private runner dispatch', () => { + const packagedProcess = process as NodeJS.Process & { pkg?: unknown } + const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg') + Object.defineProperty(packagedProcess, 'pkg', { configurable: true, value: {} }) + try { + expect(spawnRunnerInvocation()).toEqual([process.execPath, '--dsh-internal-subprocess-runner']) + } finally { + if (original === undefined) Reflect.deleteProperty(packagedProcess, 'pkg') + else Object.defineProperty(packagedProcess, 'pkg', original) + } + }) + + it('supports the node runner capability probe', () => { + const result = spawnSync(sourceInvocation[0] as string, [ + ...sourceInvocation.slice(1), + '--mode', + 'probe-node', + ], { encoding: 'utf8', timeout: 10_000 }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + }) + it('maps every target stdio disposition and optional IPC channel', () => { expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) expect(runnerStdio(spec({ diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index a7413159f9..2ba5baa9fc 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -80,8 +80,9 @@ declare module '@deepseek-ai/cordis' { * Implementations must honor these semantics: * - Executable paths belong to one execution world shared with the mounted * filesystem provider. - * - {@link spawn} returns immediately with a live handle; `done` resolves at - * process close with exit facts and rejects only for spawn-level failures. + * - {@link spawn} returns a live handle synchronously after provider-specific + * setup needed to publish its target pid. `done` resolves with direct-process + * exit facts and may reject for spawn or selected provider-runner failures. * - Collect-mode readers are offset-based and non-consuming, so independent * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53dd8aa315..f89301f556 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4088,6 +4088,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index f001adccb2..852a7df950 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -679,19 +679,26 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "runner", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"} and args.exe is None: - parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "runner", "direct"} and args.exe is None: + parser.error("--exe is required for custom, minimal, fs-search, snapshot, runner, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, or all") if args.exe is not None and not args.exe.is_file(): parser.error(f"runtime executable does not exist: {args.exe}") + if args.scenario in {"all", "runner"}: + assert args.exe is not None + smoke_packaged_runner(args.exe.resolve()) + if args.scenario == "runner": + print("smoke-python-runtime: runner passed") + return + with MockModel() as model: if args.scenario in {"all", "sdk-default"}: smoke_sdk_default(model.url) @@ -946,6 +953,58 @@ def smoke_direct(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT) +def smoke_packaged_runner(executable: Path) -> None: + """Exercise the private subprocess runner through the single-file entry.""" + with tempfile.TemporaryDirectory(prefix="dsh-packaged-runner-") as temporary: + root = Path(temporary).resolve() + request_path = root / "request.json" + events_path = root / "events.ndjson" + probe = subprocess.run( + [str(executable), "--dsh-internal-subprocess-runner", "--mode", "probe-node"], + cwd=root, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if probe.returncode != 0: + raise AssertionError(f"packaged runner probe failed: {probe.stderr}") + + request_path.write_text(json.dumps({ + "argv": [sys.executable, "-c", "import sys; sys.exit(7)"], + "cwd": str(root), + "env": {}, + })) + result = subprocess.run( + [ + str(executable), + "--dsh-internal-subprocess-runner", + "--mode", + "node", + "--request", + str(request_path), + "--events", + str(events_path), + ], + cwd=root, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 7: + raise AssertionError( + f"packaged runner returned {result.returncode}, expected 7; stderr: {result.stderr}" + ) + events = [json.loads(line) for line in events_path.read_text().splitlines()] + if len(events) != 2 or events[0].get("type") != "started" or events[1] != { + "type": "exit", + "exitCode": 7, + "signal": None, + }: + raise AssertionError(f"packaged runner emitted unexpected events: {events}") + + def is_idle_notification(message: dict[str, object]) -> bool: """Return whether a JSON-RPC notification marks a session idle.""" params = message.get("params")