From bdff8573b686773fc5d82ab71eb047e8cb7a48c8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 12:44:00 +0800 Subject: [PATCH 01/75] ci: run coverage on in-house vm-backup pool Coverage does not gate merges, so move it off the metered dsh-enterprise-ubuntu-24-04-32core-test pool onto the in-house self-hosted pool (vm-backup label, 64-core). Also switch the pnpm store cache path to ~ so it resolves under both /home/runner (hosted) and self-hosted home directories. Verified on the self-hosted pool: the full coverage job (including prepare-ci-bubblewrap and the exhaustive suite) completed green in ~5 min. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eceefa114..a2e70cab6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,9 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-24-04-32core-test + # Coverage does not gate merges, so it runs on the in-house pool + # (self-hosted, 64-core) instead of the metered enterprise pool. + runs-on: [self-hosted, linux, x64, vm-backup] name: node 24 / coverage env: DSH_COVERAGE_MAX_WORKERS: '24' @@ -89,7 +91,8 @@ jobs: - uses: actions/cache/restore@v4 with: - path: /home/runner/.local/share/pnpm/store/v11 + # ~ resolves on both hosted (/home/runner) and self-hosted homes + path: ~/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- From 81890d7a994ab791c7db8bc93667caf21fc38f45 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 16:37:59 +0800 Subject: [PATCH 02/75] =?UTF-8?q?ci:=20address=20review=20=E2=80=94=20same?= =?UTF-8?q?-repo=20guard,=20keep=20cache=20path=20identical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restrict node-24-coverage to same-repo PRs so fork-originated code can never reach the self-hosted runner (defense in depth; the repo is private with forking disabled today). - Revert the pnpm cache path to the literal /home/runner/... save-side path: actions/cache hashes the path into the cache version, so the ~ variant could never match the cache saved by the master lane. On self-hosted the persistent local pnpm store covers warm installs. - Drop the incorrect 'does not gate merges' claim: node-24-coverage is needed by all-checks-passed. Pool capacity notes moved into comments. --- .github/workflows/ci.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2e70cab6f..dd60593a85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,14 @@ jobs: compression-level: 0 node-24-coverage: - if: github.event_name == 'pull_request' - # Coverage does not gate merges, so it runs on the in-house pool - # (self-hosted, 64-core) instead of the metered enterprise pool. + # Same-repo PRs only: this lane runs on an in-house self-hosted runner, + # so fork-originated code must never land here. The repo is currently + # private with forking disabled; this guard keeps that invariant explicit + # if either setting ever changes. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + # Runs on the in-house pool (self-hosted, 64-core) instead of the metered + # enterprise pool. The pool holds 4 always-on instances plus 4 registered + # spares; the runner service is systemd-managed and self-healing. runs-on: [self-hosted, linux, x64, vm-backup] name: node 24 / coverage env: @@ -91,8 +96,12 @@ jobs: - uses: actions/cache/restore@v4 with: - # ~ resolves on both hosted (/home/runner) and self-hosted homes - path: ~/.local/share/pnpm/store/v11 + # Path must stay byte-identical to the save-side path in the master + # lane: actions/cache hashes the literal path into the cache version, + # so any variation (e.g. ~) would never match the saved cache. On + # self-hosted this restore simply misses and the persistent local + # pnpm store covers warm installs instead. + path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- From 5818fd62242f8799484fbf166c11f1fc8434bf48 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:00:56 +0800 Subject: [PATCH 03/75] =?UTF-8?q?ci:=20address=20second=20review=20round?= =?UTF-8?q?=20=E2=80=94=20dependabot=20lane,=20drop=20dead=20restore,=20up?= =?UTF-8?q?date=20topology=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route untrusted PRs (forks + Dependabot, same author test as e2e.yml) back to the hosted enterprise pool via a runs-on expression: Dependabot PRs are same-repo, so the previous head.repo guard admitted dependency-supplied code onto the persistent self-hosted VM. A single job with pool selection keeps all-checks-passed free of skips. - Drop the pnpm-store cache restore from this lane: on self-hosted the hosted-path cache actually HIT (Linux key) and spent ~52 s pulling 181 MB into a path pnpm never reads; the persistent local store already serves warm installs in seconds. - Update the larger-hosted-runners Agent Note (en/zh + i18n pairing record) so the decision record describes the shipped topology: coverage on the in-house vm-backup pool for trusted PRs, hosted Ubuntu 24.04 32-core retained for untrusted PRs. --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 2 +- .github/workflows/ci.yml | 39 +++++++++---------- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 9d87cb9ad3..360395102e 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: aaeab4ed9ae9687598f9f1d4a862120405697672 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 72b69c85908990a9f35b60f4c0a2ce213f9c8134 +2026-07-22-evidence-based-larger-hosted-runners.md: c3e6344ae61669da4810090e558589875ca7536e +2026-07-22-evidence-based-larger-hosted-runners.zh.md: e5b322673b7a1eb004eb15b3784d21f500e83719 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index aaeab4ed9a..c3e6344ae6 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -12,7 +12,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep ## Decision -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 72b69c8590..e5b322673b 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd60593a85..dc45bc9a7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,15 +76,19 @@ jobs: compression-level: 0 node-24-coverage: - # Same-repo PRs only: this lane runs on an in-house self-hosted runner, - # so fork-originated code must never land here. The repo is currently - # private with forking disabled; this guard keeps that invariant explicit - # if either setting ever changes. - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - # Runs on the in-house pool (self-hosted, 64-core) instead of the metered - # enterprise pool. The pool holds 4 always-on instances plus 4 registered - # spares; the runner service is systemd-managed and self-healing. - runs-on: [self-hosted, linux, x64, vm-backup] + if: github.event_name == 'pull_request' + # Trusted same-repo PRs run on the in-house pool (self-hosted, 64-core; + # 4 always-on systemd-managed instances plus 4 registered spares) instead + # of the metered enterprise pool. Untrusted PRs — forks and Dependabot + # (same-repo but dependency-supplied code; same author test as e2e.yml) — + # stay on the hosted enterprise pool so no untrusted code reaches the + # persistent self-hosted VM. Selecting the pool via runs-on keeps this a + # single job, so the all-checks-passed aggregate never sees a skip. + runs-on: >- + ${{ (github.event.pull_request.head.repo.full_name != github.repository + || github.event.pull_request.user.login == 'dependabot[bot]') + && 'dsh-enterprise-ubuntu-24-04-32core-test' + || fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') }} name: node 24 / coverage env: DSH_COVERAGE_MAX_WORKERS: '24' @@ -94,17 +98,12 @@ jobs: with: persist-credentials: false - - uses: actions/cache/restore@v4 - with: - # Path must stay byte-identical to the save-side path in the master - # lane: actions/cache hashes the literal path into the cache version, - # so any variation (e.g. ~) would never match the saved cache. On - # self-hosted this restore simply misses and the persistent local - # pnpm store covers warm installs instead. - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + # No pnpm-store cache restore in this lane: on the self-hosted pool + # pnpm's persistent store lives outside /home/runner, so restoring the + # hosted cache here downloads ~180 MB into a path pnpm never reads + # (measured: 52 s restore, then a 2.8 s install straight from the + # persistent store). The rare hosted (untrusted-PR) run just does a + # cold install. - uses: actions/setup-node@v6 with: From e532c9ccc245a2360df74bb6d4795ea1f3c13162 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:12:20 +0800 Subject: [PATCH 04/75] ci: restore pnpm cache on the hosted leg only Keep the cache restore for the ephemeral hosted (untrusted-PR) leg where it is a genuine speedup, gated by the same expression as the runs-on pool selector; the self-hosted leg skips it and installs from the persistent local store. --- .github/workflows/ci.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc45bc9a7a..d0d51fde9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,12 +98,21 @@ jobs: with: persist-credentials: false - # No pnpm-store cache restore in this lane: on the self-hosted pool - # pnpm's persistent store lives outside /home/runner, so restoring the - # hosted cache here downloads ~180 MB into a path pnpm never reads - # (measured: 52 s restore, then a 2.8 s install straight from the - # persistent store). The rare hosted (untrusted-PR) run just does a - # cold install. + # Restore the pnpm-store cache only on the hosted (untrusted-PR) leg, + # where the VM is ephemeral and the same-region download is fast. On + # the self-hosted leg pnpm's persistent store lives outside + # /home/runner, so this restore would spend ~52 s pulling ~180 MB into + # a path pnpm never reads (measured; install then took 2.8 s straight + # from the persistent store). Condition mirrors the runs-on selector. + - uses: actions/cache/restore@v4 + if: >- + github.event.pull_request.head.repo.full_name != github.repository + || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: /home/runner/.local/share/pnpm/store/v11 + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - uses: actions/setup-node@v6 with: From 8d53d44b6055ce37aecdd22be1eb9d1429a96cae Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:49:58 +0800 Subject: [PATCH 05/75] docs(ci): reconcile every present-tense topology description with the coverage lane move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep all remaining sources that still described coverage as an enterprise 32-core job: the ci.yml jobs preamble, the three-job paragraph of the larger-hosted-runners note, and the required-pool sentence of the portable-recovery note — English and Chinese sides of both notes, with their i18n pairing records re-recorded. --- ...026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .../2026-07-23-portable-required-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-07-23-portable-required-pull-request-ci.md | 2 +- .../2026-07-23-portable-required-pull-request-ci.zh.md | 2 +- .github/workflows/ci.yml | 6 ++++-- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 360395102e..4d781caa54 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: c3e6344ae61669da4810090e558589875ca7536e -2026-07-22-evidence-based-larger-hosted-runners.zh.md: e5b322673b7a1eb004eb15b3784d21f500e83719 +2026-07-22-evidence-based-larger-hosted-runners.md: 88b9e6d83777172d8afb6a391512e5f293b81171 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: b1105f00cd08b1af633d258ea4ff28a835ce6074 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index c3e6344ae6..88b9e6d837 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -18,7 +18,7 @@ The required primary path depends on those enterprise pools. Standard GitHub-hos The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent jobs: static gates and the consumer tail on hosted 32-core pools, and coverage on the in-house self-hosted 64-core pool for trusted PRs (hosted 32-core for untrusted ones). Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index e5b322673b..b1105f00cd 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -18,7 +18,7 @@ Status: implemented 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的作业:静态门禁与消费方尾部作业运行在托管 32 核池上,覆盖率对可信拉取请求运行在公司自有的自托管 64 核池上(不可信请求仍用托管 32 核池)。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index f8b54b0ec5..ed97fe08a7 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-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 -2026-07-23-portable-required-pull-request-ci.md: 9cf8d97016300c5258c075879176aa6abd64e59e -2026-07-23-portable-required-pull-request-ci.zh.md: c6839a133d0c3fe7a699362f6168e17d827a5b61 +2026-07-23-portable-required-pull-request-ci.md: 29b2cfa3f431a4a8be4aaa685b16cffdb4bf2593 +2026-07-23-portable-required-pull-request-ci.zh.md: 8b6d067637ce83c09529977f463f16dfa4af5a8b diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 9cf8d97016..29b2cfa3f4 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,7 +12,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools, except exhaustive coverage, which runs on the in-house self-hosted 64-core pool for trusted same-repo pull requests (hosted 32-core for forks and Dependabot). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when an enterprise label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index c6839a133d..8b6d067637 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业;唯一例外是完整覆盖率——可信的同仓库拉取请求在公司自有的自托管 64 核池上运行(fork 与 Dependabot 仍用托管 32 核池)。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。企业级运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d51fde9b..a21086754b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,10 @@ env: jobs: - # Three enterprise jobs isolate coverage, static analysis, and the - # build-backed consumer tail. The static job publishes its exact build so + # Three independent Linux jobs isolate coverage, static analysis, and the + # build-backed consumer tail: static and consumers on hosted enterprise + # 32-core pools; coverage on the in-house self-hosted pool for trusted PRs + # (hosted for forks/Dependabot). The static job publishes its exact build so # consumers do not repeat the longest part of their critical path. node-24: if: github.event_name == 'pull_request' From 1a5d892ec53beb5f1b7212decfc2a10bd9ea2741 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 25 Jul 2026 00:45:31 +0800 Subject: [PATCH 06/75] ci: halve coverage workers on the shared self-hosted leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted 32-core runner is exclusive to one job, but the vm-backup pool shares one 64-core VM across four runner instances; concurrent PRs could stack 4×24 = 96 Vitest workers and re-trigger the documented aggregate-contention failures in the timing-sensitive process suites. Bound the self-hosted leg at 12 workers per job (48 host-wide fully loaded) and keep 24 on the hosted leg, selected by the same expression as the pool. --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a21086754b..dc5ad98ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,16 @@ jobs: || fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') }} name: node 24 / coverage env: - DSH_COVERAGE_MAX_WORKERS: '24' + # Worker bound is per-leg: the hosted 32-core runner is exclusive to + # one job, but the self-hosted pool shares one 64-core VM across four + # runner instances, so concurrent PRs would otherwise stack up to + # 4×24 = 96 workers and re-trigger the aggregate-contention failures + # documented for the timing-sensitive process suites. 12 per job caps + # the shared host at 48 workers even fully loaded. + DSH_COVERAGE_MAX_WORKERS: >- + ${{ (github.event.pull_request.head.repo.full_name != github.repository + || github.event.pull_request.user.login == 'dependabot[bot]') + && '24' || '12' }} DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 From f09539581d33a5110c97d81cfe2778c74337690e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 25 Jul 2026 00:54:35 +0800 Subject: [PATCH 07/75] docs(ci): record disabled forking as an explicit precondition of the self-hosted lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool selector is defense-in-depth only — pull_request executes the PR's own workflow definition, so YAML cannot enforce runner trust. Make the actual enforcement boundary explicit in the decision record: org-side disabled forking (the public release is an isolated read-only mirror under a separate org), with migration to a repo-restricted org-level runner group with base-branch workflow pinning as a hard gate before forking could ever be enabled. --- .../2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 4d781caa54..ea3a57e072 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 88b9e6d83777172d8afb6a391512e5f293b81171 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: b1105f00cd08b1af633d258ea4ff28a835ce6074 +2026-07-22-evidence-based-larger-hosted-runners.md: 497c6f297d79245fb40cd30457e4b1d1e36db651 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: bcb0c6e9f11081b2cff696a9b6b425a40ee4aeb4 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 88b9e6d837..497c6f297d 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -12,7 +12,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep ## Decision -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. **Precondition: repository forking stays disabled.** The workflow's pool selector is defense-in-depth only — `pull_request` executes the PR's own workflow definition, so YAML cannot enforce runner trust against a fork that edits it. Disabled forking (org-side, not PR-editable) is the enforcement boundary; the planned public release is an isolated read-only mirror under a separate org, preserving this. Before forking is ever enabled, the runners must first move into an org-level runner group restricted to this repository with base-branch workflow pinning — that migration is the gate, not a follow-up. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index b1105f00cd..bcb0c6e9f1 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。**前置条件:仓库必须保持禁用 fork。**工作流中的运行器池选择表达式仅是纵深防御——`pull_request` 执行的是拉取请求自带的工作流定义,因此 YAML 无法对能修改它的 fork 实施运行器信任约束。真正的强制边界是组织侧(拉取请求无法修改)的 fork 禁用设置;规划中的开源发布采用独立组织下的只读镜像仓库,正是为了保持这一边界。将来若要启用 fork,必须先把运行器迁入组织级 runner group(限定本仓库并绑定基线分支工作流)——该迁移是启用 fork 的先决门槛,而非事后跟进项。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 From 310a387b144526354bec79ab8f913cd419fcf570 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 00:08:26 +0800 Subject: [PATCH 08/75] =?UTF-8?q?ci:=20pivot=20=E2=80=94=20keep=20coverage?= =?UTF-8?q?=20hosted,=20add=20self-hosted=20serial=20standby=20lane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direction change after review discussion. Moving a REQUIRED check onto a single in-house VM traded merge-path availability for modest savings and accumulated trust/contention caveats (six review rounds' worth). Revert every coverage-lane change: coverage stays on the enterprise Ubuntu 24.04 32-core pool exactly as on master. Instead, add serial-linux-selfhosted: on every master push the in-house pool (vm-backup) runs the complete unsharded primary aggregate as a hot-standby drill. It blocks nothing, yet continuously proves the environment end to end, so any hosted-pool outage can be answered with a one-line runs-on retarget onto continuously verified capacity. Push-triggered lanes execute the base branch's own workflow definition, so no PR-editable path selects these runners — the entire fork-trust discussion is structurally moot for this lane. Topology notes (en/zh + pairing records) describe the standby lane and the switch play. --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 6 +- ...evidence-based-larger-hosted-runners.zh.md | 6 +- ...ortable-required-pull-request-ci.i18n.yaml | 4 +- ...07-23-portable-required-pull-request-ci.md | 2 +- ...23-portable-required-pull-request-ci.zh.md | 2 +- .github/workflows/ci.yml | 78 ++++++++++--------- 7 files changed, 57 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index ea3a57e072..1b14f5b689 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 497c6f297d79245fb40cd30457e4b1d1e36db651 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: bcb0c6e9f11081b2cff696a9b6b425a40ee4aeb4 +2026-07-22-evidence-based-larger-hosted-runners.md: 6654f5eb3e21b48c6d33fd9d74ebd23cf3065d54 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 3fe5715b20d3b881f8fb439b61900bdb84e5a588 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 497c6f297d..6654f5eb3e 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -12,13 +12,13 @@ Larger runners make it possible to pay setup once and parallelize inside the rep ## Decision -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. **Precondition: repository forking stays disabled.** The workflow's pool selector is defense-in-depth only — `pull_request` executes the PR's own workflow definition, so YAML cannot enforce runner trust against a fork that edits it. Disabled forking (org-side, not PR-editable) is the enforcement boundary; the planned public release is an isolated read-only mirror under a separate org, preserving this. Before forking is ever enabled, the runners must first move into an org-level runner group restricted to this repository with base-branch workflow pinning — that migration is the gate, not a follow-up. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Linux primary work uses three independent jobs: static gates and the consumer tail on hosted 32-core pools, and coverage on the in-house self-hosted 64-core pool for trusted PRs (hosted 32-core for untrusted ones). Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. @@ -48,6 +48,8 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate, so if the enterprise pools degrade, a required lane can be retargeted with a one-line `runs-on` change onto an environment with continuously verified evidence. Because the lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. + ## Alternatives considered **Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index bcb0c6e9f1..3fe5715b20 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。**前置条件:仓库必须保持禁用 fork。**工作流中的运行器池选择表达式仅是纵深防御——`pull_request` 执行的是拉取请求自带的工作流定义,因此 YAML 无法对能修改它的 fork 实施运行器信任约束。真正的强制边界是组织侧(拉取请求无法修改)的 fork 禁用设置;规划中的开源发布采用独立组织下的只读镜像仓库,正是为了保持这一边界。将来若要启用 fork,必须先把运行器迁入组织级 runner group(限定本仓库并绑定基线分支工作流)——该迁移是启用 fork 的先决门槛,而非事后跟进项。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -Linux 主流程使用 3 个相互独立的作业:静态门禁与消费方尾部作业运行在托管 32 核池上,覆盖率对可信拉取请求运行在公司自有的自托管 64 核池上(不可信请求仍用托管 32 核池)。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 @@ -48,6 +48,8 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程,因此当企业池发生故障时,只需一行 `runs-on` 修改即可把必需通道切换到一个具有持续验证证据的环境上。该通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 + ## 曾考虑的替代方案 **保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index ed97fe08a7..f8b54b0ec5 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-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 -2026-07-23-portable-required-pull-request-ci.md: 29b2cfa3f431a4a8be4aaa685b16cffdb4bf2593 -2026-07-23-portable-required-pull-request-ci.zh.md: 8b6d067637ce83c09529977f463f16dfa4af5a8b +2026-07-23-portable-required-pull-request-ci.md: 9cf8d97016300c5258c075879176aa6abd64e59e +2026-07-23-portable-required-pull-request-ci.zh.md: c6839a133d0c3fe7a699362f6168e17d827a5b61 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 29b2cfa3f4..9cf8d97016 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,7 +12,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools, except exhaustive coverage, which runs on the in-house self-hosted 64-core pool for trusted same-repo pull requests (hosted 32-core for forks and Dependabot). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when an enterprise label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index 8b6d067637..c6839a133d 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业;唯一例外是完整覆盖率——可信的同仓库拉取请求在公司自有的自托管 64 核池上运行(fork 与 Dependabot 仍用托管 32 核池)。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。企业级运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc5ad98ec4..2666c93b8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,8 @@ env: jobs: - # Three independent Linux jobs isolate coverage, static analysis, and the - # build-backed consumer tail: static and consumers on hosted enterprise - # 32-core pools; coverage on the in-house self-hosted pool for trusted PRs - # (hosted for forks/Dependabot). The static job publishes its exact build so + # Three enterprise jobs isolate coverage, static analysis, and the + # build-backed consumer tail. The static job publishes its exact build so # consumers do not repeat the longest part of their critical path. node-24: if: github.event_name == 'pull_request' @@ -79,46 +77,17 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' - # Trusted same-repo PRs run on the in-house pool (self-hosted, 64-core; - # 4 always-on systemd-managed instances plus 4 registered spares) instead - # of the metered enterprise pool. Untrusted PRs — forks and Dependabot - # (same-repo but dependency-supplied code; same author test as e2e.yml) — - # stay on the hosted enterprise pool so no untrusted code reaches the - # persistent self-hosted VM. Selecting the pool via runs-on keeps this a - # single job, so the all-checks-passed aggregate never sees a skip. - runs-on: >- - ${{ (github.event.pull_request.head.repo.full_name != github.repository - || github.event.pull_request.user.login == 'dependabot[bot]') - && 'dsh-enterprise-ubuntu-24-04-32core-test' - || fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') }} + runs-on: dsh-enterprise-ubuntu-24-04-32core-test name: node 24 / coverage env: - # Worker bound is per-leg: the hosted 32-core runner is exclusive to - # one job, but the self-hosted pool shares one 64-core VM across four - # runner instances, so concurrent PRs would otherwise stack up to - # 4×24 = 96 workers and re-trigger the aggregate-contention failures - # documented for the timing-sensitive process suites. 12 per job caps - # the shared host at 48 workers even fully loaded. - DSH_COVERAGE_MAX_WORKERS: >- - ${{ (github.event.pull_request.head.repo.full_name != github.repository - || github.event.pull_request.user.login == 'dependabot[bot]') - && '24' || '12' }} + DSH_COVERAGE_MAX_WORKERS: '24' DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 with: persist-credentials: false - # Restore the pnpm-store cache only on the hosted (untrusted-PR) leg, - # where the VM is ephemeral and the same-region download is fast. On - # the self-hosted leg pnpm's persistent store lives outside - # /home/runner, so this restore would spend ~52 s pulling ~180 MB into - # a path pnpm never reads (measured; install then took 2.8 s straight - # from the persistent store). Condition mirrors the runs-on selector. - uses: actions/cache/restore@v4 - if: >- - github.event.pull_request.head.repo.full_name != github.repository - || github.event.pull_request.user.login == 'dependabot[bot]' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -396,6 +365,45 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + # Hot-standby drill for the in-house self-hosted pool: every master move + # re-runs the complete unsharded aggregate on the persistent 64-core VM, + # continuously proving that environment can take over a required lane if + # the hosted pools degrade (the switch is then a one-line runs-on change). + # Push-triggered, so it always executes the base branch's own workflow + # definition — no PR-editable path selects these runners. Non-blocking for + # pull requests; no cache steps because the VM's persistent pnpm store and + # tool caches make them redundant (and saving here would poison the hosted + # cache namespace with self-hosted paths). + serial-linux-selfhosted: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: serial / linux (self-hosted standby) + runs-on: [self-hosted, linux, x64, vm-backup] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Prepare bubblewrap (unrestrict userns) + run: bash scripts/prepare-ci-bubblewrap.sh + + - name: Run complete unsharded primary Node CI serially + env: + DSH_COVERAGE_MAX_WORKERS: '1' + DSH_E2E_MAX_WORKERS: '1' + DSH_ESLINT_CACHE: '1' + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' + DSH_SNAPSHOT_MAX_CONCURRENCY: '1' + run: pnpm run check:ci + serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / macos From 0fd6dc8924a087db5c3a8190a2f1783766660e8b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 00:34:53 +0800 Subject: [PATCH 09/75] ci: pre-wire admin-only failover from hosted pools to the in-house pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three required Linux jobs now resolve their pool through the DSH_CI_FAILOVER repository variable. Unset, everything runs exactly as today on the hosted enterprise pools. Setting it to 'selfhosted' (repo-admin-only, not PR-editable, no merge required — a merge would be deadlocked behind the failing checks themselves) retargets all three onto the vm-backup pool, halves the coverage worker bound and snapshot concurrency for the shared VM, and skips the hosted-path cache restores. Adds a bilingual failover runbook (switch, capacity via the four registered spare instances, switch-back, trust boundary) and links it from the topology note. The push-triggered standby lane remains the continuous proof that the failover target works. --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 2 +- .../process/ci-failover-runbook.i18n.yaml | 6 +++ .../process/ci-failover-runbook.md | 33 +++++++++++++++ .../process/ci-failover-runbook.zh.md | 33 +++++++++++++++ .github/workflows/ci.yml | 40 ++++++++++++++++--- 7 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/process/ci-failover-runbook.i18n.yaml create mode 100644 .agents/notes/implemented/process/ci-failover-runbook.md create mode 100644 .agents/notes/implemented/process/ci-failover-runbook.zh.md diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 1b14f5b689..cd3ae7a181 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 6654f5eb3e21b48c6d33fd9d74ebd23cf3065d54 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 3fe5715b20d3b881f8fb439b61900bdb84e5a588 +2026-07-22-evidence-based-larger-hosted-runners.md: dd07280092565257f4b5324f997d5efd4c9c51cc +2026-07-22-evidence-based-larger-hosted-runners.zh.md: a9c034b643da0cb9148d08c0300f3e142eec31e6 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 6654f5eb3e..dd07280092 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -48,7 +48,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate, so if the enterprise pools degrade, a required lane can be retargeted with a one-line `runs-on` change onto an environment with continuously verified evidence. Because the lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 3fe5715b20..a9c034b643 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -48,7 +48,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程,因此当企业池发生故障时,只需一行 `runs-on` 修改即可把必需通道切换到一个具有持续验证证据的环境上。该通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](ci-failover-runbook.zh.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml new file mode 100644 index 0000000000..294ed38ddf --- /dev/null +++ b/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +ci-failover-runbook.md: d22c93fbedd3216e71bc24101dfa06dc606521c2 +ci-failover-runbook.zh.md: d7d26287191165cd3cb2666de4b1c7d6217ba71d diff --git a/.agents/notes/implemented/process/ci-failover-runbook.md b/.agents/notes/implemented/process/ci-failover-runbook.md new file mode 100644 index 0000000000..d22c93fbed --- /dev/null +++ b/.agents/notes/implemented/process/ci-failover-runbook.md @@ -0,0 +1,33 @@ +# Agent Note: CI failover runbook — hosted pools → in-house pool + +Status: implemented + +English | [中文](ci-failover-runbook.zh.md) + +## What this is + +The three required Linux jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) resolve their runner pool through the `DSH_CI_FAILOVER` repository variable. Normally the variable is unset and they run on the hosted enterprise 32-core pools. When the hosted pools are degraded (jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails), a repository admin can retarget all three onto the in-house self-hosted pool without merging anything — merging would itself be blocked by the very checks that are failing. + +The in-house pool (`vm-backup`: one 64-core VM, four always-on systemd-managed runner instances, four registered spares) is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. Check its latest run before switching: green standby = verified-yesterday capacity. + +## Switch (repo admin, ~1 minute, no merge) + +1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. +2. Re-run the failed/queued required jobs (Re-run failed jobs on affected PRs, or let new pushes pick it up). +3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). + +## Capacity during failover + +Four always-on instances absorb normal PR traffic. If queues build, bring the four registered spares online on the VM (no token needed — they are already registered): + +```bash +for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done +``` + +## Switch back + +Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Stop the spare instances if they were started. + +## Trust boundary + +The variable is repository-admin-only state: a pull request can neither set it nor read a different value into effect, and the expressions live in the base branch's workflow definition. This failover path therefore adds no PR-editable route to the self-hosted pool. (Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism.) diff --git a/.agents/notes/implemented/process/ci-failover-runbook.zh.md b/.agents/notes/implemented/process/ci-failover-runbook.zh.md new file mode 100644 index 0000000000..d7d2628719 --- /dev/null +++ b/.agents/notes/implemented/process/ci-failover-runbook.zh.md @@ -0,0 +1,33 @@ +# Agent Note: CI 故障切换手册 — 托管池 → 自有池 + +Status: implemented + +[English](ci-failover-runbook.md) | 中文 + +## 这是什么 + +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。正常情况下该变量不存在,作业运行在托管的企业级 32 核池上。当托管池发生故障(作业无限排队、企业标签消失或 GitHub 侧容量故障)时,仓库管理员无需合并任何代码即可把三个作业整体切换到公司自有的自托管池——此时合并本身正被这些失败的检查阻塞,任何"先合 PR 再切换"的方案都是死锁。 + +自有池(`vm-backup`:一台 64 核虚拟机,4 个常驻 systemd 管理的运行器实例,另有 4 个已注册备用位)由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。切换前先看该通道最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 + +## 切换步骤(仓库管理员,约 1 分钟,无需合并) + +1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 +2. 对受影响 PR 的失败/排队作业点 Re-run failed jobs(或等新推送自然触发)。 +3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 + +## 切换期间的容量 + +4 个常驻实例可承接正常 PR 流量。若出现排队,在虚拟机上把 4 个已注册的备用位拉起(无需 token——它们已注册): + +```bash +for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done +``` + +## 切回 + +删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若启动过备用实例,将其停止。 + +## 信任边界 + +该变量是仅限仓库管理员的状态:拉取请求既不能设置它,也不能让不同的值生效,且表达式存在于基线分支的工作流定义中。因此这条故障切换路径没有增加任何可由 PR 编辑的自托管池访问途径。(运行器侧的强制约束——通过组织级 runner group 把这批运行器限定到 master 引用的工作流——另行跟踪,与本机制互补。) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2666c93b8c..88722804f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,22 @@ jobs: # Three enterprise jobs isolate coverage, static analysis, and the # build-backed consumer tail. The static job publishes its exact build so # consumers do not repeat the longest part of their critical path. + # + # FAILOVER: each Linux enterprise job resolves its pool through the + # DSH_CI_FAILOVER repository variable. Unset (normal), the expressions + # pick the hosted enterprise pools below. Setting the variable to + # 'selfhosted' (repo Settings → Actions → Variables; admin-only, not + # PR-editable, no merge required) retargets all three onto the in-house + # vm-backup pool and re-running the failed jobs is the entire switch — + # see .agents/notes/implemented/process/ci-failover-runbook.md. The + # in-house pool's readiness is re-proven on every master push by the + # serial-linux-selfhosted standby lane below. node-24: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-latest-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / static env: DSH_GATE_CONCURRENCY: '8' @@ -77,17 +90,28 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-24-04-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-24-04-32core-test' }} name: node 24 / coverage env: - DSH_COVERAGE_MAX_WORKERS: '24' + # Failover halves the worker bound: the hosted 32-core runner is + # exclusive to one job, but the failover pool shares one 64-core VM + # across four runner instances, and the timing-sensitive process + # suites have documented aggregate-contention failures. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '12' || '24' }} DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 with: persist-credentials: false + # Skipped under failover: the self-hosted VM's persistent pnpm store + # serves warm installs directly, and this hosted-path restore would + # spend ~52 s pulling ~180 MB into a path pnpm never reads there. - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -118,7 +142,10 @@ jobs: node-24-consumers: needs: node-24 if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-latest-32core-test + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / snapshots and artifacts env: DSH_ESLINT_CACHE: '1' @@ -126,7 +153,8 @@ jobs: DSH_GATE_CONCURRENCY: '8' DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_PUBLINT_CONCURRENCY: '8' - DSH_SNAPSHOT_MAX_CONCURRENCY: '32' + # Failover halves snapshot concurrency for the shared 64-core VM. + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '16' || '32' }} steps: - uses: actions/checkout@v6 with: @@ -140,7 +168,9 @@ jobs: - name: Restore built tree run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz" + # Skipped under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} From 68e280ce4ff86629ea0443a012d7c7080289ce4d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 05:22:28 +0800 Subject: [PATCH 10/75] docs(ci): make the failover runbook a conforming dated Agent Note The failover runbook landed as .agents/notes/implemented/process/ci-failover-runbook.md, which fails three doc-sync gates: the classification/format gates require a yyyy-mm-dd-topic.md filename and the implemented Agent Note skeleton (Problem/Decision/Alternatives/Consequences), and the bilingual pairing gate requires cross-note link targets to match between the two language sides. Rename to 2026-07-26-ci-failover-runbook.md/.zh.md, reshape both sides into the implemented skeleton (the runbook steps live in bespoke sections under Decision), point the sibling topology note and the ci.yml comment at the dated filename, and make both sides link the canonical .md per the bilingual convention. Re-recorded the i18n pairing records. --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 2 +- ... 2026-07-26-ci-failover-runbook.i18n.yaml} | 4 +- .../process/2026-07-26-ci-failover-runbook.md | 49 +++++++++++++++++++ .../2026-07-26-ci-failover-runbook.zh.md | 49 +++++++++++++++++++ .../process/ci-failover-runbook.md | 33 ------------- .../process/ci-failover-runbook.zh.md | 33 ------------- .github/workflows/ci.yml | 2 +- 9 files changed, 105 insertions(+), 73 deletions(-) rename .agents/notes/implemented/process/{ci-failover-runbook.i18n.yaml => 2026-07-26-ci-failover-runbook.i18n.yaml} (65%) create mode 100644 .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md create mode 100644 .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md delete mode 100644 .agents/notes/implemented/process/ci-failover-runbook.md delete mode 100644 .agents/notes/implemented/process/ci-failover-runbook.zh.md diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index b2d20fb999..84a10e5ab9 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: 6e989a908b1faa363d04746e4efaa1a77358be9d -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 02c2ab405ec10dd381b051581d72662ec342e21e +2026-07-22-evidence-based-larger-hosted-runners.md: 21e602b2b5850176df981dcf448f4f827b756719 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: ba49ff18ac304f4078d4c8ebfd00bb1a85ada0b3 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 6e989a908b..21e602b2b5 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -52,7 +52,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 02c2ab405e..ba49ff18ac 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](ci-failover-runbook.zh.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml similarity index 65% rename from .agents/notes/implemented/process/ci-failover-runbook.i18n.yaml rename to .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 294ed38ddf..7a65ff5479 100644 --- a/.agents/notes/implemented/process/ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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 -ci-failover-runbook.md: d22c93fbedd3216e71bc24101dfa06dc606521c2 -ci-failover-runbook.zh.md: d7d26287191165cd3cb2666de4b1c7d6217ba71d +2026-07-26-ci-failover-runbook.md: 9100cf226467d06835478b13c41904bc50270b78 +2026-07-26-ci-failover-runbook.zh.md: 4ec80ae36411335a378f7979b9bca704c17732d0 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md new file mode 100644 index 0000000000..9100cf2264 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -0,0 +1,49 @@ +# Agent Note: CI failover runbook — hosted pools → in-house pool + +Status: implemented + +English | [中文](2026-07-26-ci-failover-runbook.zh.md) + +## Problem + +The three required Linux jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch a repository admin can throw without merging anything. + +## Decision + +Each of the three required Linux jobs resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by a repository admin, all three retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is admin-only repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. + +### What the in-house pool is + +`vm-backup`: one 64-core VM, four always-on systemd-managed runner instances, four registered spares. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. + +### Switch (repo admin, ~1 minute, no merge) + +1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. +2. Re-run the failed/queued required jobs (Re-run failed jobs on affected PRs, or let new pushes pick it up). +3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). + +### Capacity during failover + +Four always-on instances absorb normal PR traffic. If queues build, bring the four registered spares online on the VM (no token needed — they are already registered): + +```bash +for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done +``` + +### Switch back + +Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Stop the spare instances if they were started. + +### Trust boundary + +The variable is repository-admin-only state: a pull request can neither set it nor read a different value into effect, and the expressions live in the base branch's workflow definition. This failover path therefore adds no PR-editable route to the self-hosted pool. Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism. + +## Alternatives considered + +**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is admin-controlled state that takes effect on re-run without a merge. + +**Keep the self-hosted pool always in the required path.** Rejected because it trades hosted-pool availability for the in-house VM's, moving a single point of failure rather than adding a fallback. The variable keeps the hosted pools primary and the self-hosted pool a proven, one-action standby. + +## Consequences + +Recovering from a hosted-pool outage is a single admin variable plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md new file mode 100644 index 0000000000..4ec80ae364 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -0,0 +1,49 @@ +# Agent Note: CI 故障切换手册 — 托管池 → 自有池 + +Status: implemented + +[English](2026-07-26-ci-failover-runbook.md) | 中文 + +## 问题 + +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个仓库管理员无需合并任何代码即可触发的开关。 + +## 决策 + +三个必需的 Linux 作业各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由仓库管理员设为 `selfhosted` 时,三者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是仅限管理员的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 + +### 自有池是什么 + +`vm-backup`:一台 64 核虚拟机,4 个常驻 systemd 管理的运行器实例,另有 4 个已注册备用位。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 + +### 切换步骤(仓库管理员,约 1 分钟,无需合并) + +1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 +2. 对受影响 PR 的失败/排队作业点 Re-run failed jobs(或等新推送自然触发)。 +3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 + +### 切换期间的容量 + +4 个常驻实例可承接正常 PR 流量。若出现排队,在虚拟机上把 4 个已注册的备用位拉起(无需 token——它们已注册): + +```bash +for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done +``` + +### 切回 + +删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若启动过备用实例,将其停止。 + +### 信任边界 + +该变量是仅限仓库管理员的状态:拉取请求既不能设置它,也不能让不同的值生效,且表达式存在于基线分支的工作流定义中。因此这条故障切换路径没有增加任何可由 PR 编辑的自托管池访问途径。运行器侧的强制约束——通过组织级 runner group 把这批运行器限定到 master 引用的工作流——另行跟踪,与本机制互补。 + +## 曾考虑的替代方案 + +**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是管理员控制的状态,重跑即生效,无需合并。 + +**让自托管池长期处于必需路径中。** 否决,因为这是拿托管池的可用性去换自有虚拟机的可用性,只是搬移了单点故障而非增加回退。该变量让托管池保持主路径,自托管池作为一个经过验证、一步即可启用的热备。 + +## 后果 + +从托管池故障中恢复只需一个管理员变量加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。 diff --git a/.agents/notes/implemented/process/ci-failover-runbook.md b/.agents/notes/implemented/process/ci-failover-runbook.md deleted file mode 100644 index d22c93fbed..0000000000 --- a/.agents/notes/implemented/process/ci-failover-runbook.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: CI failover runbook — hosted pools → in-house pool - -Status: implemented - -English | [中文](ci-failover-runbook.zh.md) - -## What this is - -The three required Linux jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) resolve their runner pool through the `DSH_CI_FAILOVER` repository variable. Normally the variable is unset and they run on the hosted enterprise 32-core pools. When the hosted pools are degraded (jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails), a repository admin can retarget all three onto the in-house self-hosted pool without merging anything — merging would itself be blocked by the very checks that are failing. - -The in-house pool (`vm-backup`: one 64-core VM, four always-on systemd-managed runner instances, four registered spares) is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. Check its latest run before switching: green standby = verified-yesterday capacity. - -## Switch (repo admin, ~1 minute, no merge) - -1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. -2. Re-run the failed/queued required jobs (Re-run failed jobs on affected PRs, or let new pushes pick it up). -3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). - -## Capacity during failover - -Four always-on instances absorb normal PR traffic. If queues build, bring the four registered spares online on the VM (no token needed — they are already registered): - -```bash -for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done -``` - -## Switch back - -Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Stop the spare instances if they were started. - -## Trust boundary - -The variable is repository-admin-only state: a pull request can neither set it nor read a different value into effect, and the expressions live in the base branch's workflow definition. This failover path therefore adds no PR-editable route to the self-hosted pool. (Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism.) diff --git a/.agents/notes/implemented/process/ci-failover-runbook.zh.md b/.agents/notes/implemented/process/ci-failover-runbook.zh.md deleted file mode 100644 index d7d2628719..0000000000 --- a/.agents/notes/implemented/process/ci-failover-runbook.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: CI 故障切换手册 — 托管池 → 自有池 - -Status: implemented - -[English](ci-failover-runbook.md) | 中文 - -## 这是什么 - -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。正常情况下该变量不存在,作业运行在托管的企业级 32 核池上。当托管池发生故障(作业无限排队、企业标签消失或 GitHub 侧容量故障)时,仓库管理员无需合并任何代码即可把三个作业整体切换到公司自有的自托管池——此时合并本身正被这些失败的检查阻塞,任何"先合 PR 再切换"的方案都是死锁。 - -自有池(`vm-backup`:一台 64 核虚拟机,4 个常驻 systemd 管理的运行器实例,另有 4 个已注册备用位)由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。切换前先看该通道最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 - -## 切换步骤(仓库管理员,约 1 分钟,无需合并) - -1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 -2. 对受影响 PR 的失败/排队作业点 Re-run failed jobs(或等新推送自然触发)。 -3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 - -## 切换期间的容量 - -4 个常驻实例可承接正常 PR 流量。若出现排队,在虚拟机上把 4 个已注册的备用位拉起(无需 token——它们已注册): - -```bash -for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done -``` - -## 切回 - -删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若启动过备用实例,将其停止。 - -## 信任边界 - -该变量是仅限仓库管理员的状态:拉取请求既不能设置它,也不能让不同的值生效,且表达式存在于基线分支的工作流定义中。因此这条故障切换路径没有增加任何可由 PR 编辑的自托管池访问途径。(运行器侧的强制约束——通过组织级 runner group 把这批运行器限定到 master 引用的工作流——另行跟踪,与本机制互补。) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88722804f9..0be7655193 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: # 'selfhosted' (repo Settings → Actions → Variables; admin-only, not # PR-editable, no merge required) retargets all three onto the in-house # vm-backup pool and re-running the failed jobs is the entire switch — - # see .agents/notes/implemented/process/ci-failover-runbook.md. The + # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The # in-house pool's readiness is re-proven on every master push by the # serial-linux-selfhosted standby lane below. node-24: From 498df1d8de66d3f17ed52ec93d7ffa863604cde8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 05:44:35 +0800 Subject: [PATCH 11/75] ci: gate static lane's cache restore under failover; fix runbook recovery steps Review round on the pivoted design: - node-24 (static) kept an unconditional hosted pnpm cache restore while the coverage and consumers lanes skip it under failover. On the self-hosted VM that restore downloads ~180 MB into /home/runner, a path pnpm never reads there, adding latency and contention during an outage. Gate it with the same `vars.DSH_CI_FAILOVER != 'selfhosted'` condition so all three lanes match. - Runbook switch step 2 said "Re-run failed jobs", but the documented indefinite-queue outage leaves jobs queued (not failed), which cannot be re-run in place and do not retarget on variable change. Correct both language sides to cancel the run and re-run all jobs, or push a new commit. - The standby-lane comment still described the switch as a one-line runs-on change; it is now setting the admin-only DSH_CI_FAILOVER variable. --- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../implemented/process/2026-07-26-ci-failover-runbook.md | 2 +- .../process/2026-07-26-ci-failover-runbook.zh.md | 2 +- .github/workflows/ci.yml | 7 +++++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 7a65ff5479..a2725da1b2 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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 -2026-07-26-ci-failover-runbook.md: 9100cf226467d06835478b13c41904bc50270b78 -2026-07-26-ci-failover-runbook.zh.md: 4ec80ae36411335a378f7979b9bca704c17732d0 +2026-07-26-ci-failover-runbook.md: db8e0676ecc6eeaea16438e7868ccf9ac43887cc +2026-07-26-ci-failover-runbook.zh.md: b3b4149f460784e88ce03458fc556f402c38fa2f diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 9100cf2264..db8e0676ec 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -19,7 +19,7 @@ Each of the three required Linux jobs resolves its runner pool through the `DSH_ ### Switch (repo admin, ~1 minute, no merge) 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. -2. Re-run the failed/queued required jobs (Re-run failed jobs on affected PRs, or let new pushes pick it up). +2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. 3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). ### Capacity during failover diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 4ec80ae364..b3b4149f46 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -19,7 +19,7 @@ Status: implemented ### 切换步骤(仓库管理员,约 1 分钟,无需合并) 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 -2. 对受影响 PR 的失败/排队作业点 Re-run failed jobs(或等新推送自然触发)。 +2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 ### 切换期间的容量 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0be7655193..95b54b44f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,8 +55,10 @@ jobs: persist-credentials: false # Pull requests consume the default-branch cache but do not put cache - # compression and upload on the paid latency-critical path. + # compression and upload on the paid latency-critical path. Skipped + # under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -398,7 +400,8 @@ jobs: # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if - # the hosted pools degrade (the switch is then a one-line runs-on change). + # the hosted pools degrade (the switch is then setting the admin-only + # DSH_CI_FAILOVER variable — see the failover runbook, no merge required). # Push-triggered, so it always executes the base branch's own workflow # definition — no PR-editable path selects these runners. Non-blocking for # pull requests; no cache steps because the VM's persistent pnpm store and From 0d6bfd88563fea212a08c9d94dc3690541c8f498 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:59:01 +0800 Subject: [PATCH 12/75] refactor(process): split the process manager out of the bash executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New process/ capability family: @deepseek-ai/dsh-process owns ctx.processes — abstract ProcessManager.spawn(spec) over a fully-explicit ProcessSpawnSpec — plus the shared DSH_* managed-environment and CollectedOutput vocabulary; @deepseek-ai/dsh-process-local carries the former bash-local run.ts plumbing (detached groups, tail-keep spill-backed output, credential scrub, kill escalation, kill-and-join disposal) with no config of its own. dsh-bash-local becomes a consumer: it keeps command defaulting, the fused deadline timedOut/aborted classification, the model-friendly terminal env (now merged through the ordinary env channel), and the [stderr]-marked background read merge, and spawns through ctx.processes. Background-process lifetime moves to the manager, so an executor reload no longer kills live background work; a background spawn failure is injected once into the read path instead of being buffered as fake stderr. dsh-bash re-exports the moved vocabulary so bash consumers keep one import root; dsh-bash-sandbox only redeclares the inherited inject. Every composition loading a bash executor now loads dsh-process-local (CLI, examples, python bundled runtime, create-sdk bash feature, inline test configs). --- .../2026-07-06-timeout-deadline-library.md | 2 +- .../2026-07-06-timeout-deadline-library.zh.md | 2 +- .../2026-07-26-process-manager-seam.md | 38 +++++ apps/cli/cordis.yml | 4 + apps/cli/package.json | 1 + docs/capability-seams.md | 12 +- docs/config-catalog.md | 8 +- docs/cordis-catalog/services.md | 27 +++- docs/core-data-structures/bash.md | 28 +--- docs/core-data-structures/bash.zh.md | 28 +--- docs/core-data-structures/process.md | 133 +++++++++++++++++ docs/module-graph.md | 15 +- examples/acp-agent/composition.md | 3 + examples/acp-agent/cordis.yml | 4 + examples/cordis-agent/composition.md | 3 + examples/cordis-agent/cordis.yml | 4 + examples/headless-agent/composition.md | 3 + examples/headless-agent/cordis.yml | 4 + .../semantic-checkpoint.cordis.snapshot.yml | 4 + .../headless-agent/tests/code-mode.e2e.ts | 3 + .../tests/fixtures/goal-domain/cordis.yml | 4 + .../tests/fixtures/time-context.cordis.yml | 4 + examples/headless-agent/tests/harness.ts | 2 + examples/jsonrpc-agent/cordis.yml | 4 + examples/package.json | 1 + examples/tui-agent/composition.md | 3 + examples/tui-agent/cordis.yml | 4 + .../tests/fixtures/tui-scripted.cordis.yml | 4 + examples/tui-agent/tests/tui.snapshot.ts | 2 + packages/README.md | 1 + packages/bash/README.md | 4 +- packages/bash/bash-local/README.md | 21 ++- packages/bash/bash-local/package.json | 3 + packages/bash/bash-local/src/index.ts | 117 ++++++++------- .../bash/bash-local/tests/executor.spec.ts | 50 ++++--- packages/bash/bash-local/tsconfig.json | 3 + packages/bash/bash-sandbox/package.json | 1 + packages/bash/bash-sandbox/src/index.ts | 4 +- .../bash/bash-sandbox/tests/sandbox.spec.ts | 4 +- packages/bash/bash/package.json | 2 + packages/bash/bash/src/types.ts | 26 +--- packages/bash/bash/tsconfig.json | 3 + packages/bash/tool-bash/package.json | 1 + .../bash/tool-bash/tests/integration.spec.ts | 2 + packages/bash/tool-bash/tests/tools.spec.ts | 14 +- .../cordis/tool-cordis/src/api-catalog.ts | 30 ++++ .../examples/acp-demo/tests/built-bin.e2e.ts | 2 + .../examples/acp-demo/tests/load-path.e2e.ts | 2 + .../examples/cli-demo/tests/built-bin.e2e.ts | 2 + packages/fs/tool-fs-search/package.json | 1 + .../tool-fs-search/tests/integration.spec.ts | 2 + packages/hooks/hooks-claude/package.json | 1 + .../hooks/hooks-claude/tests/bridge.spec.ts | 4 + .../hooks-claude/tests/coverage-cases.ts | 5 + packages/hooks/hooks-codex/package.json | 1 + .../hooks/hooks-codex/tests/bridge.spec.ts | 4 + .../hooks/hooks-codex/tests/coverage-cases.ts | 4 + packages/process/README.md | 10 ++ packages/process/process-local/README.md | 27 ++++ packages/process/process-local/package.json | 39 +++++ packages/process/process-local/src/index.ts | 53 +++++++ .../process/process-local/src/invariant.ts | 30 ++++ .../process-local/src/spawn.ts} | 140 ++++-------------- .../process-local/tests/manager.spec.ts | 70 +++++++++ .../process-local/tests/spawn.spec.ts} | 124 +++++++++------- packages/process/process-local/tsconfig.json | 24 +++ packages/process/process/README.md | 26 ++++ packages/process/process/package.json | 37 +++++ packages/process/process/src/index.ts | 62 ++++++++ packages/process/process/src/invariant.ts | 22 +++ packages/process/process/src/types.ts | 128 ++++++++++++++++ .../process/process/tests/service.spec.ts | 55 +++++++ packages/process/process/tsconfig.json | 21 +++ .../sdk/helper/src/features/builtin/index.ts | 5 +- packages/subagent/subagent-spawn/package.json | 1 + .../subagent/subagent-spawn/tests/harness.ts | 2 + pnpm-lock.yaml | 60 ++++++++ python/sdk-runtime/package.json | 2 + .../runtime/cordis.yml | 4 + python/sdk/tests/test_bundled_runtime.py | 2 + scripts/doc-budgets.manifest.json | 2 +- scripts/gen-cordis-catalog.ts | 7 +- scripts/gen-doc-graphs.ts | 10 ++ scripts/gen-tool-catalog.ts | 2 + scripts/type-equiv.manifest.json | 55 ++++--- .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + vitest.config.ts | 2 +- 89 files changed, 1331 insertions(+), 365 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md create mode 100644 docs/core-data-structures/process.md create mode 100644 packages/process/README.md create mode 100644 packages/process/process-local/README.md create mode 100644 packages/process/process-local/package.json create mode 100644 packages/process/process-local/src/index.ts create mode 100644 packages/process/process-local/src/invariant.ts rename packages/{bash/bash-local/src/run.ts => process/process-local/src/spawn.ts} (66%) create mode 100644 packages/process/process-local/tests/manager.spec.ts rename packages/{bash/bash-local/tests/run.spec.ts => process/process-local/tests/spawn.spec.ts} (78%) create mode 100644 packages/process/process-local/tsconfig.json create mode 100644 packages/process/process/README.md create mode 100644 packages/process/process/package.json create mode 100644 packages/process/process/src/index.ts create mode 100644 packages/process/process/src/invariant.ts create mode 100644 packages/process/process/src/types.ts create mode 100644 packages/process/process/tests/service.spec.ts create mode 100644 packages/process/process/tsconfig.json 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 11d4b8cd48..b459a7479e 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 @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** ([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. +- **bash** ([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) 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 334914c689..e79ac6d7b6 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 @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 +- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md new file mode 100644 index 0000000000..2157259517 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md @@ -0,0 +1,38 @@ +# Agent Note: The process manager is its own seam under the bash executors (`dsh-process` / `dsh-process-local`) + +Status: implemented + +English | [中文](2026-07-26-process-manager-seam.zh.md) + +## Problem + +`dsh-bash-local` bundled two capabilities that change for different reasons: *running a bash command* (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and *running and managing a child process* (detached process groups, bounded tail-keep output with spill files, the credential scrub and `DSH_*` merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — `run.ts`, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared `DSH_*`/`CollectedOutput` vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling [task registry](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers. + +## Decision + +A new `process/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: + +- **`@deepseek-ai/dsh-process` (interface)** — the abstract `ProcessManager` owning `ctx.processes` with one method, `spawn(spec): ProcessHandle`, and the shared vocabulary: the fully-explicit `ProcessSpawnSpec` (argv, cwd, per-stream caps, spill cap, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `ProcessHandle` with non-consuming offset-based readers, `ProcessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. +- **`@deepseek-ai/dsh-process-local` (implementation)** — `LocalProcessManager` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` merge, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel. +- **`dsh-bash-local` (consumer)** — `inject: ['processes']`; maps each resolved `BashExecSpec` onto a `ProcessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. +- **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-process`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. + +Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-process-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs). + +Background-process lifetime moved from the executor to the manager: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the manager's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the manager rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta. + +## Alternatives considered + +**Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) landed: the boundary is stable and already documented in-code (`run.ts`'s module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split. + +**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.processes` in the same change.** Rejected as scope creep with real design risk: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README. + +**Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry. + +**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the manager.** Rejected: a generic process manager must not impose terminal presentation policy on non-terminal consumers; the scrub and `DSH_*` channel rules are security/identity invariants and stay, but terminal friendliness is the bash tool's choice, expressed through the ordinary env channel where an explicit caller entry still wins. + +## Consequences + +Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: `bash-local`, `bash-sandbox`); a containerized or remote process backend slots in without touching bash semantics; the shared `DSH_*`/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to `dsh-process-local` (argv-based, plus argv-validation and manager lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, manager-owned lifetime) against the real manager. + +Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the manager leaves `ctx.bash` pending on `ctx.processes` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the process seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated `readFrom(0)` reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists. diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 1d5c37cff5..5521c09b65 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -88,6 +88,10 @@ - id: workspace name: '@deepseek-ai/dsh-workspace' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash-local name: '@deepseek-ai/dsh-bash-local' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4f3bb5be35..7ef006c924 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2da9652c59..0fef36c63e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -82,10 +82,13 @@ flowchart LR pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] - pkg_bash["bash"] - svc_bash["ctx.bash
Bash executor seam"] + pkg_process["process"] + svc_processes["ctx.processes
Process manager seam"] + pkg_process_local["process-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_bash["bash"] + svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] pkg_pty["pty"] svc_pty["ctx.pty
Persistent PTY session registry"] @@ -164,6 +167,8 @@ flowchart LR pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode + pkg_process --> svc_processes + pkg_process_local --> svc_processes pkg_pty --> svc_pty pkg_pty_local --> svc_pty pkg_sandbox --> svc_sandbox @@ -234,6 +239,8 @@ flowchart LR svc_invariants --> pkg_session svc_llm --> pkg_agent_loop svc_llm --> pkg_compact_basic + svc_processes --> pkg_bash_local + svc_processes --> pkg_bash_sandbox svc_pty --> pkg_tool_pty svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_pty_local @@ -317,6 +324,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `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.processes` | `seam` | [`process`](../packages/process/process) | [`process-local`](../packages/process/process-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d794425e10..322f5c70d3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -192,6 +192,8 @@ Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examp ## `@deepseek-ai/dsh-bash-local` +Requires: `processes` + ```ts config-catalog /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -210,11 +212,11 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:17`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` -Requires: `sandbox` · `sandboxPolicy` +Requires: `processes` · `sandbox` · `sandboxPolicy` ```ts config-catalog /** @@ -2053,6 +2055,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) +- `@deepseek-ai/dsh-process-local` ([`packages/process/process-local/src/index.ts`](../packages/process/process-local/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) @@ -2073,6 +2076,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) +- `@deepseek-ai/dsh-process` — abstract `ProcessManager` ([`packages/process/process/src/index.ts`](../packages/process/process/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e14d28564a..6ea9919713 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -315,7 +315,7 @@ collect(execution: ToolExecution): DshEnvironment list(): BashEnvVariableInfo[] ``` -Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../core-data-structures/tools.md) +Types: [DshEnvironment](../core-data-structures/process.md) · [ToolExecution](../core-data-structures/tools.md) Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) @@ -829,6 +829,31 @@ Types: [Agent](../core-data-structures/core.md) Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts) +## `ctx.processes` — `ProcessManager` (abstract seam) + +Abstract process manager. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.processes` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. +- Output 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. +- ProcessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group. +- Disposal kills all still-running managed processes and awaits their exit. + +```ts cordis-catalog +/** + * Start one managed child process from a fully-specified spec; this seam + * applies no defaults. + * @param spec - argv, directory, limits, grace, cancellation, and environment. + * @returns the live process handle (readers, kill, outcome promise). + */ +abstract spawn(spec: ProcessSpawnSpec): ProcessHandle +``` + +Types: [ProcessHandle](../core-data-structures/process.md) · [ProcessSpawnSpec](../core-data-structures/process.md) + +Source: [`packages/process/process/src/index.ts:48`](../../packages/process/process/src/index.ts) + ## `ctx.pty` — `PtyService` In-process registry for replaceable PTY backends and exact-Agent sessions. diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 35cf206158..e092bfb1bf 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -2,23 +2,13 @@ English | [中文](bash.zh.md) -The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. +The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [process-manager seam](process.md). Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## Managed shell environment namespace -`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot. - -```ts type-equiv -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` -``` - -```ts type-equiv -/** Trusted DeepSeek Harness variables for one bash execution. */ -type DshEnvironment = Readonly> -``` +`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the process manager removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`. ## Request vs. spec: the `resolve()` split @@ -145,19 +135,7 @@ interface BashRunResult { } ``` -Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file: - -```ts type-equiv -/** One captured stream: the (possibly truncated) text plus recovery info. */ -interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} -``` +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`. ## File sandbox: `BashSandboxInfo` diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 0cfeb9e1a8..7ae60b9f38 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -2,23 +2,13 @@ [English](bash.md) | 中文 -bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。 +bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](process.md)之后。 源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## 受管 shell 环境命名空间 -`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;执行器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。 - -```ts type-equiv -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` -``` - -```ts type-equiv -/** Trusted DeepSeek Harness variables for one bash execution. */ -type DshEnvironment = Readonly> -``` +`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。 ## 请求与规格:`resolve()` 拆分 @@ -145,19 +135,7 @@ interface BashRunResult { } ``` -每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息。截断时,`text` 是**尾部**,完整流溢出到一个私有文件: - -```ts type-equiv -/** One captured stream: the (possibly truncated) text plus recovery info. */ -interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} -``` +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。 ## 文件沙箱:`BashSandboxInfo` diff --git a/docs/core-data-structures/process.md b/docs/core-data-structures/process.md new file mode 100644 index 0000000000..93e7a73ad6 --- /dev/null +++ b/docs/core-data-structures/process.md @@ -0,0 +1,133 @@ +# Process Manager + +The child-process manager seam is split across interface ([dsh-process](../../packages/process/process), `ctx.processes`) and implementation ([dsh-process-local](../../packages/process/process-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. + +Source: [`packages/process/process/src/types.ts`](../../packages/process/process/src/types.ts) + +## The fully-explicit spawn spec + +The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden process-manager default — decides them. `argv` is never shell-interpreted. + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every limit + * and directory is explicit, so the caller's own config — not a hidden + * process-manager default — decides them (the `dsh-bash` request/spec split + * is the owning template). + */ +interface ProcessSpawnSpec { + /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ + argv: readonly string[] + /** Working directory for the child. */ + cwd: string + /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ + stdoutMaxBytes: number + /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ + stderrMaxBytes: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes: number + /** Grace period for kill escalation and for inherited pipes after process exit. */ + graceMs: number + /** + * Abort signal — kills the process group when it fires. The caller owns + * deadlines and cause classification; this seam only reacts to the abort. + */ + signal?: AbortSignal | undefined + /** + * Bytes to write to the child's stdin, then close it. Absent (or empty) + * leaves stdin closed/empty. + */ + stdin?: string | undefined + /** + * Ordinary environment entries merged after the implementation's credential + * scrub. `DSH_*` names are rejected and belong in {@link dshEnv}. + */ + env?: Record | undefined + /** + * Harness-owned `DSH_*` variables for this execution. Implementations + * discard ambient `DSH_*` entries before merging this snapshot, so an + * unavailable current fact cannot inherit a stale value from the harness + * process, and reject non-`DSH_*` names supplied through this channel. + */ + dshEnv?: DshEnvironment | undefined +} +``` + +## Handles and offset-based reads + +A spawn returns a live handle immediately. Output readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; the consuming-cursor model the bash tool presents is consumer-owned state over these readers. + +```ts type-equiv +/** + * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL + * escalation; buffered output remains readable after exit. + */ +interface ProcessHandle { + /** Process id (group leader); -1 when the spawn itself failed. */ + readonly pid: number + /** Live stdout reader (also readable after exit). */ + readonly stdout: ProcessOutputReader + /** Live stderr reader (also readable after exit). */ + readonly stderr: ProcessOutputReader + /** Resolves when the process closes; rejects only for spawn-level failures. */ + readonly done: Promise + /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ + kill(): void +} +``` + +```ts type-equiv +/** + * Cursor-free incremental access to one live output stream. Offsets are + * whole-stream byte coordinates owned by the caller, so independent readers + * cannot consume one another's output. + */ +interface ProcessOutputReader { + /** + * Read everything captured since `fromByte`. When that offset has slid out + * of the in-memory tail window the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. + */ + readFrom(fromByte: number): ProcessOutputRead +} +``` + +```ts type-equiv +/** One incremental {@link ProcessOutputReader.readFrom} read. */ +interface ProcessOutputRead { + /** Stream text from the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the in-memory tail window. */ + lossy: boolean + /** Path to the full-stream spill file, when one was created and remains intact. */ + spillPath?: string +} +``` + +## Outcomes carry no cause classification + +`done` reports raw exit facts. The manager kills on abort but never decides why — the caller reads the deadline signal it owns to classify timeout versus cancellation (the bash executor's `timedOut`/`aborted` split). + +```ts type-equiv +/** + * Raw outcome of one closed process. Deliberately carries NO timeout or + * cancellation classification: the manager kills on abort but does not decide + * why — the caller reads the signal it owns to classify causes. + */ +interface ProcessOutcome { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null + stdout: CollectedOutput + stderr: CollectedOutput +} +``` + +## Service behavior + +The abstract [`ProcessManager`](../../packages/process/process/src/index.ts) seam defines `spawn` only; [`LocalProcessManager`](../../packages/process/process-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-process`](../../packages/process/process/README.md) for the seam contract and [`dsh-process-local`](../../packages/process/process-local/README.md) for the mechanics. diff --git a/docs/module-graph.md b/docs/module-graph.md index 87e0f7c0d5..10e6ab65a6 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -184,6 +184,10 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_process["packages/process"] + pkg_process["process"] + pkg_process_local["process-local"] + end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -243,6 +247,7 @@ flowchart TD pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_process --> pkg_invariants pkg_storage --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants @@ -266,6 +271,8 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots pkg_client_ui_workspace --> pkg_invariants + pkg_process_local --> pkg_invariants + pkg_process_local --> pkg_process pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_telemetry --> pkg_brand @@ -307,6 +314,7 @@ flowchart TD pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_bash --> pkg_invariants + pkg_bash --> pkg_process pkg_bash --> pkg_sandbox pkg_fs --> pkg_brand pkg_fs --> pkg_invariants @@ -371,6 +379,7 @@ flowchart TD pkg_goal --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_process pkg_bash_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants @@ -828,6 +837,7 @@ flowchart TD | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`process`](../packages/process/process) | `process` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | @@ -836,6 +846,7 @@ flowchart TD | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`process-local`](../packages/process/process-local) | `process` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -850,7 +861,7 @@ flowchart TD | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`sandbox`](../packages/sandbox/sandbox) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -869,7 +880,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 8d112f2910..e4660ed8da 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -14,6 +14,8 @@ flowchart LR cfg --> plugin_acp_sandbox plugin_acp_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"] cfg --> plugin_acp_sandbox_policy + plugin_acp_processes["processes
@deepseek-ai/dsh-process-local"] + cfg --> plugin_acp_processes plugin_acp_bash["bash
@deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_acp_bash plugin_acp_approval["approval
@deepseek-ai/dsh-user-approval"] @@ -68,6 +70,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | +| `processes` | `@deepseek-ai/dsh-process-local` | | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index fa7f6ad4ef..38994b1826 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -33,6 +33,10 @@ mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" workspaceRoot: !!js process.cwd() +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-sandbox' config: diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 3cbcb0e571..78eb186e37 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_cordis_hmr plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_cordis_llm_deepseek + plugin_cordis_processes["processes
@deepseek-ai/dsh-process-local"] + cfg --> plugin_cordis_processes plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_cordis_bash plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -39,6 +41,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `processes` | `@deepseek-ai/dsh-process-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 6144d3ae4d..051c3a5662 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -25,6 +25,10 @@ # Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an # ordinary tool whose calls make the mounted listeners observably fire. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index e467d32833..98ef6cd167 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -10,6 +10,8 @@ flowchart LR cfg["examples/headless-agent
cordis.yml"] plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek + plugin_headless_processes["processes
@deepseek-ai/dsh-process-local"] + cfg --> plugin_headless_processes plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_headless_bash plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] @@ -54,6 +56,7 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `processes` | `@deepseek-ai/dsh-process-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 944fe7e180..261eaa6d20 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -19,6 +19,10 @@ - id: deepseek-v4-flash contextWindow: 128000 +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml index fc7959e25d..66fbca2e6a 100644 --- a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml +++ b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml @@ -17,6 +17,10 @@ file: !!js process.env.DSH_SNAPSHOT_FILE overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index bb8fdfe260..6f36b1f8c9 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' @@ -55,6 +56,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentRegistry) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) + await harness.plugin(LocalProcessManager) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -114,6 +116,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { const harness = await typedCodeModeHarness() await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) + await harness.plugin(LocalProcessManager) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml index bc9b71685e..d20c05b785 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml +++ b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml @@ -2,6 +2,10 @@ - id: cli-mock-llm name: '../cli-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml index 91ba8a1254..1050d32376 100644 --- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -2,6 +2,10 @@ - id: time-context-mock-llm name: './time-context-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index e1edc1dadd..cf5f30aaee 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -59,6 +60,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index f0eb7ea6d8..7576b06cbf 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -17,6 +17,10 @@ thinking: enabled reasoningEffort: max +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/package.json b/examples/package.json index 8a81d399b8..183aa03111 100644 --- a/examples/package.json +++ b/examples/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-process-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index fd6d163952..9c64a95fbb 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_hmr plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_processes["processes
@deepseek-ai/dsh-process-local"] + cfg --> plugin_tui_processes plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_tui_bash plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] @@ -69,6 +71,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `processes` | `@deepseek-ai/dsh-process-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index e96ef680b2..9a67ce4f77 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -21,6 +21,10 @@ reasoningEffort: max # Local executor for the app bundle's bash tool. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index 307c5a1dfe..f25cafc64b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -4,6 +4,10 @@ - id: scripted-llm name: './tui-scripted-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 26ba64f23b..795f21a794 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -8,6 +8,7 @@ import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -204,6 +205,7 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(TokenMeterService) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) diff --git a/packages/README.md b/packages/README.md index cffc4a0554..f9707a178d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages live at `packages///`; groups are containers, while names r | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | +| [`process/`](process/README.md) | Child-process manager capability family: spawn seam + local process-group implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | | [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | diff --git a/packages/bash/README.md b/packages/bash/README.md index 2e2bb5692a..57b0ffcf9e 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -4,8 +4,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent | Package | Role | ctx key | |---|---|---| -| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` | -| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | +| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`process/`](../process/README.md) seam) | `ctx.bash` | +| `bash-local/` | Local `BashExecutor` implementation over the [`process/`](../process/README.md) manager (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 5d99161fc0..5fb4bd0a39 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-bash-local -Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. +Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-process`](../../process/process/README.md) manager: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.processes`, 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 process manager's. -The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. +The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`. ## Config @@ -22,11 +22,11 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: -- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. -- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. 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-surface.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 `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. +- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the manager explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-process-local`](../../process/process-local/README.md) mechanics. A foreground `BashExecRequest.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` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the manager'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-surface.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 `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the manager's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the manager, so it survives executor reloads and dies (killed and joined) with the manager's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. ## Model Experience @@ -40,8 +40,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, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. -- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. +- **POSIX-only** — the `bash` binary is hardcoded, and the underlying manager's group semantics are POSIX; Windows is unsupported. +- **A background spawn-failure note is single-delivery** — the manager 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. -The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. +Scrub-heuristic and spill-retention caveats live with [`dsh-process-local`](../../process/process-local/README.md), which owns those mechanics. diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index 152c2db28b..edfcafabe5 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-process": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,6 +39,8 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-process": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 2c25701fb9..5cb99b3caa 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,7 +1,10 @@ /** - * Local-subprocess implementation of the bash executor seam. Each command runs - * as `bash -c` in its own process group; disposal kills and joins live groups. - * Execution policy belongs in `tools/pre-execute` or a sandboxing executor. + * Local implementation of the bash executor seam over the process-manager + * seam. Each command runs as `bash -c` in a managed process group spawned + * through `ctx.processes`; this executor owns command defaulting, deadlines + * and cause classification, the model-friendly terminal environment, and the + * model-facing stdout/stderr merge for background reads. Execution policy + * belongs in `tools/pre-execute` or a sandboxing executor. * @module @deepseek-ai/dsh-bash-local */ @@ -9,9 +12,28 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' +import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts' -import type { RunInternals, RunningBash } from './run.ts' + +/** + * Model-friendly environment overrides: disable colors, pagers, and + * interactive terminal features that would garble tool output (the same set + * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — + * merged into the ordinary env channel, so a trusted caller's own entry still + * wins; the process manager applies its credential scrub independently. + */ +export const ENV_OVERRIDES = { + NO_COLOR: '1', + TERM: 'dumb', + PAGER: 'cat', + GIT_PAGER: 'cat', +} as const + +/** 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). */ +const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -39,10 +61,15 @@ function assertPositiveFinite(name: string, value: number): void { } /** - * Local bash executor with bounded output, spill files, and process-group - * `SIGTERM` to `SIGKILL` escalation. + * Local bash executor over `ctx.processes`. Bounded output, spill files, and + * process-group SIGTERM→SIGKILL escalation are the process manager'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. */ export class LocalBashExecutor extends BashExecutor { + static inject = ['processes'] + static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), @@ -52,11 +79,6 @@ export class LocalBashExecutor extends BashExecutor { graceMs: z.number().default(DEFAULT_GRACE_MS), }) - /** Live processes retained only so disposal can kill and join them. */ - private live = new Map() - /** Test seam: spill knobs forwarded to runBash. */ - internals: RunInternals = {} - /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig @@ -69,17 +91,6 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) - ctx.effect(() => async () => { - // Await closure so even a TERM-trapping child cannot outlive the fiber. - const pending: Promise[] = [] - for (const [proc, running] of this.live) { - proc.status = 'killed' - running.kill() - pending.push(proc.done) - } - this.live.clear() - await Promise.all(pending) - }, 'local bash teardown') } /** @@ -105,7 +116,7 @@ export class LocalBashExecutor extends BashExecutor { stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, - // no config default. run.ts owns the scrub and merge order. + // no config default. The process manager owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, @@ -116,21 +127,27 @@ export class LocalBashExecutor extends BashExecutor { } } - async run(spec: BashExecSpec): Promise { - // One deadline combines timeout and upstream cancellation; disposal clears its timer. - using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const outcome = await runBash({ - command: spec.command, + /** Map one resolved bash spec onto a fully-specified process spawn. */ + // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): ProcessSpawnSpec { + return { + argv: ['bash', '-c', spec.command], cwd: spec.workdir, - stdoutMaxBytes: spec.stdoutMaxBytes, + stdoutMaxBytes, stderrMaxBytes: this.config.maxOutputBytes, maxSpillBytes: this.config.maxSpillBytes, graceMs: this.config.graceMs, - signal: d.signal, + signal, stdin: spec.stdin, - env: spec.env, + env: { ...ENV_OVERRIDES, ...spec.env }, dshEnv: spec.dshEnv, - }, this.internals).done + } + } + + async run(spec: BashExecSpec): Promise { + // One deadline combines timeout and upstream cancellation; disposal clears its timer. + using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') + const outcome = await this.ctx.processes.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut @@ -139,18 +156,16 @@ export class LocalBashExecutor extends BashExecutor { start(spec: BashExecSpec): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = runBash({ - command: spec.command, - cwd: spec.workdir, - stdoutMaxBytes: this.config.maxOutputBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, - graceMs: this.config.graceMs, - signal: spec.signal, - stdin: spec.stdin, - env: spec.env, - dshEnv: spec.dshEnv, - }, this.internals) + const running = this.ctx.processes.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + + // A spawn failure produces no process output, so the manager 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 + } let stdoutOffset = 0 let stderrOffset = 0 @@ -166,13 +181,11 @@ export class LocalBashExecutor extends BashExecutor { proc.exitCode = outcome.exitCode proc.signal = outcome.signal this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' - running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`)) - this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote) }), readOutput: (): BashProcessRead => { const out = running.stdout.readFrom(stdoutOffset) @@ -180,11 +193,14 @@ export class LocalBashExecutor extends BashExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // 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' : '' const delta = out.text - + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '') + + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') return { delta, lossy: out.lossy || err.lossy, @@ -199,7 +215,6 @@ export class LocalBashExecutor extends BashExecutor { return true }, } - this.live.set(proc, running) return proc } diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 2e24addb3b..aa5eb2fb92 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -4,16 +4,18 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) async function setup(config: ConstructorParameters[1] = {}) { const ctx = new Context() + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } // A short kill grace via the REAL config path, so escalation tests stay fast. await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } return { ctx, bash } } @@ -293,44 +295,52 @@ describe('LocalBashExecutor.start (background process handles)', () => { }) }) -describe('LocalBashExecutor disposal', () => { - it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => { +describe('process lifecycle ownership (the manager, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the process manager', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } + const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } // The child prints its own pid ($$ = the detached bash group leader) so // the test can probe liveness through the public read surface alone. - const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' })) + const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' })) const pid = Number((await readUntil(proc, '\n')).trim()) expect(Number.isInteger(pid) && pid > 0).toBe(true) - await fiber.dispose() - // Disposal itself waited: the pid must already be gone, no grace left — - // even for a TERM-trapping child held until the SIGKILL escalation landed. + // Reloading/disposing the executor no longer kills backend work — the + // handle stays live and readable, mirroring the task runtime's + // registrations-outlive-producer-fibers contract. + await executorFiber.dispose() + expect(proc.status).toBe('running') + expect(() => process.kill(pid, 0)).not.toThrow() + + // Manager disposal kills the group and AWAITS its exit (no orphans). + await managerFiber.dispose() expect(() => process.kill(pid, 0)).toThrow() - expect(proc.status).toBe('killed') await proc.done + expect(proc.status).toBe('killed') }) - it('settled processes already left the live map: dispose does not touch them', async () => { + it('manager disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } const finished = bash.start(bash.resolve({ command: 'echo done' })) await finished.done expect(finished.status).toBe('completed') - const running = bash.start(bash.resolve({ command: 'sleep 60' })) + const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' })) + await readUntil(trapping, 'armed') - await fiber.dispose() - // The teardown marks every LIVE entry killed; a settled process had - // already left the map, so its status stays completed. + await managerFiber.dispose() + // A settled process was untouched; the live one died by escalation. expect(finished.status).toBe('completed') - expect(running.status).toBe('killed') - await running.done - expect(running.signal).toBe('SIGTERM') + await trapping.done + expect(trapping.status).toBe('killed') + expect(trapping.signal).toBe('SIGKILL') }) }) diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index a55c76f00a..ce24865d49 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../bash/bash" }, + { + "path": "../../process/process" + }, { "path": "../../support/invariants" } diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 9faf613c71..b8737e0e68 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index b889692f3c..3f0fc0c55d 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -34,7 +34,7 @@ export type Config = LocalConfig * mode; `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { - static inject = ['sandbox', 'sandboxPolicy'] + static override inject = ['processes', 'sandbox', 'sandboxPolicy'] // No own Config: the sandbox default (mode + workspaceRoot) moved to // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config @@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Wrap one shell command via the `ctx.sandbox` provider: hand over the * exact `['bash', '-c', command]` argv this executor would spawn, get back * the confined argv, and re-assemble it into the `exec …` command string - * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns + * the inherited spawn path runs (the outer `bash -c` the process manager spawns * `exec`s into the runner, so no extra shell lingers). Provider errors * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. */ diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 6e8f2229a0..3510c71737 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -15,6 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' @@ -58,9 +59,10 @@ async function setup( ...mode !== undefined ? { mode } : {}, ...workspaceRoot !== undefined ? { workspaceRoot } : {}, }) + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig }) const bash = ctx.bash as SandboxBashExecutor - bash.internals = { spillDir } return { ctx, bash, calls } } diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 93497df719..13c31ee2de 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -28,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-process": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-process": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index a504513417..5928687e93 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -1,19 +1,17 @@ /** * Execution types for the bash executor seam. Background task semantics belong - * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. + * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The + * managed-environment and captured-output vocabulary is owned by the + * process-manager seam and re-exported here so bash consumers keep one import + * root. * @module dsh-bash/types */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-process' -/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ -export const DSH_ENV_PREFIX = 'DSH_' as const - -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` - -/** Trusted DeepSeek Harness variables for one bash execution. */ -export type DshEnvironment = Readonly> +export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-process' /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -110,16 +108,6 @@ export interface BashExecSpec { sandboxPolicy: SandboxExecutionPolicy | undefined } -/** One captured stream: the (possibly truncated) text plus recovery info. */ -export interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} - /** The outcome of one completed (or killed) foreground run. */ export interface BashRunResult { /** Exit code; null when the process died from a signal. */ diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 51175e6080..ad38e8e3e1 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../process/process" + }, { "path": "../../sandbox/sandbox" }, diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index a89e147e0e..821e9706c9 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index e1315c232a..1a1d8bb4db 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -29,6 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c2b0c3d31b..3f22551b83 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' @@ -32,8 +33,9 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -46,8 +48,9 @@ async function setupWithTasks() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -275,8 +278,9 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -383,6 +387,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, {}) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) @@ -400,6 +405,7 @@ describe('bash tool', () => { // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, {}) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.tools.schemas()).toHaveLength(1) @@ -411,6 +417,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, {}) ToolBash.apply(ctx, {}) const schema = ctx.tools.schemas()[0]! @@ -526,6 +533,7 @@ describe('background execution through the task runtime', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3681efa015..a15c306e2f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -426,6 +426,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'processes', + summary: 'Abstract process manager.', + methods: [ + { + signature: 'abstract spawn(spec: ProcessSpawnSpec): ProcessHandle', + jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */', + }, + ], + }, { key: 'pty', summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.', @@ -1760,6 +1770,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PresetSpec', declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', }, + { + name: 'ProcessHandle', + declaration: 'export interface ProcessHandle {\n readonly pid: number;\n readonly stdout: ProcessOutputReader;\n readonly stderr: ProcessOutputReader;\n readonly done: Promise;\n kill(): void;\n}', + }, + { + name: 'ProcessOutcome', + declaration: 'export interface ProcessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}', + }, + { + name: 'ProcessOutputRead', + declaration: 'export interface ProcessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}', + }, + { + name: 'ProcessOutputReader', + declaration: 'export interface ProcessOutputReader {\n readFrom(fromByte: number): ProcessOutputRead;\n}', + }, + { + name: 'ProcessSpawnSpec', + declaration: 'export interface ProcessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + }, { name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}', diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 02e82ac5ac..3c101f14b5 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -94,6 +94,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', ' name: \'./mock-llm.mjs\'', + '- id: processes', + ' name: \'@deepseek-ai/dsh-process-local\'', '- id: bash', ' name: \'@deepseek-ai/dsh-bash-local\'', '- id: acp-agent', diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 2c507a0bad..624db3d97a 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -35,6 +35,8 @@ const CORDIS_YML = ` name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY +- id: processes + name: '@deepseek-ai/dsh-process-local' - id: bash name: '@deepseek-ai/dsh-bash-local' - id: acp-agent diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 5c3a6ad62e..0dcca62291 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -80,6 +80,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', " name: './mock-llm.ts'", + '- id: processes', + " name: '@deepseek-ai/dsh-process-local'", '- id: bash', " name: '@deepseek-ai/dsh-bash-local'", '- id: cli-agent', diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 45de8f5e4a..c0abc66a50 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index c497d7e428..3ba8e86c36 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -18,6 +18,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' const testToolSignal = new AbortController().signal @@ -61,6 +62,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 }) await ctx.plugin(ToolFsSearch) }) diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 34696ab625..bf333718fa 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index f5fd5702d5..3cf70d569a 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -52,6 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -353,6 +355,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -374,6 +377,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 8a3c44c5bd..1470c5f9a9 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -41,6 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath, ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -359,6 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) // Direct apply with only configPath — bypasses schemastery's defaults, so // the bridge must run on the raw minimal config (the per-hook timeout is @@ -657,6 +660,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the session cwd). + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -686,6 +690,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the child session cwd). + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 5d583baafe..472e010a93 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 923a4bf8b5..eb34a5c1f8 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -41,6 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -164,6 +166,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() @@ -186,6 +189,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index ebb2164902..4bfaf4f98a 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -31,6 +32,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -310,6 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. @@ -619,6 +622,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/process/README.md b/packages/process/README.md new file mode 100644 index 0000000000..956f08e3fe --- /dev/null +++ b/packages/process/README.md @@ -0,0 +1,10 @@ +# process/ — child-process manager capability family + +The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [process-manager seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md). + +| Package | ctx key | Role | +|---|---|---| +| [`process`](process/README.md) (`@deepseek-ai/dsh-process`) | `ctx.processes` | The seam: abstract `ProcessManager.spawn(spec)`, the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary | +| [`process-local`](process-local/README.md) (`@deepseek-ai/dsh-process-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal | + +The manager 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/process/process-local/README.md b/packages/process/process-local/README.md new file mode 100644 index 0000000000..595e42af2f --- /dev/null +++ b/packages/process/process-local/README.md @@ -0,0 +1,27 @@ +# @deepseek-ai/dsh-process-local + +Local-subprocess implementation of the [`@deepseek-ai/dsh-process`](../process/README.md) manager seam: `LocalProcessManager` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). + +## Behavior (and where it came from) + +- **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; 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 + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. 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-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Offset-based reads** — `ProcessHandle` readers return deltas in whole-stream byte coordinates; the manager never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist. +- **Kill-and-join disposal** — the manager retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement. + +## Model Experience + +Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **POSIX-only** — detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. +- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. +- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. + +The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring. diff --git a/packages/process/process-local/package.json b/packages/process/process-local/package.json new file mode 100644 index 0000000000..22d51aadff --- /dev/null +++ b/packages/process/process-local/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-process-local", + "description": "Local-subprocess implementation of the DeepSeek Harness process-manager seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-process": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-process": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/process/process-local/src/index.ts b/packages/process/process-local/src/index.ts new file mode 100644 index 0000000000..6971a58a71 --- /dev/null +++ b/packages/process/process-local/src/index.ts @@ -0,0 +1,53 @@ +/** + * Local-subprocess implementation of the process-manager seam. Each spawn is + * a detached process group with bounded, spill-backed output; disposal kills + * and joins live groups. It has no config: every limit arrives on the spec, + * so the deployment-varying choices stay with the calling seam's config (the + * bash executor's, today). + * @module @deepseek-ai/dsh-process-local + */ + +import { Context } from 'cordis' +import { ProcessManager } from '@deepseek-ai/dsh-process' +import type { ProcessHandle, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import { spawnProcess } from './spawn.ts' +import type { SpawnInternals } from './spawn.ts' + +/** + * Local process manager: detached process groups, tail-keep truncation with + * bounded spill files, credential-scrubbed environment, and group + * SIGTERM→grace→SIGKILL escalation. + */ +export class LocalProcessManager extends ProcessManager { + /** Live handles retained only so disposal can kill and join them. */ + private live = new Set() + /** Test seam: spill knobs forwarded to spawnProcess. */ + internals: SpawnInternals = {} + + constructor(ctx: Context) { + super(ctx) + ctx.effect(() => async () => { + // Await closure so even a TERM-trapping child cannot outlive the fiber. + const pending: Promise[] = [] + for (const handle of this.live) { + handle.kill() + // Spawn-failure rejections already settled and left the live set. + pending.push(handle.done.catch(() => {})) + } + this.live.clear() + await Promise.all(pending) + }, 'local process-manager teardown') + } + + spawn(spec: ProcessSpawnSpec): ProcessHandle { + const handle = spawnProcess(spec, this.internals) + this.live.add(handle) + handle.done.then( + () => { this.live.delete(handle) }, + () => { this.live.delete(handle) }, + ) + return handle + } +} + +export default LocalProcessManager diff --git a/packages/process/process-local/src/invariant.ts b/packages/process/process-local/src/invariant.ts new file mode 100644 index 0000000000..fa6c0c810e --- /dev/null +++ b/packages/process/process-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-process-local`. + * @module @deepseek-ai/dsh-process-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-process-local' + +/** Cordis companion plugin name. */ +export const name = 'process-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash-local/src/run.ts b/packages/process/process-local/src/spawn.ts similarity index 66% rename from packages/bash/bash-local/src/run.ts rename to packages/process/process-local/src/spawn.ts index 600e920c96..747b85cf13 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/process/process-local/src/spawn.ts @@ -1,8 +1,9 @@ /** - * Process plumbing for the local bash executor: detached process-group spawn, - * tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer - * reacts to an abort signal; the executor owns deadlines and classifies causes. - * @module dsh-bash-local/run + * Process plumbing for the local process manager: detached process-group + * spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation. + * This layer reacts to an abort signal; callers own deadlines and classify + * causes. + * @module dsh-process-local/spawn */ import { type ChildProcessByStdio, spawn } from 'node:child_process' @@ -11,23 +12,11 @@ import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process' +import type { CollectedOutput, DshEnvironment, ProcessHandle, ProcessOutcome, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' /** - * Model-friendly environment overrides: disable colors, pagers, and - * interactive terminal features that would garble tool output (the same set - * Codex hardcodes; Claude Code achieves it via TERM=dumb). - */ -export const ENV_OVERRIDES = { - NO_COLOR: '1', - TERM: 'dumb', - PAGER: 'cat', - GIT_PAGER: 'cat', -} as const - -/** - * Credential-shaped env vars are NOT forwarded to commands (the harness's + * Credential-shaped env vars are NOT forwarded to children (the harness's * own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or * spill files). Same default pattern as Codex's env policy; a future config * can whitelist specific vars when a workflow genuinely needs one. @@ -35,10 +24,10 @@ export const ENV_OVERRIDES = { export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** - * Build a child environment from scrubbed ambient values, terminal overrides, - * ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed - * names are removed; ordinary and managed entries reject the other channel's - * namespace before `dshEnv` merges last. + * Build a child environment from scrubbed ambient values, ordinary caller + * entries, and a managed `DSH_*` snapshot. Ambient managed names are removed; + * ordinary and managed entries reject the other channel's namespace before + * `dshEnv` merges last. * @param extra - caller entries; `DSH_*` names are rejected. * @param dshEnv - managed entries; non-`DSH_*` names are rejected. * @returns the environment to hand to `spawn` for the child process. @@ -53,77 +42,23 @@ export function childEnv( } for (const key of Object.keys(extra ?? {})) { if (key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`) + throw new Error(`ordinary child env cannot set reserved variable "${key}"; use dshEnv`) } } for (const key of Object.keys(dshEnv ?? {})) { if (!key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`) + throw new Error(`managed child env cannot set ordinary variable "${key}"; use env`) } } - return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv } -} - -/** What to run and under which limits (resolved — no defaults in here). */ -export interface SpawnSpec { - command: string - cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ - graceMs: number - /** - * Abort signal — kills the process group when it fires. The executor owns - * timing: `run()` passes a fused timeout/cancel deadline signal (see - * `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal. - * runBash only listens and kills; it does NOT classify why (the executor - * reads the signal's reason afterward). - */ - signal?: AbortSignal | undefined - /** - * Bytes to write to the child's stdin, then close it. Absent (or empty) - * leaves stdin closed/empty. Set by in-process plugins (the hooks bridges); - * the model-facing `dsh-tool-bash` tool does not thread model input here. - */ - stdin?: string | undefined - /** - * Ordinary environment entries merged after the credential scrub and - * terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`. - */ - env?: Record | undefined - /** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */ - dshEnv?: DshEnvironment | undefined -} - -/** - * Raw outcome of one closed process (before result shaping). Deliberately - * carries NO timeout/cancel classification: runBash kills on abort but does not - * decide why — the executor's `run()`/`start()` reads the deadline signal it - * owns to classify `timedOut`/`aborted` (see the package README). - */ -export interface SpawnOutcome { - exitCode: number | null - signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput + return { ...env, ...extra, ...dshEnv } } /** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */ -export interface RunInternals { +export interface SpawnInternals { /** Directory for spill files (defaults to the OS temp dir). */ spillDir?: string } -/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ -export const DEFAULT_GRACE_MS = 3_000 - -/** Default per-stream spill cap (the `maxSpillBytes` config). */ -export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 - let spillCounter = 0 let defaultSpillDir: string | undefined @@ -133,7 +68,7 @@ let defaultSpillDir: string | undefined * other local users read command output or pre-create symlinks. */ function privateSpillDir(): string { - defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-')) + defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-proc-')) return defaultSpillDir } @@ -205,7 +140,7 @@ export class OutputCollector { // prediction and symlink planting in shared tmp dirs. this.spillFile = join( this.spillDir, - `dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, + `dsh-proc-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, ) this.spillFd = openSync(this.spillFile, 'wx', 0o600) for (const prior of this.chunks) writeSync(this.spillFd, prior) @@ -300,41 +235,28 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void { } /** - * A live bash child process: the promise resolves when the process closes; - * `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group. - */ -export interface RunningBash { - /** Process id (group leader); -1 when the spawn itself failed. */ - readonly pid: number - /** stdout/stderr collectors (live — background polling reads incrementally). */ - readonly stdout: OutputCollector - readonly stderr: OutputCollector - /** Resolves when the process closes; rejects only for spawn-level failures. */ - readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void -} - -/** - * Spawn one isolated `bash -c` process group and collect its output. - * Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject. - * @param spec - fully resolved command, cwd, limits, and cancellation. - * @param internals - test-only process and spill-directory overrides. + * Spawn one isolated detached process group and collect its output. + * Runtime exits resolve as {@link ProcessOutcome}; only spawn failures reject. + * @param spec - fully resolved argv, cwd, limits, and cancellation. + * @param internals - test-only spill-directory override. * @returns live process handle and outcome promise. */ -// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. -export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash { +export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = {}): ProcessHandle { const spillDir = internals.spillDir ?? privateSpillDir() if (spec.signal?.aborted) { throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) } + const [program, ...args] = spec.argv + if (program === undefined || program.length === 0) { + throw new Error('invalid argv: expected a non-empty program name at argv[0]') + } // Keep absent stdin as /dev/null; literal tuples preserve non-null output types. const env = childEnv(spec.env, spec.dshEnv) const child: ChildProcessByStdio = spec.stdin !== undefined - ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) - : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) + ? spawn(program, args, { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) + : spawn(program, args, { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir) const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir) @@ -352,7 +274,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) } - // The executor owns timeout classification; this layer only reacts to abort. + // The caller owns timeout classification; this layer only reacts to abort. const onAbort = (): void => { kill() } spec.signal?.addEventListener('abort', onAbort, { once: true }) @@ -362,7 +284,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB child.stdin.end(spec.stdin) } - const done = new Promise((resolve, reject) => { + const done = new Promise((resolve, reject) => { let settled = false let pipeDrainTimer: NodeJS.Timeout | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { diff --git a/packages/process/process-local/tests/manager.spec.ts b/packages/process/process-local/tests/manager.spec.ts new file mode 100644 index 0000000000..719dcc1d44 --- /dev/null +++ b/packages/process/process-local/tests/manager.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process' + +function spec(command: string, overrides: Partial = {}): ProcessSpawnSpec { + return { + argv: ['bash', '-c', command], + cwd: process.cwd(), + stdoutMaxBytes: 64_000, + stderrMaxBytes: 64_000, + maxSpillBytes: 64 * 1024 * 1024, + graceMs: 200, + ...overrides, + } +} + +describe('LocalProcessManager', () => { + it('registers as ctx.processes and spawns managed handles', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + const result = await ctx.processes.spawn(spec('echo managed')).done + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('managed\n') + await fiber.dispose() + }) + + it('disposal kills still-running processes and awaits their exit', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + const handle = ctx.processes.spawn(spec('sleep 60')) + await fiber.dispose() + const outcome = await handle.done + expect(outcome.signal).toBe('SIGTERM') + }) + + it('a settled process leaves the live set (disposal does not re-kill it)', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + const handle = ctx.processes.spawn(spec('true')) + const outcome = await handle.done + expect(outcome.exitCode).toBe(0) + await fiber.dispose() + }) + + it('disposal tolerates a handle whose spawn already failed', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' })) + await expect(handle.done).rejects.toThrow() + await fiber.dispose() + }) + + it('disposal contains a spawn-failure rejection that races teardown', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + // Dispose before the rejection continuation removes the handle from the + // live set, so teardown itself must swallow the rejected done. + const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' })) + await fiber.dispose() + await expect(handle.done).rejects.toThrow() + }) + + it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { + const ctx = new Context() + await ctx.plugin(LocalProcessManager) + class SecondManager extends LocalProcessManager {} + await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/) + }) +}) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/process/process-local/tests/spawn.spec.ts similarity index 78% rename from packages/bash/bash-local/tests/run.spec.ts rename to packages/process/process-local/tests/spawn.spec.ts index 91afd1aede..954196e104 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/process/process-local/tests/spawn.spec.ts @@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import type { DshEnvironment } from '@deepseek-ai/dsh-bash' -import { killGroup, OutputCollector, runBash } from '../src/run.ts' -import type { RunningBash } from '../src/run.ts' +import type { DshEnvironment } from '@deepseek-ai/dsh-process' +import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts' +import type { ProcessHandle } from '@deepseek-ai/dsh-process' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -31,11 +31,11 @@ vi.mock('node:fs', async (importOriginal) => { } }) -const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-')) +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-')) -function spec(command: string, overrides: Partial[0]> = {}) { +function spec(command: string, overrides: Partial[0]> = {}) { return { - command, + argv: ['bash', '-c', command], cwd: process.cwd(), stdoutMaxBytes: 64_000, stderrMaxBytes: 64_000, @@ -59,7 +59,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise { throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) } -async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise { +async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (running.stdout.readFrom(0).text.includes(expected)) return @@ -82,9 +82,9 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) } -describe('runBash', () => { +describe('spawnProcess', () => { it('captures stdout on success', async () => { - const result = await runBash(spec('echo hello')).done + const result = await spawnProcess(spec('echo hello')).done expect(result.exitCode).toBe(0) expect(result.signal).toBeNull() expect(result.stdout.text).toBe('hello\n') @@ -93,41 +93,43 @@ describe('runBash', () => { }) it('captures stderr separately', async () => { - const result = await runBash(spec('echo oops >&2')).done + const result = await spawnProcess(spec('echo oops >&2')).done expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('') expect(result.stderr.text).toBe('oops\n') }) it('captures both streams', async () => { - const result = await runBash(spec('echo out; echo err >&2')).done + const result = await spawnProcess(spec('echo out; echo err >&2')).done expect(result.stdout.text).toBe('out\n') expect(result.stderr.text).toBe('err\n') }) it('reports non-zero exit codes', async () => { - const result = await runBash(spec('exit 42')).done + const result = await spawnProcess(spec('exit 42')).done expect(result.exitCode).toBe(42) expect(result.signal).toBeNull() }) - it('applies model-friendly env overrides', async () => { - const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done - expect(result.stdout.text).toBe('1/dumb/cat\n') + it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => { + const result = await spawnProcess(spec('echo "${TERM:-unset}"', { + env: { TERM: 'callers-choice' }, + })).done + expect(result.stdout.text).toBe('callers-choice\n') }) it('runs in the requested cwd', async () => { - const result = await runBash(spec('pwd', { cwd: '/tmp' })).done + const result = await spawnProcess(spec('pwd', { cwd: '/tmp' })).done expect(result.stdout.text.trim()).toMatch(/\/tmp$/) }) it('kills the process group with SIGTERM when the signal fires', async () => { - // runBash owns no timer: it kills on abort. The executor drives the timeout + // spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout // by firing this signal via a deadline (see executor.spec.ts); here we // assert the kill itself lands as SIGTERM. const controller = new AbortController() const start = Date.now() - const running = runBash(spec('sleep 60', { signal: controller.signal })) + const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('deadline') }, 100) const result = await running.done expect(Date.now() - start).toBeLessThan(5_000) @@ -136,7 +138,7 @@ describe('runBash', () => { }) it('escalates to SIGKILL when SIGTERM is trapped', async () => { - const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) + const running = spawnProcess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) await waitForStdout(running, 'ready\n') running.kill() const result = await running.done @@ -147,7 +149,7 @@ describe('runBash', () => { // The subshell writes the sleep's pid then waits on it; killing the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) + const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) expect(grandchild).toBeGreaterThan(0) @@ -159,7 +161,7 @@ describe('runBash', () => { it('aborts via AbortSignal mid-run', async () => { const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) + const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('user cancelled') }, 50) const result = await running.done expect(result.signal).toBe('SIGTERM') @@ -168,17 +170,17 @@ describe('runBash', () => { it('throws when the signal is already aborted before spawn', () => { const controller = new AbortController() controller.abort('too late') - expect(() => runBash(spec('echo hi', { signal: controller.signal }))) + expect(() => spawnProcess(spec('echo hi', { signal: controller.signal }))) .toThrow(/aborted before spawn: too late/) }) it('rejects with a spawn error for a nonexistent cwd', async () => { - await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) + await expect(spawnProcess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) .rejects.toThrow(/ENOENT/) }) it('kill() is idempotent (second call does not restart escalation)', async () => { - const running = runBash(spec('sleep 60')) + const running = spawnProcess(spec('sleep 60')) running.kill() running.kill() const result = await running.done @@ -188,7 +190,7 @@ describe('runBash', () => { it('bounds inherited-pipe draining after the shell exits', async () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now() - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) + const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) const descendant = await waitForPidFile(pidFile) try { const result = await running.done @@ -204,7 +206,7 @@ describe('runBash', () => { describe('stdin and extra env (set by in-process plugins)', () => { it('writes stdin to the command and closes it', async () => { - const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done + const result = await spawnProcess(spec('cat', { stdin: 'hello from stdin\n' })).done expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('hello from stdin\n') }) @@ -212,7 +214,7 @@ describe('stdin and extra env (set by in-process plugins)', () => { it('a command that reads stdin sees EOF when none is supplied', async () => { // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no // output (it does NOT block). - const result = await runBash(spec('cat')).done + const result = await spawnProcess(spec('cat')).done expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('') }) @@ -220,41 +222,40 @@ describe('stdin and extra env (set by in-process plugins)', () => { it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. - const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done + const none = await spawnProcess(spec('test -c /dev/stdin && echo char || echo other')).done expect(none.stdout.text).toBe('char\n') - const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done + const piped = await spawnProcess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done expect(piped.stdout.text).toBe('socket\n') }) it('merges ordinary extra env entries onto the scrubbed environment', async () => { - const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { + const result = await spawnProcess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, })).done expect(result.stdout.text).toBe('alpha/beta\n') }) - it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => { - // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins. + it('an explicit extra env entry overrides the credential scrub', async () => { // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. - const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', { - env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, + const result = await spawnProcess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', { + env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, })).done - expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n') + expect(result.stdout.text).toBe('explicit-wins\n') }) it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => { // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. // The handler swallows that write error and `done` reports the child's real exit. const big = 'x'.repeat(1024 * 1024) - const result = await runBash(spec('exit 7', { stdin: big })).done + const result = await spawnProcess(spec('exit 7', { stdin: big })).done expect(result.exitCode).toBe(7) }) }) describe('output truncation and spill', () => { it('applies stdout and stderr caps independently', async () => { - const result = await runBash( + const result = await spawnProcess( spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', { stdoutMaxBytes: 500, stderrMaxBytes: 100, @@ -269,7 +270,7 @@ describe('output truncation and spill', () => { it('keeps the tail and spills the full stream to disk', async () => { // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. - const result = await runBash( + const result = await spawnProcess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done @@ -284,7 +285,7 @@ describe('output truncation and spill', () => { }) it('does not truncate output exactly at the cap', async () => { - const result = await runBash( + const result = await spawnProcess( spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done @@ -295,7 +296,7 @@ describe('output truncation and spill', () => { it('settles with the tail and no spill path when final spill close fails', async () => { failNextClose.value = true - const result = await runBash( + const result = await spawnProcess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done @@ -402,12 +403,27 @@ describe('killGroup', () => { }) it('swallows ESRCH for vanished groups', async () => { - const running = runBash(spec('true')) + const running = spawnProcess(spec('true')) await running.done expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) }) +describe('argv validation', () => { + it('rejects an empty argv before spawning', () => { + expect(() => spawnProcess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) + }) + + it('rejects an empty program name before spawning', () => { + expect(() => spawnProcess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) + }) + + it('spawns argv verbatim without shell interpretation', async () => { + const result = await spawnProcess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }).done + expect(result.stdout.text).toBe('$HOME') + }) +}) + describe('abort edge cases', () => { it('reports a fallback reason for reason-less pre-aborted signals', () => { // Real AbortControllers always set a DOMException reason; signal-like @@ -418,14 +434,14 @@ describe('abort edge cases', () => { addEventListener() {}, removeEventListener() {}, } as unknown as AbortSignal - expect(() => runBash(spec('echo hi', { signal: bare }))) + expect(() => spawnProcess(spec('echo hi', { signal: bare }))) .toThrow(/aborted before spawn: aborted/) }) it('reports the terminating signal of an externally self-killed command', async () => { - // runBash reports the raw signal; whether it counts as timeout/cancel is the + // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. - const result = await runBash(spec('kill -TERM $$')).done + const result = await spawnProcess(spec('kill -TERM $$')).done expect(result.signal).toBe('SIGTERM') }) }) @@ -436,7 +452,7 @@ describe('environment and spill-file hardening', () => { process.env.DSH_TEST_TOKEN = 'also-secret' process.env.DSH_TEST_PLAIN = 'visible' try { - const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done + const result = await spawnProcess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') } finally { delete process.env.DSH_TEST_API_KEY @@ -448,7 +464,7 @@ describe('environment and spill-file hardening', () => { it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { process.env.DSH_STALE = 'old-value' try { - const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { + const result = await spawnProcess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, })).done expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') @@ -458,33 +474,33 @@ describe('environment and spill-file hardening', () => { }) it('rejects DSH variables on the ordinary env channel', () => { - expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) + expect(() => spawnProcess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) }) it('rejects ordinary variables on the managed env channel', () => { const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment - expect(() => runBash(spec('true', { dshEnv: invalid }))) - .toThrow(/managed bash env.*PATH.*use env/) + expect(() => spawnProcess(spec('true', { dshEnv: invalid }))) + .toThrow(/managed child env.*PATH.*use env/) }) it('creates spill files with owner-only permissions and random names', async () => { - const result = await runBash( + const result = await spawnProcess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done const path = result.stdout.spillPath! - expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) + expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) const mode = statSync(path).mode & 0o777 expect(mode).toBe(0o600) }) it('defaults spills into a private per-process directory', async () => { - const result = await runBash( + const result = await spawnProcess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), ).done const dir = dirname(result.stdout.spillPath!) - expect(dir).toMatch(/dsh-bash-/) + expect(dir).toMatch(/dsh-proc-/) const mode = statSync(dir).mode & 0o777 expect(mode).toBe(0o700) }) @@ -502,7 +518,7 @@ describe('environment and spill-file hardening', () => { it('honors AbortSignal on background-style runs (no timeout)', async () => { const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) + const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort() }, 50) const result = await running.done expect(result.signal).toBe('SIGTERM') diff --git a/packages/process/process-local/tsconfig.json b/packages/process/process-local/tsconfig.json new file mode 100644 index 0000000000..5f84f780af --- /dev/null +++ b/packages/process/process-local/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../process" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/process/process/README.md b/packages/process/process/README.md new file mode 100644 index 0000000000..b53628cab3 --- /dev/null +++ b/packages/process/process/README.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-process + +The child-process manager seam (`ctx.processes`). The abstract `ProcessManager` exposes one method — `spawn(spec): ProcessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with its non-consuming offset-based output readers, `ProcessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-process-local`](../process-local/README.md). + +## Contract + +- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. +- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden process-manager default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- Output readers take whole-stream byte offsets and never consume: 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 full-stream spill file when one exists. +- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the manager reacts to the abort but never classifies why (callers own deadlines and cause classification). +- Disposal kills all still-running managed processes and awaits their exit. + +See the [process data-structure catalog](../../../docs/core-data-structures/process.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md). + +## Model Experience + +Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **One consumer family so far** — the seam's shape is proven against the bash executors only; the other in-repo spawn sites (LSP servers, PTY backends, subagent transports) keep their own bespoke process handling until their stream/lifecycle needs are re-examined against this contract. +- **POSIX group semantics are assumed** — the handle vocabulary (`pid` as group leader, group kills, SIGTERM/SIGKILL escalation) has no Windows story. diff --git a/packages/process/process/package.json b/packages/process/process/package.json new file mode 100644 index 0000000000..6210948d56 --- /dev/null +++ b/packages/process/process/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-process", + "description": "Child-process manager seam (ctx.processes) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/process/process/src/index.ts b/packages/process/process/src/index.ts new file mode 100644 index 0000000000..4fe3386503 --- /dev/null +++ b/packages/process/process/src/index.ts @@ -0,0 +1,62 @@ +/** + * The child-process manager seam (`ctx.processes`): spawn fully-specified + * commands into managed process groups with bounded, spill-backed output and + * escalated kills. Command defaulting, shell semantics, deadlines, and + * presentation belong to consumers — the bash executor seam is the owning + * template. The local implementation lives in + * `@deepseek-ai/dsh-process-local`. + * @module @deepseek-ai/dsh-process + */ + +import { Context, Service } from 'cordis' +import type { ProcessHandle, ProcessSpawnSpec } from './types.ts' + +export { DSH_ENV_PREFIX } from './types.ts' +export type { + CollectedOutput, + DshEnvironment, + DshEnvironmentKey, + ProcessHandle, + ProcessOutcome, + ProcessOutputRead, + ProcessOutputReader, + ProcessSpawnSpec, +} from './types.ts' + +declare module 'cordis' { + interface Context { + processes: ProcessManager + } +} + +/** + * Abstract process manager. Subclass, implement {@link spawn}, and load the + * subclass as a plugin — it registers as `ctx.processes` (one implementation + * per context; loading a second throws, which is cordis' standard + * duplicate-service behavior). + * + * Implementations must honor these semantics: + * - {@link spawn} returns immediately with a live handle; `done` resolves at + * process close and rejects only for spawn-level failures. + * - Output 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. + * - {@link ProcessHandle.kill} and the spec's abort signal escalate + * SIGTERM→grace→SIGKILL across the whole process group. + * - Disposal kills all still-running managed processes and awaits their exit. + */ +export abstract class ProcessManager extends Service { + constructor(ctx: Context) { + super(ctx, 'processes') + } + + /** + * Start one managed child process from a fully-specified spec; this seam + * applies no defaults. + * @param spec - argv, directory, limits, grace, cancellation, and environment. + * @returns the live process handle (readers, kill, outcome promise). + */ + abstract spawn(spec: ProcessSpawnSpec): ProcessHandle +} + +export default ProcessManager diff --git a/packages/process/process/src/invariant.ts b/packages/process/process/src/invariant.ts new file mode 100644 index 0000000000..f54476747e --- /dev/null +++ b/packages/process/process/src/invariant.ts @@ -0,0 +1,22 @@ +/** Package-owned invariant companion for the process-manager seam. @module @deepseek-ai/dsh-process/invariant */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-process' + +/** Cordis companion plugin name. */ +export const name = 'process-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this stateless seam owns spawn-spec/handle types, while implementations own observations. */ +const install: InvariantInstaller = () => {} + +/** + * Register the process-manager invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/process/process/src/types.ts b/packages/process/process/src/types.ts new file mode 100644 index 0000000000..1b81ab8c07 --- /dev/null +++ b/packages/process/process/src/types.ts @@ -0,0 +1,128 @@ +/** + * Vocabulary for the process-manager seam: fully-specified spawn requests, + * bounded output with spill recovery, and live process handles. Command + * defaulting, shell semantics, and presentation belong to consumers such as + * the bash executor seam. + * @module dsh-process/types + */ + +/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ +export const DSH_ENV_PREFIX = 'DSH_' as const + +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` + +/** Trusted DeepSeek Harness variables for one child-process execution. */ +export type DshEnvironment = Readonly> + +/** One captured stream: the (possibly truncated) text plus recovery info. */ +export interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} + +/** + * A fully-specified spawn request. This seam applies no defaults: every limit + * and directory is explicit, so the caller's own config — not a hidden + * process-manager default — decides them (the `dsh-bash` request/spec split + * is the owning template). + */ +export interface ProcessSpawnSpec { + /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ + argv: readonly string[] + /** Working directory for the child. */ + cwd: string + /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ + stdoutMaxBytes: number + /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ + stderrMaxBytes: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes: number + /** Grace period for kill escalation and for inherited pipes after process exit. */ + graceMs: number + /** + * Abort signal — kills the process group when it fires. The caller owns + * deadlines and cause classification; this seam only reacts to the abort. + */ + signal?: AbortSignal | undefined + /** + * Bytes to write to the child's stdin, then close it. Absent (or empty) + * leaves stdin closed/empty. + */ + stdin?: string | undefined + /** + * Ordinary environment entries merged after the implementation's credential + * scrub. `DSH_*` names are rejected and belong in {@link dshEnv}. + */ + env?: Record | undefined + /** + * Harness-owned `DSH_*` variables for this execution. Implementations + * discard ambient `DSH_*` entries before merging this snapshot, so an + * unavailable current fact cannot inherit a stale value from the harness + * process, and reject non-`DSH_*` names supplied through this channel. + */ + dshEnv?: DshEnvironment | undefined +} + +/** + * Raw outcome of one closed process. Deliberately carries NO timeout or + * cancellation classification: the manager kills on abort but does not decide + * why — the caller reads the signal it owns to classify causes. + */ +export interface ProcessOutcome { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null + stdout: CollectedOutput + stderr: CollectedOutput +} + +/** One incremental {@link ProcessOutputReader.readFrom} read. */ +export interface ProcessOutputRead { + /** Stream text from the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the in-memory tail window. */ + lossy: boolean + /** Path to the full-stream spill file, when one was created and remains intact. */ + spillPath?: string +} + +/** + * Cursor-free incremental access to one live output stream. Offsets are + * whole-stream byte coordinates owned by the caller, so independent readers + * cannot consume one another's output. + */ +export interface ProcessOutputReader { + /** + * Read everything captured since `fromByte`. When that offset has slid out + * of the in-memory tail window the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. + */ + readFrom(fromByte: number): ProcessOutputRead +} + +/** + * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL + * escalation; buffered output remains readable after exit. + */ +export interface ProcessHandle { + /** Process id (group leader); -1 when the spawn itself failed. */ + readonly pid: number + /** Live stdout reader (also readable after exit). */ + readonly stdout: ProcessOutputReader + /** Live stderr reader (also readable after exit). */ + readonly stderr: ProcessOutputReader + /** Resolves when the process closes; rejects only for spawn-level failures. */ + readonly done: Promise + /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ + kill(): void +} diff --git a/packages/process/process/tests/service.spec.ts b/packages/process/process/tests/service.spec.ts new file mode 100644 index 0000000000..04ffa8c9f4 --- /dev/null +++ b/packages/process/process/tests/service.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { ProcessManager } from '@deepseek-ai/dsh-process' +import type { ProcessHandle, ProcessOutputRead, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' + +/** + * Minimal concrete manager: a hand-built handle. The seam is spawn-only — + * defaulting, shell semantics, and deadlines belong to callers — so this stub + * is all an implementation owes the abstract class. + */ +class StubProcessManager extends ProcessManager { + spawn(spec: ProcessSpawnSpec): ProcessHandle { + const read: ProcessOutputRead = { text: '', nextOffset: 0, lossy: false } + let killed = false + return { + pid: spec.argv.length, + stdout: { readFrom: () => read }, + stderr: { readFrom: () => read }, + done: Promise.resolve({ + exitCode: killed ? null : 0, + signal: null, + stdout: { text: 'ok', truncated: false }, + stderr: { text: '', truncated: false }, + }), + kill: () => { killed = true }, + } + } +} + +describe('ProcessManager seam', () => { + it('a concrete subclass registers as ctx.processes and serves the abstract API', async () => { + const ctx = new Context() + await ctx.plugin(StubProcessManager) + const handle = ctx.processes.spawn({ + argv: ['true'], + cwd: '/stub', + stdoutMaxBytes: 1, + stderrMaxBytes: 1, + maxSpillBytes: 1, + graceMs: 1, + }) + expect(handle.pid).toBe(1) + expect(handle.stdout.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) + handle.kill() + const outcome = await handle.done + expect(outcome.stdout.text).toBe('ok') + }) + + it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { + const ctx = new Context() + await ctx.plugin(StubProcessManager) + class SecondManager extends StubProcessManager {} + await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/) + }) +}) diff --git a/packages/process/process/tsconfig.json b/packages/process/process/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/process/process/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 48b50977f8..a6873b75dc 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -32,7 +32,10 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry summary: 'Command execution', mode: 'exclusive', required: true, - baseResources: [{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }], + baseResources: [ + { kind: 'npm-cordis-config-entry', id: 'processes', package: '@deepseek-ai/dsh-process-local' }, + { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }, + ], options: [ { id: 'local', diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index f429025a5d..6ab0d4f45f 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 3b9342a90e..3bebb612c4 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -27,6 +28,7 @@ export async function spawnHarness(workdir: string): Promise { }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ed728d64d..c0471ffc91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../packages/process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -424,6 +427,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:* version: link:../packages/plan/plan-mode + '@deepseek-ai/dsh-process-local': + specifier: workspace:* + version: link:../packages/process/process-local '@deepseek-ai/dsh-pty': specifier: workspace:* version: link:../packages/pty/pty @@ -585,6 +591,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-process': + specifier: workspace:^ + version: link:../../process/process '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -604,6 +613,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-process': + specifier: workspace:^ + version: link:../../process/process + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -622,6 +637,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -668,6 +686,9 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -1980,6 +2001,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention @@ -2200,6 +2224,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2249,6 +2276,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2613,6 +2643,27 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/process/process: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/process/process-local: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-process': + specifier: workspace:^ + version: link:../process + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/pty/pty: devDependencies: '@deepseek-ai/dsh-agent': @@ -3569,6 +3620,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -4582,6 +4636,12 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-process': + specifier: workspace:^ + version: link:../../packages/process/process + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../packages/process/process-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index abf943793d..6fca02ccf8 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -40,6 +40,8 @@ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-process": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index 824aa03e7b..b59b94a257 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -33,6 +33,10 @@ name: '@deepseek-ai/dsh-session-checkpoint-policy' # Local bash executor; $DSH_CWD wins over the process cwd. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 6ff14fa266..755e65e00a 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -30,6 +30,8 @@ _CORDIS_YML = """\ root: './sessions' - id: session-checkpoints name: '@deepseek-ai/dsh-session-checkpoint-policy' +- id: processes + name: '@deepseek-ai/dsh-process-local' - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index c2fee63c70..a8e259d16b 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 660, - "packages/README.md": 790 + "packages/README.md": 810 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b53d1eaa12..07adb0ba0c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -64,7 +64,12 @@ export const LINK_MAP: Record = { BashExecSpec: 'bash.md', BashProcess: 'bash.md', BashRunResult: 'bash.md', - DshEnvironment: 'bash.md', + DshEnvironment: 'process.md', + ProcessHandle: 'process.md', + ProcessOutcome: 'process.md', + ProcessOutputRead: 'process.md', + ProcessOutputReader: 'process.md', + ProcessSpawnSpec: 'process.md', CodeRunRequest: 'code-runtime.md', CodeRunResult: 'code-runtime.md', CompactionResult: 'compaction.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 2551f95df9..05cec1dbad 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -59,6 +59,7 @@ const GROUP_ORDER = [ 'llm', 'core', 'goal', + 'process', 'bash', 'pty', 'sandbox', @@ -264,6 +265,15 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.', }, + { + key: 'processes', + pkg: 'process', + title: 'Process manager seam', + mode: 'seam', + implementations: ['process-local'], + consumers: ['bash-local', 'bash-sandbox'], + note: 'The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation.', + }, { key: 'bash', pkg: 'bash', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 45aa58d74e..5d787814eb 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -19,6 +19,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import PlanModeService from '@deepseek-ai/dsh-plan-mode' @@ -197,6 +198,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor) await ctx.plugin(ToolBash) }, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0310306bfa..629cfe4038 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -729,16 +729,6 @@ "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "DshEnvironmentKey", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "DshEnvironment", - "source": "packages/bash/bash/src/types.ts" - }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", @@ -759,11 +749,6 @@ "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "CollectedOutput", - "source": "packages/bash/bash/src/types.ts" - }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcess", @@ -1867,16 +1852,6 @@ "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "DshEnvironmentKey", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "DshEnvironment", - "source": "packages/bash/bash/src/types.ts" - }, { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashExecRequest", @@ -1897,11 +1872,6 @@ "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "CollectedOutput", - "source": "packages/bash/bash/src/types.ts" - }, { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashProcess", @@ -2191,6 +2161,31 @@ "doc": "docs/core-data-structures/workflow.zh.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessSpawnSpec", + "source": "packages/process/process/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessHandle", + "source": "packages/process/process/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessOutputReader", + "source": "packages/process/process/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessOutputRead", + "source": "packages/process/process/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessOutcome", + "source": "packages/process/process/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 16803b157f..dc284e9b21 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -72,6 +72,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, + 'packages/process/process': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' }, + 'packages/process/process-local': { kind: 'indirect', reason: 'The manager backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a449a34c4e..2f73278c55 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -54,6 +54,7 @@ "./packages/prompt/*/src/invariant.ts", "./packages/llm/*/src/invariant.ts", "./packages/bash/*/src/invariant.ts", + "./packages/process/*/src/invariant.ts", "./packages/code-runtime/*/src/invariant.ts", "./packages/fs/*/src/invariant.ts", "./packages/skill/*/src/invariant.ts", @@ -123,6 +124,7 @@ "./packages/llm/*/src", "./packages/bash/*/src", "./packages/pty/*/src", + "./packages/process/*/src", "./packages/code-runtime/*/src", "./packages/fs/*/src", "./packages/lsp/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 1aab67964a..c4bdc74fe1 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -81,6 +81,8 @@ { "path": "./packages/llm/llm-retry" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/cli-demo" }, + { "path": "./packages/process/process" }, + { "path": "./packages/process/process-local" }, { "path": "./packages/bash/bash" }, { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, diff --git a/vitest.config.ts b/vitest.config.ts index deb2460f11..0f2b25f627 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,7 +40,7 @@ const testIncludes = [ // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ - 'packages/bash/bash-local/tests/run.spec.ts', + 'packages/process/process-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts', From e20545c9eeda8ac2e5418146aa784a263b7b659d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:20:45 +0800 Subject: [PATCH 13/75] fix(tasks): fail loud when the abstract seam is mounted directly Review finding (Codex round 1): abstract erases at runtime and @deepseek-ai/dsh-tasks used to be the mountable registry, so a stale composition row would register a ctx.tasks with no method implementations and fail far from the misconfiguration. The seam constructor now rejects direct mounts with a load-time pointer at dsh-tasks-local; the seam suite pins the fence, the Agent Note cost paragraph records the actual behavior, and the stale tool-pty README requirement line names the implementation package. --- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +- .../2026-07-26-process-manager-seam.i18n.yaml | 6 +++ .../2026-07-26-process-manager-seam.zh.md | 38 +++++++++++++++++++ .../2026-07-26-task-registry-seam.i18n.yaml | 4 +- .../2026-07-26-task-registry-seam.md | 2 +- .../2026-07-26-task-registry-seam.zh.md | 2 +- docs/core-data-structures/bash.i18n.yaml | 4 +- packages/pty/tool-pty/README.md | 2 +- 8 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md 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 6d14977e92..9047815e3c 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 -2026-07-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4 -2026-07-06-timeout-deadline-library.zh.md: 334914c689adf54a654c5907395c29ceeeb50891 +2026-07-06-timeout-deadline-library.md: b459a7479e1d133d40c0f65464740611f3c59fcb +2026-07-06-timeout-deadline-library.zh.md: e79ac6d7b618b6dde6a487e2f3b21c180b3ef137 diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml new file mode 100644 index 0000000000..48bca24b09 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-26-process-manager-seam.md: 215725951792d23053c93da19c619c08393af1b9 +2026-07-26-process-manager-seam.zh.md: 268486de2f94d491529620941a6e00d90cb50cf0 diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md new file mode 100644 index 0000000000..268486de2f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-process` / `dsh-process-local`) + +Status: implemented + +[English](2026-07-26-process-manager-seam.md) | 中文 + +## 问题 + +`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker 监督器)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。 + +## 决策 + +新的 `process/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: + +- **`@deepseek-ai/dsh-process`(接口)**——拥有 `ctx.processes` 的抽象 `ProcessManager`(仅一个方法:`spawn(spec): ProcessHandle`),以及共享词汇:完全显式的 `ProcessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `ProcessHandle`、刻意不含超时/取消分类的 `ProcessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 +- **`@deepseek-ai/dsh-process-local`(实现)**——`LocalProcessManager`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 +- **`dsh-bash-local`(消费方)**——`inject: ['processes']`;把每个解析后的 `BashExecSpec` 映射为一个 `ProcessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 +- **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-process` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 + +如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-process-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 + +后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。 + +## 曾考虑的替代方案 + +**把进程管道留在 `dsh-bash-local` 里(维持现状)。**否决的理由与[任务注册表拆分](2026-07-26-task-registry-seam.md)得以落地的理由相同:这条边界既稳定,也早已记录在代码里(`run.ts` 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。这组堆叠变更对用户可见的动因正是这一拆分。 + +**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.processes` 上。**作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 在其唯一真实的消费方家族上得到验证后交付;其余调用点已在 seam README 中列为暂缓工作。 + +**改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 + +**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;凭据清除与 `DSH_*` 通道规则是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经普通 env 通道表达,而调用方的显式条目在该通道中依旧优先。 + +## 后果 + +换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-process-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。 + +代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.processes` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml index 409bc30c12..530e12edae 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-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 -2026-07-26-task-registry-seam.md: b785eb75a632503def10fad583f6a68477cffef6 -2026-07-26-task-registry-seam.zh.md: bfb733a5e1060c9bfe2acc6c4769aa47443d0c9e +2026-07-26-task-registry-seam.md: d550b5b081a7980cceddd3c1eb65c3a9a175906f +2026-07-26-task-registry-seam.zh.md: 1088465b908fd905900aa11479a48632fff3fe6f diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md index b785eb75a6..d550b5b081 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md @@ -32,4 +32,4 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling package implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The seam README states the contract; the implementation README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the seam keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite. -Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package — a boot that loads only `@deepseek-ai/dsh-tasks` gets a pending `ctx.tasks` and producers fail with the standard missing-service behavior rather than a bespoke message. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default. +Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package. `abstract` erases at runtime and this package name used to be the mountable registry, so the seam constructor fails loudly when mounted directly — a stale composition row gets "load an implementation such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default. diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index bfb733a5e1..1088465b90 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -32,4 +32,4 @@ Status: implemented 换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 -代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。 +代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此 seam 的构造函数在被直接挂载时会响亮失败——一条过期的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个方法残缺的 `ctx.tasks` 在远离错误配置处才失败。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。 diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 98855cdc0c..0111aa680d 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.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 -bash.md: 35cf2061588907dde41123efb01e453eb9cc929d -bash.zh.md: 0cfeb9e1a858f7057e720215c41a757588751122 +bash.md: e092bfb1bf1aa23f7eeeda41a16a1625d1698201 +bash.zh.md: 7ae60b9f38e6ca5c72e349e141132ae6ec72913d diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index f4f1e7af7e..b16cb271f1 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -66,4 +66,4 @@ Append-only; new results follow the reusable request prefix. ## Known Limitations and Deferred Work - No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed. -- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface. +- Background mode requires both `@deepseek-ai/dsh-tasks-local` and the model-facing control surface from `@deepseek-ai/dsh-tool-tasks`. From 13ce23db4bb3fc089a0a1e17f007af64b33f38ab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:27:33 +0800 Subject: [PATCH 14/75] docs(process): final translation pass on the process-seam note zh counterpart --- .../architecture/2026-07-26-process-manager-seam.i18n.yaml | 2 +- .../architecture/2026-07-26-process-manager-seam.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml index 48bca24b09..fc1952eaa6 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-26-process-manager-seam.md: 215725951792d23053c93da19c619c08393af1b9 -2026-07-26-process-manager-seam.zh.md: 268486de2f94d491529620941a6e00d90cb50cf0 +2026-07-26-process-manager-seam.zh.md: 03062f33d3d5fbafa1ce0b9bc3e723d10572cfd4 diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md index 268486de2f..03062f33d3 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker 监督器)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。 +`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。 ## 决策 From fb0d4ba56434f01cd71efb1e9685702fcd3ae708 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:54:42 +0800 Subject: [PATCH 15/75] fix(process): close review gaps from Codex round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mount LocalProcessManager in the sandbox e2e compositions (bwrap/landlock/ seatbelt + the spine multi-project e2e) and add the spine demo's dsh-process-local devDependency, so SandboxBashExecutor's new inject resolves when those suites are enabled. - Extend the Windows test/coverage skip to packages/process/* — the POSIX process-group suite moved there from packages/bash. - Update the stale disposal contract: the bash seam JSDoc, BashProcess JSDoc, and core bash doc (en+zh) now state that composition teardown (the process manager's disposal) owns kill-and-await, and an executor-only reload leaves background processes running. - Record ctx.processes in the architecture capability table and extension map (en+zh) and the root AGENTS.md layout tree; reword the timeout-library note so it describes where the plumbing and classification live today. --- AGENTS.md | 1 + docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 3 ++- docs/architecture.zh.md | 3 ++- docs/cordis-catalog/services.md | 4 ++-- docs/core-data-structures/bash.i18n.yaml | 4 ++-- docs/core-data-structures/bash.md | 7 ++++--- docs/core-data-structures/bash.zh.md | 7 ++++--- packages/bash/bash-sandbox/tests/bwrap.e2e.ts | 2 ++ packages/bash/bash-sandbox/tests/landlock.e2e.ts | 2 ++ packages/bash/bash-sandbox/tests/seatbelt.e2e.ts | 2 ++ packages/bash/bash/src/index.ts | 5 ++++- packages/bash/bash/src/types.ts | 5 +++-- packages/examples/agent-spine-demo/package.json | 1 + .../agent-spine-demo/tests/multi-project-sandbox.e2e.ts | 2 ++ pnpm-lock.yaml | 3 +++ vitest.config.ts | 1 + 17 files changed, 39 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64259784fb..abcd3e8709 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// prompt/ workspace instructions llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools + process/ child-process manager seam + local process-group impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools lsp/ language-server seam + local stdio provider + model-facing lsp tool diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 1798b46908..5293177942 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 -architecture.md: b426891c0483f42a64b597632cf1871aff79ca2d -architecture.zh.md: 13feefb6854e79ddee38602902d325a789fd7744 +architecture.md: ca2d1c70342fcef35deca7e247863511fb7c35b8 +architecture.zh.md: c3107b23f96497a5ab2184d660d085f3e8a5e527 diff --git a/docs/architecture.md b/docs/architecture.md index b426891c04..ca2d1c7034 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,6 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | +| `ctx.processes` | [`process/`](../packages/process/README.md) | managed child-process groups under the bash executors | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | @@ -177,7 +178,7 @@ New behavior attaches to a documented extension point; a loop change updates thi |---|---| | Add a model provider | register an adapter on `ctx.llm` | | Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | -| Add shell execution | implement and register a `ctx.bash` backend | +| Add shell execution | implement and register a `ctx.bash` backend (the local one spawns through `ctx.processes`) | | Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` | | Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 13feefb685..c3107b23f9 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,6 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | +| `ctx.processes` | [`process/`](../packages/process/README.md) | bash 执行器之下受管理的子进程组 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | @@ -177,7 +178,7 @@ forever: |---|---| | 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | | 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 进入提示词组装流程 | -| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端 | +| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端(本地后端通过 `ctx.processes` 生成进程) | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派该命令 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6ea9919713..b2229130bc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -257,7 +257,7 @@ Implementations must honor these semantics: - run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a BashRunResult. - start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr. - BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files. -- Disposal kills all running background processes and awaits their exit. +- A still-running background process is stopped and awaited when its owning composition tears down. With the process-manager seam that boundary is `ctx.processes` disposal, so a background process survives an executor-only reload. ```ts cordis-catalog /** @@ -286,7 +286,7 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashProcess](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:48`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/index.ts) ## `ctx.bashEnv` — `BashEnvRegistry` diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 0111aa680d..00a77885e8 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.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 -bash.md: e092bfb1bf1aa23f7eeeda41a16a1625d1698201 -bash.zh.md: 7ae60b9f38e6ca5c72e349e141132ae6ec72913d +bash.md: 0c92addf4778fa780cd432444549bfc36eeb763c +bash.zh.md: cd9d94055627937d03107ead5a4ffaeaa945d187 diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index e092bfb1bf..0c92addf47 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -170,8 +170,9 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o ```ts type-equiv /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the process manager's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ @@ -216,4 +217,4 @@ interface BashProcessRead { ## The service -`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns process groups, timeout/abort handling, bounded collectors, spill files, credential scrubbing, and disposal quiescence. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). +`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [process manager](process.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 7ae60b9f38..cd9d940556 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -170,8 +170,9 @@ interface BashSandboxInfo { ```ts type-equiv /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the process manager's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ @@ -216,4 +217,4 @@ interface BashProcessRead { ## 服务 -`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有进程组、超时/中止处理、有界收集器、spill 文件、凭据清除以及 dispose(资源释放)后完全停稳。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 +`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](process.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 87bcffe9df..ad6ecab6f1 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' /** * Keyless integration of the real provider and executor through public run/start paths. With @@ -42,6 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 3ce944b07c..aac9b766e8 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -9,6 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap @@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 6c212ee546..87e76d95e1 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' /** * Keyless macOS integration of the real provider and executor through public run/start paths. @@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index d9eedee052..ac03e73a91 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -43,7 +43,10 @@ declare module 'cordis' { * failures settle as `killed` with the error on stderr. * - {@link BashProcess.readOutput} is incremental: consecutive reads never * repeat output. Lossy reads report truncation and available spill files. - * - Disposal kills all running background processes and awaits their exit. + * - A still-running background process is stopped and awaited when its + * owning composition tears down. With the process-manager seam that + * boundary is `ctx.processes` disposal, so a background process survives + * an executor-only reload. */ export abstract class BashExecutor extends Service { constructor(ctx: Context) { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 5928687e93..4fdb11288e 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -153,8 +153,9 @@ export interface BashProcessRead { /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the process manager's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ export interface BashProcess { /** Process lifecycle state (settled exactly once). */ diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 923a9aace6..6ecca27aed 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index 11f5421bf9..f22947eb3b 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -5,6 +5,7 @@ import { basename, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' import { CallId } from '@deepseek-ai/dsh-llm' @@ -52,6 +53,7 @@ beforeEach(async () => { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 }) await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot }) await ctx.plugin(agentSpine, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0471ffc91..340d47c7fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1658,6 +1658,9 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local diff --git a/vitest.config.ts b/vitest.config.ts index 0f2b25f627..a0379a325b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ const windowsUnsupportedPackages = process.platform === 'win32' ? [ 'packages/bash/*', 'packages/hooks/*', + 'packages/process/*', 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', 'packages/sdk/create-sdk', From 65cd40e76494686b891aee932a570e63879e9ddc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:03:34 +0800 Subject: [PATCH 16/75] docs(process): unambiguous past-tense in the timeout-library note Codex round-2 (B): the retargeted bullet read as though spawn.ts carries the full timeout plumbing today. Split the historical claim (run.ts, pre-library) from the current split (spawn.ts reacts to aborts; bash-local owns the fused deadline and classification), en+zh, pair re-recorded. --- .../architecture/2026-07-06-timeout-deadline-library.i18n.yaml | 2 +- .../architecture/2026-07-06-timeout-deadline-library.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 9047815e3c..7a6c6be383 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-timeout-deadline-library.md: b459a7479e1d133d40c0f65464740611f3c59fcb -2026-07-06-timeout-deadline-library.zh.md: e79ac6d7b618b6dde6a487e2f3b21c180b3ef137 +2026-07-06-timeout-deadline-library.zh.md: 5b2c0049705787c27435dcdca2c3847eadd27963 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 e79ac6d7b6..5b2c004970 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 @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 +- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) From bd462ff2b59e152712952f36edd84f35a74306c4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:07:53 +0800 Subject: [PATCH 17/75] docs(process): apply the timeout-note clarification to the English side too The previous commit's English edit silently missed (its match pattern targeted already-rewritten text); only the zh side carried the split. Both sides now state it and the pair is re-recorded. --- .../architecture/2026-07-06-timeout-deadline-library.i18n.yaml | 2 +- .../architecture/2026-07-06-timeout-deadline-library.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 7a6c6be383..86b29e2a7a 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 -2026-07-06-timeout-deadline-library.md: b459a7479e1d133d40c0f65464740611f3c59fcb +2026-07-06-timeout-deadline-library.md: 8a21918097de105e7ebd5c94135add96e976b171 2026-07-06-timeout-deadline-library.zh.md: 5b2c0049705787c27435dcdca2c3847eadd27963 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 b459a7479e..8a21918097 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 @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** ([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. +- **bash** (then in `packages/bash/bash-local/src/run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) From 2d2bf871920a2981dd77d16897136ec23844fc6b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:17:18 +0800 Subject: [PATCH 18/75] =?UTF-8?q?fix(process):=20CI=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20built-bin=20closures=20and=20gate-safe=20path=20pro?= =?UTF-8?q?se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The acp-demo and cli-demo built-bin e2e consumers link a pruned package closure; add process/process and process/process-local so the built bins boot now that bash-local injects ctx.processes. - verify-package-paths scans backticked packages/* references, so the timeout-note's historical mention of the deleted run.ts path is reworded (en+zh, both sides now carry the same historical-vs-current split; the earlier zh edit had silently missed its match); pair re-recorded. --- .../2026-07-06-timeout-deadline-library.i18n.yaml | 4 ++-- .../architecture/2026-07-06-timeout-deadline-library.md | 2 +- .../architecture/2026-07-06-timeout-deadline-library.zh.md | 2 +- packages/examples/acp-demo/tests/built-bin.e2e.ts | 2 +- packages/examples/cli-demo/tests/built-bin.e2e.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) 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 86b29e2a7a..b213b86ba1 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 -2026-07-06-timeout-deadline-library.md: 8a21918097de105e7ebd5c94135add96e976b171 -2026-07-06-timeout-deadline-library.zh.md: 5b2c0049705787c27435dcdca2c3847eadd27963 +2026-07-06-timeout-deadline-library.md: 8e739b111644aaa00e2cba9f6de803327de90e26 +2026-07-06-timeout-deadline-library.zh.md: 60d6c60b396b91b0504c7e36d1465787e8b6ae66 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 8a21918097..8e739b1116 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 @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** (then in `packages/bash/bash-local/src/run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. +- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) 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 5b2c004970..60d6c60b39 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 @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 +- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 3c101f14b5..464196433e 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -32,7 +32,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'process/process', 'process/process-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'acp/acp', 'examples/acp-demo', 'util/paths', diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 0dcca62291..25169948e0 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -23,7 +23,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'process/process', 'process/process-local', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'context/workspace-context', From fc566119a728c17066cb458a61da1c0e1bb010c7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:43:14 +0800 Subject: [PATCH 19/75] refactor(subprocess): rename the process seam to subprocess and address review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (tianyicui): 'process' is a poor service name. The family is now packages/subprocess/ — @deepseek-ai/dsh-subprocess (ctx.subprocess, abstract SubprocessService, Subprocess* vocabulary) and @deepseek-ai/dsh-subprocess-local (LocalSubprocessService) — renamed throughout code, compositions, docs (en+zh, pairs re-recorded), catalogs, and gates. 'subprocess' is the precise term for managed OS children (the Python-stdlib sense), avoids colliding with Node's global process object, and reads as one system beside dsh-subagent-subprocess. ds-review-bot findings addressed: - kill() on a settled handle is now a no-op (no signal to a possibly-reused pgid, no referenced grace timer delaying exit); pinned by a spy test. - The moved DshEnvironmentKey/DshEnvironment/CollectedOutput types get drift-checked type-equiv blocks on the new subprocess.md page, restoring their manifest registration. - subprocess.md is registered in the core.md sub-page index (en+zh). --- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +- .../2026-07-06-timeout-deadline-library.md | 2 +- .../2026-07-06-timeout-deadline-library.zh.md | 2 +- .../2026-07-26-process-manager-seam.md | 38 ------ ...l => 2026-07-26-subprocess-seam.i18n.yaml} | 4 +- .../2026-07-26-subprocess-seam.md | 38 ++++++ ...zh.md => 2026-07-26-subprocess-seam.zh.md} | 20 +-- apps/cli/cordis.yml | 4 +- apps/cli/package.json | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/capability-seams.md | 12 +- docs/config-catalog.md | 8 +- docs/cordis-catalog/services.md | 54 ++++---- docs/core-data-structures/bash.i18n.yaml | 4 +- docs/core-data-structures/bash.md | 10 +- docs/core-data-structures/bash.zh.md | 10 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/core.zh.md | 1 + .../{process.md => subprocess.md} | 62 ++++++--- docs/module-graph.md | 26 ++-- examples/acp-agent/composition.md | 6 +- examples/acp-agent/cordis.yml | 4 +- examples/cordis-agent/composition.md | 6 +- examples/cordis-agent/cordis.yml | 4 +- examples/headless-agent/composition.md | 6 +- examples/headless-agent/cordis.yml | 4 +- .../semantic-checkpoint.cordis.snapshot.yml | 4 +- .../headless-agent/tests/code-mode.e2e.ts | 6 +- .../tests/fixtures/goal-domain/cordis.yml | 4 +- .../tests/fixtures/time-context.cordis.yml | 4 +- examples/headless-agent/tests/harness.ts | 4 +- examples/jsonrpc-agent/cordis.yml | 4 +- examples/package.json | 2 +- examples/tui-agent/composition.md | 6 +- examples/tui-agent/cordis.yml | 4 +- .../tests/fixtures/tui-scripted.cordis.yml | 4 +- examples/tui-agent/tests/tui.snapshot.ts | 4 +- packages/README.md | 2 +- packages/bash/README.md | 4 +- packages/bash/bash-local/README.md | 14 +- packages/bash/bash-local/package.json | 6 +- packages/bash/bash-local/src/index.ts | 24 ++-- .../bash/bash-local/tests/executor.spec.ts | 22 ++-- packages/bash/bash-local/tsconfig.json | 2 +- packages/bash/bash-sandbox/package.json | 2 +- packages/bash/bash-sandbox/src/index.ts | 4 +- packages/bash/bash-sandbox/tests/bwrap.e2e.ts | 4 +- .../bash/bash-sandbox/tests/landlock.e2e.ts | 4 +- .../bash/bash-sandbox/tests/sandbox.spec.ts | 6 +- .../bash/bash-sandbox/tests/seatbelt.e2e.ts | 4 +- packages/bash/bash/package.json | 4 +- packages/bash/bash/src/index.ts | 4 +- packages/bash/bash/src/types.ts | 10 +- packages/bash/bash/tsconfig.json | 2 +- packages/bash/tool-bash/package.json | 2 +- .../bash/tool-bash/tests/integration.spec.ts | 4 +- packages/bash/tool-bash/tests/tools.spec.ts | 22 ++-- .../cordis/tool-cordis/src/api-catalog.ts | 52 ++------ .../examples/acp-demo/tests/built-bin.e2e.ts | 4 +- .../examples/acp-demo/tests/load-path.e2e.ts | 4 +- .../examples/agent-spine-demo/package.json | 2 +- .../tests/multi-project-sandbox.e2e.ts | 4 +- .../examples/cli-demo/tests/built-bin.e2e.ts | 4 +- packages/fs/tool-fs-search/package.json | 2 +- .../tool-fs-search/tests/integration.spec.ts | 4 +- packages/hooks/hooks-claude/package.json | 2 +- .../hooks/hooks-claude/tests/bridge.spec.ts | 8 +- .../hooks-claude/tests/coverage-cases.ts | 10 +- packages/hooks/hooks-codex/package.json | 2 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 8 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 8 +- packages/process/README.md | 10 -- .../sdk/helper/src/features/builtin/index.ts | 2 +- .../subagent-inprocess/src/invariant.ts | 2 +- packages/subagent/subagent-spawn/package.json | 2 +- .../subagent/subagent-spawn/tests/harness.ts | 4 +- .../subagent-subprocess/src/invariant.ts | 2 +- packages/subprocess/README.md | 10 ++ .../subprocess-local}/README.md | 8 +- .../subprocess-local}/package.json | 8 +- .../subprocess-local}/src/index.ts | 20 +-- .../subprocess-local}/src/invariant.ts | 8 +- .../subprocess-local}/src/spawn.ts | 24 ++-- .../subprocess-local/tests/local.spec.ts} | 36 +++--- .../subprocess-local}/tests/spawn.spec.ts | 27 +++- .../subprocess-local}/tsconfig.json | 2 +- .../subprocess}/README.md | 10 +- .../subprocess}/package.json | 4 +- .../subprocess}/src/index.ts | 34 ++--- .../subprocess}/src/invariant.ts | 8 +- .../subprocess}/src/types.ts | 28 ++-- .../subprocess}/tests/service.spec.ts | 26 ++-- .../subprocess}/tsconfig.json | 0 pnpm-lock.yaml | 122 +++++++++--------- python/sdk-runtime/package.json | 4 +- .../runtime/cordis.yml | 4 +- python/sdk/tests/test_bundled_runtime.py | 4 +- scripts/gen-cordis-catalog.ts | 12 +- scripts/gen-doc-graphs.ts | 4 +- scripts/gen-tool-catalog.ts | 4 +- scripts/type-equiv.manifest.json | 45 ++++--- .../verify-package-readme-model-experience.ts | 4 +- tsconfig.base.json | 4 +- tsconfig.host.json | 4 +- vitest.config.ts | 4 +- 108 files changed, 587 insertions(+), 557 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md rename .agents/notes/implemented/architecture/{2026-07-26-process-manager-seam.i18n.yaml => 2026-07-26-subprocess-seam.i18n.yaml} (65%) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md rename .agents/notes/implemented/architecture/{2026-07-26-process-manager-seam.zh.md => 2026-07-26-subprocess-seam.zh.md} (50%) rename docs/core-data-structures/{process.md => subprocess.md} (59%) delete mode 100644 packages/process/README.md create mode 100644 packages/subprocess/README.md rename packages/{process/process-local => subprocess/subprocess-local}/README.md (78%) rename packages/{process/process-local => subprocess/subprocess-local}/package.json (83%) rename packages/{process/process-local => subprocess/subprocess-local}/src/index.ts (69%) rename packages/{process/process-local => subprocess/subprocess-local}/src/invariant.ts (77%) rename packages/{process/process-local => subprocess/subprocess-local}/src/spawn.ts (92%) rename packages/{process/process-local/tests/manager.spec.ts => subprocess/subprocess-local/tests/local.spec.ts} (57%) rename packages/{process/process-local => subprocess/subprocess-local}/tests/spawn.spec.ts (95%) rename packages/{process/process-local => subprocess/subprocess-local}/tsconfig.json (92%) rename packages/{process/process => subprocess/subprocess}/README.md (61%) rename packages/{process/process => subprocess/subprocess}/package.json (77%) rename packages/{process/process => subprocess/subprocess}/src/index.ts (65%) rename packages/{process/process => subprocess/subprocess}/src/invariant.ts (73%) rename packages/{process/process => subprocess/subprocess}/src/types.ts (87%) rename packages/{process/process => subprocess/subprocess}/tests/service.spec.ts (60%) rename packages/{process/process => subprocess/subprocess}/tsconfig.json (100%) 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 b213b86ba1..471cf9f92d 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 -2026-07-06-timeout-deadline-library.md: 8e739b111644aaa00e2cba9f6de803327de90e26 -2026-07-06-timeout-deadline-library.zh.md: 60d6c60b396b91b0504c7e36d1465787e8b6ae66 +2026-07-06-timeout-deadline-library.md: 63463a76a65743436d4e78479800c19e257a42de +2026-07-06-timeout-deadline-library.zh.md: c3d3cdf1c63813fc24c10727e42d326142f3f4de 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 8e739b1116..63463a76a6 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 @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. +- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) 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 60d6c60b39..c3d3cdf1c6 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 @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 +- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md deleted file mode 100644 index 2157259517..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: The process manager is its own seam under the bash executors (`dsh-process` / `dsh-process-local`) - -Status: implemented - -English | [中文](2026-07-26-process-manager-seam.zh.md) - -## Problem - -`dsh-bash-local` bundled two capabilities that change for different reasons: *running a bash command* (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and *running and managing a child process* (detached process groups, bounded tail-keep output with spill files, the credential scrub and `DSH_*` merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — `run.ts`, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared `DSH_*`/`CollectedOutput` vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling [task registry](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers. - -## Decision - -A new `process/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: - -- **`@deepseek-ai/dsh-process` (interface)** — the abstract `ProcessManager` owning `ctx.processes` with one method, `spawn(spec): ProcessHandle`, and the shared vocabulary: the fully-explicit `ProcessSpawnSpec` (argv, cwd, per-stream caps, spill cap, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `ProcessHandle` with non-consuming offset-based readers, `ProcessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. -- **`@deepseek-ai/dsh-process-local` (implementation)** — `LocalProcessManager` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` merge, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel. -- **`dsh-bash-local` (consumer)** — `inject: ['processes']`; maps each resolved `BashExecSpec` onto a `ProcessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. -- **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-process`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. - -Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-process-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs). - -Background-process lifetime moved from the executor to the manager: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the manager's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the manager rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta. - -## Alternatives considered - -**Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) landed: the boundary is stable and already documented in-code (`run.ts`'s module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split. - -**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.processes` in the same change.** Rejected as scope creep with real design risk: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README. - -**Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry. - -**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the manager.** Rejected: a generic process manager must not impose terminal presentation policy on non-terminal consumers; the scrub and `DSH_*` channel rules are security/identity invariants and stay, but terminal friendliness is the bash tool's choice, expressed through the ordinary env channel where an explicit caller entry still wins. - -## Consequences - -Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: `bash-local`, `bash-sandbox`); a containerized or remote process backend slots in without touching bash semantics; the shared `DSH_*`/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to `dsh-process-local` (argv-based, plus argv-validation and manager lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, manager-owned lifetime) against the real manager. - -Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the manager leaves `ctx.bash` pending on `ctx.processes` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the process seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated `readFrom(0)` reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists. diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml similarity index 65% rename from .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index fc1952eaa6..a0ea989893 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-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 -2026-07-26-process-manager-seam.md: 215725951792d23053c93da19c619c08393af1b9 -2026-07-26-process-manager-seam.zh.md: 03062f33d3d5fbafa1ce0b9bc3e723d10572cfd4 +2026-07-26-subprocess-seam.md: cd7b18c209af0830e339abfe01aa91dc493e15da +2026-07-26-subprocess-seam.zh.md: 685d9797a7456b4edf95853b253dc97f3426c902 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md new file mode 100644 index 0000000000..cd7b18c209 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -0,0 +1,38 @@ +# Agent Note: The subprocess service is its own seam under the bash executors (`dsh-subprocess` / `dsh-subprocess-local`) + +Status: implemented + +English | [中文](2026-07-26-subprocess-seam.zh.md) + +## Problem + +`dsh-bash-local` bundled two capabilities that change for different reasons: *running a bash command* (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and *running and managing a child process* (detached process groups, bounded tail-keep output with spill files, the credential scrub and `DSH_*` merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — `run.ts`, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared `DSH_*`/`CollectedOutput` vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling [task registry](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers. + +## Decision + +A new `process/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: + +- **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream caps, spill cap, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. +- **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` merge, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel. +- **`dsh-bash-local` (consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. +- **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. + +Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs). + +Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta. + +## Alternatives considered + +**Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) landed: the boundary is stable and already documented in-code (`run.ts`'s module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split. + +**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.subprocess` in the same change.** Rejected as scope creep with real design risk: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README. + +**Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry. + +**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the subprocess service.** Rejected: a generic subprocess service must not impose terminal presentation policy on non-terminal consumers; the scrub and `DSH_*` channel rules are security/identity invariants and stay, but terminal friendliness is the bash tool's choice, expressed through the ordinary env channel where an explicit caller entry still wins. + +## Consequences + +Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: `bash-local`, `bash-sandbox`); a containerized or remote process backend slots in without touching bash semantics; the shared `DSH_*`/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to `dsh-subprocess-local` (argv-based, plus argv-validation and service lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, service-owned lifetime) against the real service. + +Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the subprocess service leaves `ctx.bash` pending on `ctx.subprocess` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the subprocess seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated `readFrom(0)` reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists. diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md similarity index 50% rename from .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md rename to .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index 03062f33d3..685d9797a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -1,8 +1,8 @@ -# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-process` / `dsh-process-local`) +# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`) Status: implemented -[English](2026-07-26-process-manager-seam.md) | 中文 +[English](2026-07-26-subprocess-seam.md) | 中文 ## 问题 @@ -12,12 +12,12 @@ Status: implemented 新的 `process/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: -- **`@deepseek-ai/dsh-process`(接口)**——拥有 `ctx.processes` 的抽象 `ProcessManager`(仅一个方法:`spawn(spec): ProcessHandle`),以及共享词汇:完全显式的 `ProcessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `ProcessHandle`、刻意不含超时/取消分类的 `ProcessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 -- **`@deepseek-ai/dsh-process-local`(实现)**——`LocalProcessManager`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 -- **`dsh-bash-local`(消费方)**——`inject: ['processes']`;把每个解析后的 `BashExecSpec` 映射为一个 `ProcessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 -- **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-process` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 +- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 +- **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 +- **`dsh-bash-local`(消费方)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 +- **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 -如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-process-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 +如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。 @@ -25,7 +25,7 @@ Status: implemented **把进程管道留在 `dsh-bash-local` 里(维持现状)。**否决的理由与[任务注册表拆分](2026-07-26-task-registry-seam.md)得以落地的理由相同:这条边界既稳定,也早已记录在代码里(`run.ts` 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。这组堆叠变更对用户可见的动因正是这一拆分。 -**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.processes` 上。**作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 在其唯一真实的消费方家族上得到验证后交付;其余调用点已在 seam README 中列为暂缓工作。 +**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 在其唯一真实的消费方家族上得到验证后交付;其余调用点已在 seam README 中列为暂缓工作。 **改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 @@ -33,6 +33,6 @@ Status: implemented ## 后果 -换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-process-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。 +换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-subprocess-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。 -代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.processes` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。 +代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.subprocess` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 5521c09b65..c2ab296385 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,8 +89,8 @@ name: '@deepseek-ai/dsh-workspace' # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash-local name: '@deepseek-ai/dsh-bash-local' diff --git a/apps/cli/package.json b/apps/cli/package.json index 7ef006c924..e21b51e836 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -42,7 +42,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 5293177942..50abfe37ff 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 -architecture.md: ca2d1c70342fcef35deca7e247863511fb7c35b8 -architecture.zh.md: c3107b23f96497a5ab2184d660d085f3e8a5e527 +architecture.md: a1e169a9ef7717c6889f5a1aff53af984de565f4 +architecture.zh.md: 025d3b7343b36dca91cca9ebe6323628cb0702f9 diff --git a/docs/architecture.md b/docs/architecture.md index ca2d1c7034..a1e169a9ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | -| `ctx.processes` | [`process/`](../packages/process/README.md) | managed child-process groups under the bash executors | +| `ctx.subprocess` | [`process/`](../packages/subprocess/README.md) | managed child-process groups under the bash executors | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | @@ -178,7 +178,7 @@ New behavior attaches to a documented extension point; a loop change updates thi |---|---| | Add a model provider | register an adapter on `ctx.llm` | | Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | -| Add shell execution | implement and register a `ctx.bash` backend (the local one spawns through `ctx.processes`) | +| Add shell execution | implement and register a `ctx.bash` backend (the local one spawns through `ctx.subprocess`) | | Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` | | Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c3107b23f9..025d3b7343 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,7 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | -| `ctx.processes` | [`process/`](../packages/process/README.md) | bash 执行器之下受管理的子进程组 | +| `ctx.subprocess` | [`process/`](../packages/subprocess/README.md) | bash 执行器之下受管理的子进程组 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | @@ -178,7 +178,7 @@ forever: |---|---| | 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | | 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 进入提示词组装流程 | -| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端(本地后端通过 `ctx.processes` 生成进程) | +| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端(本地后端通过 `ctx.subprocess` 生成进程) | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派该命令 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 0fef36c63e..8e0169458f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -83,7 +83,7 @@ flowchart LR pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] pkg_process["process"] - svc_processes["ctx.processes
Process manager seam"] + svc_subprocess["ctx.subprocess
Process manager seam"] pkg_process_local["process-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] @@ -167,8 +167,8 @@ flowchart LR pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode - pkg_process --> svc_processes - pkg_process_local --> svc_processes + pkg_process --> svc_subprocess + pkg_process_local --> svc_subprocess pkg_pty --> svc_pty pkg_pty_local --> svc_pty pkg_sandbox --> svc_sandbox @@ -239,8 +239,6 @@ flowchart LR svc_invariants --> pkg_session svc_llm --> pkg_agent_loop svc_llm --> pkg_compact_basic - svc_processes --> pkg_bash_local - svc_processes --> pkg_bash_sandbox svc_pty --> pkg_tool_pty svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_pty_local @@ -270,6 +268,8 @@ flowchart LR svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent + svc_subprocess --> pkg_bash_local + svc_subprocess --> pkg_bash_sandbox svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -324,7 +324,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `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.processes` | `seam` | [`process`](../packages/process/process) | [`process-local`](../packages/process/process-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation. | +| `ctx.subprocess` | `seam` | `process` | `process-local` | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 322f5c70d3..a21b11378d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -192,7 +192,7 @@ Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examp ## `@deepseek-ai/dsh-bash-local` -Requires: `processes` +Requires: `subprocess` ```ts config-catalog /** Plugin config (all optional — `static Config` supplies the defaults). */ @@ -216,7 +216,7 @@ Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local ## `@deepseek-ai/dsh-bash-sandbox` -Requires: `processes` · `sandbox` · `sandboxPolicy` +Requires: `subprocess` · `sandbox` · `sandboxPolicy` ```ts config-catalog /** @@ -2055,12 +2055,12 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) -- `@deepseek-ai/dsh-process-local` ([`packages/process/process-local/src/index.ts`](../packages/process/process-local/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) +- `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) @@ -2076,11 +2076,11 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) -- `@deepseek-ai/dsh-process` — abstract `ProcessManager` ([`packages/process/process/src/index.ts`](../packages/process/process/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) +- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b2229130bc..4108332e43 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -257,7 +257,7 @@ Implementations must honor these semantics: - run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a BashRunResult. - start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr. - BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files. -- A still-running background process is stopped and awaited when its owning composition tears down. With the process-manager seam that boundary is `ctx.processes` disposal, so a background process survives an executor-only reload. +- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload. ```ts cordis-catalog /** @@ -315,7 +315,7 @@ collect(execution: ToolExecution): DshEnvironment list(): BashEnvVariableInfo[] ``` -Types: [DshEnvironment](../core-data-structures/process.md) · [ToolExecution](../core-data-structures/tools.md) +Types: [DshEnvironment](../core-data-structures/subprocess.md) · [ToolExecution](../core-data-structures/tools.md) Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) @@ -829,31 +829,6 @@ Types: [Agent](../core-data-structures/core.md) Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts) -## `ctx.processes` — `ProcessManager` (abstract seam) - -Abstract process manager. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.processes` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). - -Implementations must honor these semantics: - -- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. -- Output 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. -- ProcessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group. -- Disposal kills all still-running managed processes and awaits their exit. - -```ts cordis-catalog -/** - * Start one managed child process from a fully-specified spec; this seam - * applies no defaults. - * @param spec - argv, directory, limits, grace, cancellation, and environment. - * @returns the live process handle (readers, kill, outcome promise). - */ -abstract spawn(spec: ProcessSpawnSpec): ProcessHandle -``` - -Types: [ProcessHandle](../core-data-structures/process.md) · [ProcessSpawnSpec](../core-data-structures/process.md) - -Source: [`packages/process/process/src/index.ts:48`](../../packages/process/process/src/index.ts) - ## `ctx.pty` — `PtyService` In-process registry for replaceable PTY backends and exact-Agent sessions. @@ -1584,6 +1559,31 @@ Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun]( Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/subagent/src/index.ts) +## `ctx.subprocess` — `SubprocessService` (abstract seam) + +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). + +Implementations must honor these semantics: + +- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. +- Output 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. +- SubprocessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group. +- Disposal kills all still-running managed processes and awaits their exit. + +```ts cordis-catalog +/** + * Start one managed child process from a fully-specified spec; this seam + * applies no defaults. + * @param spec - argv, directory, limits, grace, cancellation, and environment. + * @returns the live process handle (readers, kill, outcome promise). + */ +abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle +``` + +Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) + +Source: [`packages/subprocess/subprocess/src/index.ts:48`](../../packages/subprocess/subprocess/src/index.ts) + ## `ctx.systemPrompt` — `SystemPrompt` Registry service for the prompt inputs assembled before each model step. diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 00a77885e8..fba715d163 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.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 -bash.md: 0c92addf4778fa780cd432444549bfc36eeb763c -bash.zh.md: cd9d94055627937d03107ead5a4ffaeaa945d187 +bash.md: 4ea0ace7e4af8cda3ff5bf1b4fc672f6425e5396 +bash.zh.md: 6642c85134748bceaa4a7783742a63da8b6acabd diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 0c92addf47..4ea0ace7e4 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -2,13 +2,13 @@ English | [中文](bash.zh.md) -The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [process-manager seam](process.md). +The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [subprocess seam](subprocess.md). Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## Managed shell environment namespace -`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the process manager removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`. +`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the subprocess service removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## Request vs. spec: the `resolve()` split @@ -135,7 +135,7 @@ interface BashRunResult { } ``` -Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`. +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## File sandbox: `BashSandboxInfo` @@ -171,7 +171,7 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o /** * A background process handle returned by {@link BashExecutor.start}. It is the * only access path; buffered output remains readable after exit. Composition - * teardown (the process manager's disposal) kills running processes and + * teardown (the subprocess service's disposal) kills running processes and * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { @@ -217,4 +217,4 @@ interface BashProcessRead { ## The service -`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [process manager](process.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). +`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index cd9d940556..6642c85134 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -2,13 +2,13 @@ [English](bash.md) | 中文 -bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](process.md)之后。 +bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](subprocess.md)之后。 源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## 受管 shell 环境命名空间 -`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。 +`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 请求与规格:`resolve()` 拆分 @@ -135,7 +135,7 @@ interface BashRunResult { } ``` -每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。 +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 文件沙箱:`BashSandboxInfo` @@ -171,7 +171,7 @@ interface BashSandboxInfo { /** * A background process handle returned by {@link BashExecutor.start}. It is the * only access path; buffered output remains readable after exit. Composition - * teardown (the process manager's disposal) kills running processes and + * teardown (the subprocess service's disposal) kills running processes and * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { @@ -217,4 +217,4 @@ interface BashProcessRead { ## 服务 -`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](process.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 +`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](subprocess.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 47310dd09b..3ff88fcca2 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.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 -core.md: 781267cccdb5bbda33e5be6a9e807fdbe47dbc83 -core.zh.md: d0f67983b98b0cf679a8e599a5f8ab3c64490dd0 +core.md: cdbf8f2f7a4484986abe56511698ae3f4c2096b4 +core.zh.md: 2afd9b02135a0cc54a48ca9404be4d55f5b8c64a diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 781267cccd..cdbf8f2f7a 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -31,6 +31,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | +| [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | | [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots | | [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d0f67983b9..2afd9b0213 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -31,6 +31,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 | | [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | +| [subprocess.md](subprocess.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | | [pty.md](pty.md) | 持久化终端 ID、后端/会话契约、发送就绪状态、有界读取与 owner 可见快照 | | [sandbox.md](sandbox.md) | 每会话策略解析与进程约束 seam:文件效果模式、执行/提供方策略、`ConfinedArgv`、强制执行与故障关闭错误 | | [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 | diff --git a/docs/core-data-structures/process.md b/docs/core-data-structures/subprocess.md similarity index 59% rename from docs/core-data-structures/process.md rename to docs/core-data-structures/subprocess.md index 93e7a73ad6..6e7cea3990 100644 --- a/docs/core-data-structures/process.md +++ b/docs/core-data-structures/subprocess.md @@ -1,21 +1,47 @@ -# Process Manager +# Subprocess -The child-process manager seam is split across interface ([dsh-process](../../packages/process/process), `ctx.processes`) and implementation ([dsh-process-local](../../packages/process/process-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. +The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. -Source: [`packages/process/process/src/types.ts`](../../packages/process/process/src/types.ts) +Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) + +## Managed environment namespace and captured output + +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each captured stream reports its truncation and spill-recovery state through `CollectedOutput`. + +```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +/** Trusted DeepSeek Harness variables for one child-process execution. */ +type DshEnvironment = Readonly> +``` + +```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` ## The fully-explicit spawn spec -The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden process-manager default — decides them. `argv` is never shell-interpreted. +The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted. ```ts type-equiv /** * A fully-specified spawn request. This seam applies no defaults: every limit * and directory is explicit, so the caller's own config — not a hidden - * process-manager default — decides them (the `dsh-bash` request/spec split + * subprocess-service default — decides them (the `dsh-bash` request/spec split * is the owning template). */ -interface ProcessSpawnSpec { +interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ @@ -62,15 +88,15 @@ A spawn returns a live handle immediately. Output readers take whole-stream byte * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL * escalation; buffered output remains readable after exit. */ -interface ProcessHandle { +interface SubprocessHandle { /** Process id (group leader); -1 when the spawn itself failed. */ readonly pid: number /** Live stdout reader (also readable after exit). */ - readonly stdout: ProcessOutputReader + readonly stdout: SubprocessOutputReader /** Live stderr reader (also readable after exit). */ - readonly stderr: ProcessOutputReader + readonly stderr: SubprocessOutputReader /** Resolves when the process closes; rejects only for spawn-level failures. */ - readonly done: Promise + readonly done: Promise /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ kill(): void } @@ -82,7 +108,7 @@ interface ProcessHandle { * whole-stream byte coordinates owned by the caller, so independent readers * cannot consume one another's output. */ -interface ProcessOutputReader { +interface SubprocessOutputReader { /** * Read everything captured since `fromByte`. When that offset has slid out * of the in-memory tail window the read is `lossy` — it returns the whole @@ -90,13 +116,13 @@ interface ProcessOutputReader { * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. */ - readFrom(fromByte: number): ProcessOutputRead + readFrom(fromByte: number): SubprocessOutputRead } ``` ```ts type-equiv -/** One incremental {@link ProcessOutputReader.readFrom} read. */ -interface ProcessOutputRead { +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +interface SubprocessOutputRead { /** Stream text from the requested offset (the whole retained tail when lossy). */ text: string /** Whole-stream offset to resume from on the next read. */ @@ -110,15 +136,15 @@ interface ProcessOutputRead { ## Outcomes carry no cause classification -`done` reports raw exit facts. The manager kills on abort but never decides why — the caller reads the deadline signal it owns to classify timeout versus cancellation (the bash executor's `timedOut`/`aborted` split). +`done` reports raw exit facts. The service kills on abort but never decides why — the caller reads the deadline signal it owns to classify timeout versus cancellation (the bash executor's `timedOut`/`aborted` split). ```ts type-equiv /** * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the manager kills on abort but does not decide + * cancellation classification: the service kills on abort but does not decide * why — the caller reads the signal it owns to classify causes. */ -interface ProcessOutcome { +interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ @@ -130,4 +156,4 @@ interface ProcessOutcome { ## Service behavior -The abstract [`ProcessManager`](../../packages/process/process/src/index.ts) seam defines `spawn` only; [`LocalProcessManager`](../../packages/process/process-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-process`](../../packages/process/process/README.md) for the seam contract and [`dsh-process-local`](../../packages/process/process-local/README.md) for the mechanics. +The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics. diff --git a/docs/module-graph.md b/docs/module-graph.md index 10e6ab65a6..f310cac386 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -184,10 +184,6 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end - subgraph group_process["packages/process"] - pkg_process["process"] - pkg_process_local["process-local"] - end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -209,6 +205,10 @@ flowchart TD pkg_storage_json["storage-json"] pkg_storage_sqlite["storage-sqlite"] end + subgraph group_subprocess["packages/subprocess"] + pkg_subprocess["subprocess"] + pkg_subprocess_local["subprocess-local"] + end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] pkg_tasks_local["tasks-local"] @@ -247,8 +247,8 @@ flowchart TD pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants pkg_host_webserver --> pkg_invariants - pkg_process --> pkg_invariants pkg_storage --> pkg_invariants + pkg_subprocess --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_client_connection --> pkg_host_webserver @@ -271,8 +271,6 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots pkg_client_ui_workspace --> pkg_invariants - pkg_process_local --> pkg_invariants - pkg_process_local --> pkg_process pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_telemetry --> pkg_brand @@ -284,6 +282,8 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage + pkg_subprocess_local --> pkg_invariants + pkg_subprocess_local --> pkg_subprocess pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -314,8 +314,8 @@ flowchart TD pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_bash --> pkg_invariants - pkg_bash --> pkg_process pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm @@ -379,7 +379,7 @@ flowchart TD pkg_goal --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_process + pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants @@ -837,8 +837,8 @@ flowchart TD | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | -| [`process`](../packages/process/process) | `process` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | @@ -846,12 +846,12 @@ flowchart TD | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`process-local`](../packages/process/process-local) | `process` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -861,7 +861,7 @@ flowchart TD | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`sandbox`](../packages/sandbox/sandbox) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -880,7 +880,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`timeout`](../packages/util/timeout) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index e4660ed8da..b4a3236920 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -14,8 +14,8 @@ flowchart LR cfg --> plugin_acp_sandbox plugin_acp_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"] cfg --> plugin_acp_sandbox_policy - plugin_acp_processes["processes
@deepseek-ai/dsh-process-local"] - cfg --> plugin_acp_processes + plugin_acp_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_acp_subprocess plugin_acp_bash["bash
@deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_acp_bash plugin_acp_approval["approval
@deepseek-ai/dsh-user-approval"] @@ -70,7 +70,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | -| `processes` | `@deepseek-ai/dsh-process-local` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 38994b1826..f784972429 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -34,8 +34,8 @@ workspaceRoot: !!js process.cwd() # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-sandbox' diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 78eb186e37..55e7f33b2c 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -12,8 +12,8 @@ flowchart LR cfg --> plugin_cordis_hmr plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_cordis_llm_deepseek - plugin_cordis_processes["processes
@deepseek-ai/dsh-process-local"] - cfg --> plugin_cordis_processes + plugin_cordis_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_cordis_subprocess plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_cordis_bash plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -41,7 +41,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `processes` | `@deepseek-ai/dsh-process-local` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 051c3a5662..369886c9e5 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -26,8 +26,8 @@ # Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an # ordinary tool whose calls make the mounted listeners observably fire. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 98ef6cd167..53a01260e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -10,8 +10,8 @@ flowchart LR cfg["examples/headless-agent
cordis.yml"] plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek - plugin_headless_processes["processes
@deepseek-ai/dsh-process-local"] - cfg --> plugin_headless_processes + plugin_headless_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_headless_subprocess plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_headless_bash plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] @@ -56,7 +56,7 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `processes` | `@deepseek-ai/dsh-process-local` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 261eaa6d20..896c73469b 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -20,8 +20,8 @@ contextWindow: 128000 # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml index 66fbca2e6a..432b64eb9f 100644 --- a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml +++ b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml @@ -18,8 +18,8 @@ overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 6f36b1f8c9..31e029f201 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,7 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' @@ -56,7 +56,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentRegistry) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) - await harness.plugin(LocalProcessManager) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -116,7 +116,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { const harness = await typedCodeModeHarness() await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) - await harness.plugin(LocalProcessManager) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml index d20c05b785..d66713526e 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml +++ b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml @@ -3,8 +3,8 @@ name: '../cli-mock-llm.ts' # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml index 1050d32376..a105652e9c 100644 --- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -3,8 +3,8 @@ name: './time-context-mock-llm.ts' # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index cf5f30aaee..f0cde5c274 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,7 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -60,7 +60,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 7576b06cbf..b1364679f9 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -18,8 +18,8 @@ reasoningEffort: max # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/package.json b/examples/package.json index 183aa03111..237a46df28 100644 --- a/examples/package.json +++ b/examples/package.json @@ -31,7 +31,7 @@ "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@deepseek-ai/dsh-process-local": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 9c64a95fbb..c6fc223113 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -12,8 +12,8 @@ flowchart LR cfg --> plugin_tui_hmr plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek - plugin_tui_processes["processes
@deepseek-ai/dsh-process-local"] - cfg --> plugin_tui_processes + plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_tui_subprocess plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_tui_bash plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] @@ -71,7 +71,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `processes` | `@deepseek-ai/dsh-process-local` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 9a67ce4f77..7c8b03db05 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -22,8 +22,8 @@ # Local executor for the app bundle's bash tool. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index f25cafc64b..a32bdf987b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -5,8 +5,8 @@ name: './tui-scripted-llm.ts' # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 795f21a794..8b99bc302b 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -8,7 +8,7 @@ import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -205,7 +205,7 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(TokenMeterService) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) diff --git a/packages/README.md b/packages/README.md index f9707a178d..2c559981a7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,7 +11,7 @@ Packages live at `packages///`; groups are containers, while names r | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | -| [`process/`](process/README.md) | Child-process manager capability family: spawn seam + local process-group implementation | Product — stable surface | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-group implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | | [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | diff --git a/packages/bash/README.md b/packages/bash/README.md index 57b0ffcf9e..b9ec625353 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -4,8 +4,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent | Package | Role | ctx key | |---|---|---| -| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`process/`](../process/README.md) seam) | `ctx.bash` | -| `bash-local/` | Local `BashExecutor` implementation over the [`process/`](../process/README.md) manager (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | +| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` | +| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 5fb4bd0a39..259c2e4950 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-bash-local -Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-process`](../../process/process/README.md) manager: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.processes`, 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 process manager's. +Local implementation of the `@deepseek-ai/dsh-bash` 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`. @@ -23,10 +23,10 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the manager explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-process-local`](../../process/process-local/README.md) mechanics. A foreground `BashExecRequest.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` (default 3s — OpenCode's escalation). Process-group kills, 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 `BashExecRequest.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` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the manager'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-surface.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 `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the manager's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the manager, so it survives executor reloads and dies (killed and joined) with the manager's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. +- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged 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-surface.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 `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), 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.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. ## Model Experience @@ -40,7 +40,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, and the underlying manager's group semantics are POSIX; Windows is unsupported. -- **A background spawn-failure note is single-delivery** — the manager 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. +- **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-process-local`](../../process/process-local/README.md), which owns those mechanics. +Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index edfcafabe5..bacc7ac92d 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -29,7 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-process": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -39,8 +39,8 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-process": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 5cb99b3caa..463e7a96d4 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,7 +1,7 @@ /** - * Local implementation of the bash executor seam over the process-manager + * Local implementation of the bash executor seam over the subprocess * seam. Each command runs as `bash -c` in a managed process group spawned - * through `ctx.processes`; this executor owns command defaulting, deadlines + * through `ctx.subprocess`; this executor owns command defaulting, deadlines * and cause classification, the model-friendly terminal environment, and the * model-facing stdout/stderr merge for background reads. Execution policy * belongs in `tools/pre-execute` or a sandboxing executor. @@ -12,7 +12,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' -import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' /** @@ -20,7 +20,7 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' * interactive terminal features that would garble tool output (the same set * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — * merged into the ordinary env channel, so a trusted caller's own entry still - * wins; the process manager applies its credential scrub independently. + * wins; the subprocess service applies its credential scrub independently. */ export const ENV_OVERRIDES = { NO_COLOR: '1', @@ -61,14 +61,14 @@ function assertPositiveFinite(name: string, value: number): void { } /** - * Local bash executor over `ctx.processes`. Bounded output, spill files, and - * process-group SIGTERM→SIGKILL escalation are the process manager's + * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and + * 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. */ export class LocalBashExecutor extends BashExecutor { - static inject = ['processes'] + static inject = ['subprocess'] static Config: z = z.object({ cwd: z.string(), @@ -116,7 +116,7 @@ export class LocalBashExecutor extends BashExecutor { stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, - // no config default. The process manager owns the scrub and merge order. + // no config default. The subprocess service owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, @@ -129,7 +129,7 @@ export class LocalBashExecutor extends BashExecutor { /** Map one resolved bash spec onto a fully-specified process spawn. */ // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. - private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): ProcessSpawnSpec { + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { return { argv: ['bash', '-c', spec.command], cwd: spec.workdir, @@ -147,7 +147,7 @@ export class LocalBashExecutor extends BashExecutor { async run(spec: BashExecSpec): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const outcome = await this.ctx.processes.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done + const outcome = await this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut @@ -156,9 +156,9 @@ export class LocalBashExecutor extends BashExecutor { start(spec: BashExecSpec): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = this.ctx.processes.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) - // A spawn failure produces no process output, so the manager has nothing + // 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 => { diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index aa5eb2fb92..d174b264ab 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -4,15 +4,15 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) async function setup(config: ConstructorParameters[1] = {}) { const ctx = new Context() - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } // A short kill grace via the REAL config path, so escalation tests stay fast. await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config }) const bash = ctx.bash as LocalBashExecutor @@ -295,11 +295,11 @@ describe('LocalBashExecutor.start (background process handles)', () => { }) }) -describe('process lifecycle ownership (the manager, not the executor)', () => { - it('a background process survives executor-fiber disposal and dies with the process manager', async () => { +describe('process lifecycle ownership (the subprocess service, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => { const ctx = new Context() - const managerFiber = await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor @@ -316,17 +316,17 @@ describe('process lifecycle ownership (the manager, not the executor)', () => { expect(proc.status).toBe('running') expect(() => process.kill(pid, 0)).not.toThrow() - // Manager disposal kills the group and AWAITS its exit (no orphans). + // Service disposal kills the group and AWAITS its exit (no orphans). await managerFiber.dispose() expect(() => process.kill(pid, 0)).toThrow() await proc.done expect(proc.status).toBe('killed') }) - it('manager disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => { + it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => { const ctx = new Context() - const managerFiber = await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index ce24865d49..53ccc94926 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -27,7 +27,7 @@ "path": "../../bash/bash" }, { - "path": "../../process/process" + "path": "../../subprocess/subprocess" }, { "path": "../../support/invariants" diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index b8737e0e68..0f2240630c 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -38,7 +38,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 3f0fc0c55d..3945809fa9 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -34,7 +34,7 @@ export type Config = LocalConfig * mode; `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { - static override inject = ['processes', 'sandbox', 'sandboxPolicy'] + static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] // No own Config: the sandbox default (mode + workspaceRoot) moved to // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config @@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Wrap one shell command via the `ctx.sandbox` provider: hand over the * exact `['bash', '-c', command]` argv this executor would spawn, get back * the confined argv, and re-assemble it into the `exec …` command string - * the inherited spawn path runs (the outer `bash -c` the process manager spawns + * the inherited spawn path runs (the outer `bash -c` the subprocess service spawns * `exec`s into the runner, so no extra shell lingers). Provider errors * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. */ diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index ad6ecab6f1..437078440c 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -9,7 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless integration of the real provider and executor through public run/start paths. With @@ -43,7 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index aac9b766e8..0c5cfbe563 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -9,7 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap @@ -48,7 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 3510c71737..90a67999c8 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -15,7 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' @@ -59,8 +59,8 @@ async function setup( ...mode !== undefined ? { mode } : {}, ...workspaceRoot !== undefined ? { workspaceRoot } : {}, }) - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig }) const bash = ctx.bash as SandboxBashExecutor return { ctx, bash, calls } diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 87e76d95e1..62b1569ee7 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,7 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless macOS integration of the real provider and executor through public run/start paths. @@ -42,7 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 13c31ee2de..b8ff310f01 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -28,13 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-process": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-process": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index ac03e73a91..4f8ae112a9 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -44,8 +44,8 @@ declare module 'cordis' { * - {@link BashProcess.readOutput} is incremental: consecutive reads never * repeat output. Lossy reads report truncation and available spill files. * - A still-running background process is stopped and awaited when its - * owning composition tears down. With the process-manager seam that - * boundary is `ctx.processes` disposal, so a background process survives + * owning composition tears down. With the subprocess seam that + * boundary is `ctx.subprocess` disposal, so a background process survives * an executor-only reload. */ export abstract class BashExecutor extends Service { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 4fdb11288e..ea153605af 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -2,16 +2,16 @@ * Execution types for the bash executor seam. Background task semantics belong * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The * managed-environment and captured-output vocabulary is owned by the - * process-manager seam and re-exported here so bash consumers keep one import + * subprocess seam and re-exported here so bash consumers keep one import * root. * @module dsh-bash/types */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-process' +import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess' -export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process' -export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-process' +export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess' /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -154,7 +154,7 @@ export interface BashProcessRead { /** * A background process handle returned by {@link BashExecutor.start}. It is the * only access path; buffered output remains readable after exit. Composition - * teardown (the process manager's disposal) kills running processes and + * teardown (the subprocess service's disposal) kills running processes and * awaits {@link done}; an executor-only reload leaves them running. */ export interface BashProcess { diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index ad38e8e3e1..3f611c80e0 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../../process/process" + "path": "../../subprocess/subprocess" }, { "path": "../../sandbox/sandbox" diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 821e9706c9..c34e1c6e7b 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -52,7 +52,7 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 1a1d8bb4db..434f53ca2a 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -11,7 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 4b7647c519..15222ee647 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,7 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' @@ -33,8 +33,8 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -48,8 +48,8 @@ async function setupWithTasks() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -278,8 +278,8 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) @@ -387,7 +387,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) @@ -405,7 +405,7 @@ describe('bash tool', () => { // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.tools.schemas()).toHaveLength(1) @@ -417,7 +417,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) ToolBash.apply(ctx, {}) const schema = ctx.tools.schemas()[0]! @@ -533,7 +533,7 @@ describe('background execution through the task runtime', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a15c306e2f..59b97aedd5 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -426,16 +426,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'processes', - summary: 'Abstract process manager.', - methods: [ - { - signature: 'abstract spawn(spec: ProcessSpawnSpec): ProcessHandle', - jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */', - }, - ], - }, { key: 'pty', summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.', @@ -754,6 +744,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'subprocess', + summary: 'Abstract subprocess service.', + methods: [ + { + signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle', + jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */', + }, + ], + }, { key: 'systemPrompt', summary: 'Registry service for the prompt inputs assembled before each model step.', @@ -1458,10 +1458,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeRunResult', declaration: 'export interface CodeRunResult {\n value?: CodeJsonValue;\n logs: string[];\n error?: CodeRunFailure;\n}', }, - { - name: 'CollectedOutput', - declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}', - }, { name: 'CommandDefinition', declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', @@ -1558,14 +1554,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DomainTableSpec', declaration: 'export interface DomainTableSpec {\n readonly valueSchema: ZodType;\n readonly __key?: K;\n}', }, - { - name: 'DshEnvironment', - declaration: 'export type DshEnvironment = Readonly>;', - }, - { - name: 'DshEnvironmentKey', - declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;', - }, { name: 'EditGoalRequest', declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', @@ -1770,26 +1758,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PresetSpec', declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', }, - { - name: 'ProcessHandle', - declaration: 'export interface ProcessHandle {\n readonly pid: number;\n readonly stdout: ProcessOutputReader;\n readonly stderr: ProcessOutputReader;\n readonly done: Promise;\n kill(): void;\n}', - }, - { - name: 'ProcessOutcome', - declaration: 'export interface ProcessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}', - }, - { - name: 'ProcessOutputRead', - declaration: 'export interface ProcessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}', - }, - { - name: 'ProcessOutputReader', - declaration: 'export interface ProcessOutputReader {\n readFrom(fromByte: number): ProcessOutputRead;\n}', - }, - { - name: 'ProcessSpawnSpec', - declaration: 'export interface ProcessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', - }, { name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}', diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 464196433e..a82896ab73 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -94,8 +94,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', ' name: \'./mock-llm.mjs\'', - '- id: processes', - ' name: \'@deepseek-ai/dsh-process-local\'', + '- id: subprocess', + ' name: \'@deepseek-ai/dsh-subprocess-local\'', '- id: bash', ' name: \'@deepseek-ai/dsh-bash-local\'', '- id: acp-agent', diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 624db3d97a..8bc14c7330 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -35,8 +35,8 @@ const CORDIS_YML = ` name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' - id: acp-agent diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 6ecca27aed..e76db97daf 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -65,7 +65,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index f22947eb3b..46f3f56dc3 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -5,7 +5,7 @@ import { basename, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' import { CallId } from '@deepseek-ai/dsh-llm' @@ -53,7 +53,7 @@ beforeEach(async () => { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 }) await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot }) await ctx.plugin(agentSpine, { diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 25169948e0..6f80c983f3 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -80,8 +80,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', " name: './mock-llm.ts'", - '- id: processes', - " name: '@deepseek-ai/dsh-process-local'", + '- id: subprocess', + " name: '@deepseek-ai/dsh-subprocess-local'", '- id: bash', " name: '@deepseek-ai/dsh-bash-local'", '- id: cli-agent', diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index c0abc66a50..bf9cf15aa0 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -44,7 +44,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 3ba8e86c36..6be9629460 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -18,7 +18,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' const testToolSignal = new AbortController().signal @@ -62,7 +62,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 }) await ctx.plugin(ToolFsSearch) }) diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index bf333718fa..4d52d1f0c5 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -46,7 +46,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 3cf70d569a..1bceaa3529 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -53,7 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -355,7 +355,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -377,7 +377,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 1470c5f9a9..a773a1a74d 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -42,7 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath, ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -361,7 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) // Direct apply with only configPath — bypasses schemastery's defaults, so // the bridge must run on the raw minimal config (the per-hook timeout is @@ -660,7 +660,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the session cwd). - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -690,7 +690,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the child session cwd). - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 472e010a93..fb598b4da3 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -45,7 +45,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index eb34a5c1f8..3e1e3f8f93 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -42,7 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -166,7 +166,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() @@ -189,7 +189,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 4bfaf4f98a..1c3c8edff4 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -32,7 +32,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -312,7 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. @@ -622,7 +622,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/process/README.md b/packages/process/README.md deleted file mode 100644 index 956f08e3fe..0000000000 --- a/packages/process/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# process/ — child-process manager capability family - -The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [process-manager seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md). - -| Package | ctx key | Role | -|---|---|---| -| [`process`](process/README.md) (`@deepseek-ai/dsh-process`) | `ctx.processes` | The seam: abstract `ProcessManager.spawn(spec)`, the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary | -| [`process-local`](process-local/README.md) (`@deepseek-ai/dsh-process-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal | - -The manager 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/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index a6873b75dc..27ba0aa581 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -33,7 +33,7 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry mode: 'exclusive', required: true, baseResources: [ - { kind: 'npm-cordis-config-entry', id: 'processes', package: '@deepseek-ai/dsh-process-local' }, + { kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' }, { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }, ], options: [ diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index 7b8bfc36e2..4a2188dcc8 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' /** Cordis companion plugin name. */ -export const name = 'subagent-inprocess-invariant' +export const name = 'subagent-insubprocess-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 6ab0d4f45f..647e0b005c 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -41,7 +41,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 3bebb612c4..afa1d2a1d2 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -3,7 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -28,7 +28,7 @@ export async function spawnHarness(workdir: string): Promise { }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index c273ce5209..5e401cd738 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' /** Cordis companion plugin name. */ -export const name = 'subagent-subprocess-invariant' +export const name = 'subagent-subsubprocess-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md new file mode 100644 index 0000000000..53b295d54e --- /dev/null +++ b/packages/subprocess/README.md @@ -0,0 +1,10 @@ +# subprocess/ — subprocess capability family + +The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. 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` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal | + +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/process/process-local/README.md b/packages/subprocess/subprocess-local/README.md similarity index 78% rename from packages/process/process-local/README.md rename to packages/subprocess/subprocess-local/README.md index 595e42af2f..4bc2a71691 100644 --- a/packages/process/process-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -1,14 +1,14 @@ -# @deepseek-ai/dsh-process-local +# @deepseek-ai/dsh-subprocess-local -Local-subprocess implementation of the [`@deepseek-ai/dsh-process`](../process/README.md) manager seam: `LocalProcessManager` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). +Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). ## Behavior (and where it came from) - **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; 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 + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. 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-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Offset-based reads** — `ProcessHandle` readers return deltas in whole-stream byte coordinates; the manager never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist. -- **Kill-and-join disposal** — the manager retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement. +- **Offset-based reads** — `SubprocessHandle` readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist. +- **Kill-and-join disposal** — the service retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement. ## Model Experience diff --git a/packages/process/process-local/package.json b/packages/subprocess/subprocess-local/package.json similarity index 83% rename from packages/process/process-local/package.json rename to packages/subprocess/subprocess-local/package.json index 22d51aadff..72ff50c422 100644 --- a/packages/process/process-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-process-local", - "description": "Local-subprocess implementation of the DeepSeek Harness process-manager seam", + "name": "@deepseek-ai/dsh-subprocess-local", + "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", "version": "0.0.1", "private": true, "type": "module", @@ -28,12 +28,12 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-process": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-process": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/process/process-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts similarity index 69% rename from packages/process/process-local/src/index.ts rename to packages/subprocess/subprocess-local/src/index.ts index 6971a58a71..a5256f325b 100644 --- a/packages/process/process-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -1,26 +1,26 @@ /** - * Local-subprocess implementation of the process-manager seam. Each spawn is + * Local-subprocess implementation of the subprocess seam. Each spawn is * a detached process group with bounded, spill-backed output; disposal kills * and joins live groups. It has no config: every limit arrives on the spec, * so the deployment-varying choices stay with the calling seam's config (the * bash executor's, today). - * @module @deepseek-ai/dsh-process-local + * @module @deepseek-ai/dsh-subprocess-local */ import { Context } from 'cordis' -import { ProcessManager } from '@deepseek-ai/dsh-process' -import type { ProcessHandle, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import { SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { spawnProcess } from './spawn.ts' import type { SpawnInternals } from './spawn.ts' /** - * Local process manager: detached process groups, tail-keep truncation with + * Local subprocess service: detached process groups, tail-keep truncation with * bounded spill files, credential-scrubbed environment, and group * SIGTERM→grace→SIGKILL escalation. */ -export class LocalProcessManager extends ProcessManager { +export class LocalSubprocessService extends SubprocessService { /** Live handles retained only so disposal can kill and join them. */ - private live = new Set() + private live = new Set() /** Test seam: spill knobs forwarded to spawnProcess. */ internals: SpawnInternals = {} @@ -36,10 +36,10 @@ export class LocalProcessManager extends ProcessManager { } this.live.clear() await Promise.all(pending) - }, 'local process-manager teardown') + }, 'local subprocess teardown') } - spawn(spec: ProcessSpawnSpec): ProcessHandle { + spawn(spec: SubprocessSpawnSpec): SubprocessHandle { const handle = spawnProcess(spec, this.internals) this.live.add(handle) handle.done.then( @@ -50,4 +50,4 @@ export class LocalProcessManager extends ProcessManager { } } -export default LocalProcessManager +export default LocalSubprocessService diff --git a/packages/process/process-local/src/invariant.ts b/packages/subprocess/subprocess-local/src/invariant.ts similarity index 77% rename from packages/process/process-local/src/invariant.ts rename to packages/subprocess/subprocess-local/src/invariant.ts index fa6c0c810e..b15b2dd511 100644 --- a/packages/process/process-local/src/invariant.ts +++ b/packages/subprocess/subprocess-local/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-process-local`. - * @module @deepseek-ai/dsh-process-local/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-local`. + * @module @deepseek-ai/dsh-subprocess-local/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-process-local' +const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-local' /** Cordis companion plugin name. */ -export const name = 'process-local-invariant' +export const name = 'subprocess-local-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/process/process-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts similarity index 92% rename from packages/process/process-local/src/spawn.ts rename to packages/subprocess/subprocess-local/src/spawn.ts index 747b85cf13..95b391ea4b 100644 --- a/packages/process/process-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,9 +1,9 @@ /** - * Process plumbing for the local process manager: detached process-group + * Process plumbing for the local subprocess service: detached process-group * spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation. * This layer reacts to an abort signal; callers own deadlines and classify * causes. - * @module dsh-process-local/spawn + * @module dsh-subprocess-local/spawn */ import { type ChildProcessByStdio, spawn } from 'node:child_process' @@ -12,8 +12,8 @@ import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process' -import type { CollectedOutput, DshEnvironment, ProcessHandle, ProcessOutcome, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' +import type { CollectedOutput, DshEnvironment, SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** * Credential-shaped env vars are NOT forwarded to children (the harness's @@ -68,7 +68,7 @@ let defaultSpillDir: string | undefined * other local users read command output or pre-create symlinks. */ function privateSpillDir(): string { - defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-proc-')) + defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-')) return defaultSpillDir } @@ -140,7 +140,7 @@ export class OutputCollector { // prediction and symlink planting in shared tmp dirs. this.spillFile = join( this.spillDir, - `dsh-proc-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, + `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, ) this.spillFd = openSync(this.spillFile, 'wx', 0o600) for (const prior of this.chunks) writeSync(this.spillFd, prior) @@ -236,12 +236,12 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void { /** * Spawn one isolated detached process group and collect its output. - * Runtime exits resolve as {@link ProcessOutcome}; only spawn failures reject. + * Runtime exits resolve as {@link SubprocessOutcome}; only spawn failures reject. * @param spec - fully resolved argv, cwd, limits, and cancellation. * @param internals - test-only spill-directory override. * @returns live process handle and outcome promise. */ -export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = {}): ProcessHandle { +export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { const spillDir = internals.spillDir ?? privateSpillDir() if (spec.signal?.aborted) { @@ -264,12 +264,17 @@ export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) let graceTimer: NodeJS.Timeout | undefined + let settled = false // Failed spawns use pid -1 so kill remains a no-op. const pid = child.pid ?? -1 const kill = (): void => { if (graceTimer !== undefined) return // escalation already in flight + // After settlement the group is gone and the pid may be reused; callers + // commonly kill() in a finally, so this must not re-signal or start a + // timer that outlives the handle. + if (settled) return killGroup(pid, 'SIGTERM') graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) } @@ -284,8 +289,7 @@ export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = child.stdin.end(spec.stdin) } - const done = new Promise((resolve, reject) => { - let settled = false + const done = new Promise((resolve, reject) => { let pipeDrainTimer: NodeJS.Timeout | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return diff --git a/packages/process/process-local/tests/manager.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts similarity index 57% rename from packages/process/process-local/tests/manager.spec.ts rename to packages/subprocess/subprocess-local/tests/local.spec.ts index 719dcc1d44..d6f81042b5 100644 --- a/packages/process/process-local/tests/manager.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' -import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -function spec(command: string, overrides: Partial = {}): ProcessSpawnSpec { +function spec(command: string, overrides: Partial = {}): SubprocessSpawnSpec { return { argv: ['bash', '-c', command], cwd: process.cwd(), @@ -15,11 +15,11 @@ function spec(command: string, overrides: Partial = {}): Proce } } -describe('LocalProcessManager', () => { - it('registers as ctx.processes and spawns managed handles', async () => { +describe('LocalSubprocessService', () => { + it('registers as ctx.subprocess and spawns managed handles', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) - const result = await ctx.processes.spawn(spec('echo managed')).done + const fiber = await ctx.plugin(LocalSubprocessService) + const result = await ctx.subprocess.spawn(spec('echo managed')).done expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('managed\n') await fiber.dispose() @@ -27,8 +27,8 @@ describe('LocalProcessManager', () => { it('disposal kills still-running processes and awaits their exit', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) - const handle = ctx.processes.spawn(spec('sleep 60')) + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('sleep 60')) await fiber.dispose() const outcome = await handle.done expect(outcome.signal).toBe('SIGTERM') @@ -36,8 +36,8 @@ describe('LocalProcessManager', () => { it('a settled process leaves the live set (disposal does not re-kill it)', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) - const handle = ctx.processes.spawn(spec('true')) + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('true')) const outcome = await handle.done expect(outcome.exitCode).toBe(0) await fiber.dispose() @@ -45,26 +45,26 @@ describe('LocalProcessManager', () => { it('disposal tolerates a handle whose spawn already failed', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) - const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' })) + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) await expect(handle.done).rejects.toThrow() await fiber.dispose() }) it('disposal contains a spawn-failure rejection that races teardown', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) + const fiber = await ctx.plugin(LocalSubprocessService) // Dispose before the rejection continuation removes the handle from the // live set, so teardown itself must swallow the rejected done. - const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' })) + const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) await fiber.dispose() await expect(handle.done).rejects.toThrow() }) it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { const ctx = new Context() - await ctx.plugin(LocalProcessManager) - class SecondManager extends LocalProcessManager {} - await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/) + await ctx.plugin(LocalSubprocessService) + class SecondManager extends LocalSubprocessService {} + await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/) }) }) diff --git a/packages/process/process-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts similarity index 95% rename from packages/process/process-local/tests/spawn.spec.ts rename to packages/subprocess/subprocess-local/tests/spawn.spec.ts index 954196e104..1bc2b27498 100644 --- a/packages/process/process-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import type { DshEnvironment } from '@deepseek-ai/dsh-process' +import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess' import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts' -import type { ProcessHandle } from '@deepseek-ai/dsh-process' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -31,7 +31,7 @@ vi.mock('node:fs', async (importOriginal) => { } }) -const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-')) +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-')) function spec(command: string, overrides: Partial[0]> = {}) { return { @@ -59,7 +59,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise { throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) } -async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise { +async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (running.stdout.readFrom(0).text.includes(expected)) return @@ -407,6 +407,21 @@ describe('killGroup', () => { await running.done expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) + + it('handle.kill() after settlement signals nothing and starts no grace timer', async () => { + // Cleanup code commonly kills handles in a finally; after settlement the + // group is gone and the pid may be reused, so a late kill must be inert + // (no signal to a possibly-recycled pgid, no referenced timer delaying exit). + const running = spawnProcess(spec('true')) + await running.done + const spy = vi.spyOn(process, 'kill') + try { + running.kill() + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } + }) }) describe('argv validation', () => { @@ -490,7 +505,7 @@ describe('environment and spill-file hardening', () => { { spillDir }, ).done const path = result.stdout.spillPath! - expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) + expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) const mode = statSync(path).mode & 0o777 expect(mode).toBe(0o600) }) @@ -500,7 +515,7 @@ describe('environment and spill-file hardening', () => { spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), ).done const dir = dirname(result.stdout.spillPath!) - expect(dir).toMatch(/dsh-proc-/) + expect(dir).toMatch(/dsh-subprocess-/) const mode = statSync(dir).mode & 0o777 expect(mode).toBe(0o700) }) diff --git a/packages/process/process-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json similarity index 92% rename from packages/process/process-local/tsconfig.json rename to packages/subprocess/subprocess-local/tsconfig.json index 5f84f780af..5a8dea211b 100644 --- a/packages/process/process-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../process" + "path": "../subprocess" }, { "path": "../../support/invariants" diff --git a/packages/process/process/README.md b/packages/subprocess/subprocess/README.md similarity index 61% rename from packages/process/process/README.md rename to packages/subprocess/subprocess/README.md index b53628cab3..15165a730d 100644 --- a/packages/process/process/README.md +++ b/packages/subprocess/subprocess/README.md @@ -1,16 +1,16 @@ -# @deepseek-ai/dsh-process +# @deepseek-ai/dsh-subprocess -The child-process manager seam (`ctx.processes`). The abstract `ProcessManager` exposes one method — `spawn(spec): ProcessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with its non-consuming offset-based output readers, `ProcessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-process-local`](../process-local/README.md). +The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes one method — `spawn(spec): SubprocessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with its non-consuming offset-based output readers, `SubprocessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md). ## Contract - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. -- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden process-manager default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Output readers take whole-stream byte offsets and never consume: 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 full-stream spill file when one exists. -- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the manager reacts to the abort but never classifies why (callers own deadlines and cause classification). +- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the service reacts to the abort but never classifies why (callers own deadlines and cause classification). - Disposal kills all still-running managed processes and awaits their exit. -See the [process data-structure catalog](../../../docs/core-data-structures/process.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md). +See the [process data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). ## Model Experience diff --git a/packages/process/process/package.json b/packages/subprocess/subprocess/package.json similarity index 77% rename from packages/process/process/package.json rename to packages/subprocess/subprocess/package.json index 6210948d56..6771c19651 100644 --- a/packages/process/process/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-process", - "description": "Child-process manager seam (ctx.processes) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", + "name": "@deepseek-ai/dsh-subprocess", + "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/process/process/src/index.ts b/packages/subprocess/subprocess/src/index.ts similarity index 65% rename from packages/process/process/src/index.ts rename to packages/subprocess/subprocess/src/index.ts index 4fe3386503..890cf74e61 100644 --- a/packages/process/process/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -1,37 +1,37 @@ /** - * The child-process manager seam (`ctx.processes`): spawn fully-specified + * The subprocess seam (`ctx.subprocess`): spawn fully-specified * commands into managed process groups with bounded, spill-backed output and * escalated kills. Command defaulting, shell semantics, deadlines, and * presentation belong to consumers — the bash executor seam is the owning * template. The local implementation lives in - * `@deepseek-ai/dsh-process-local`. - * @module @deepseek-ai/dsh-process + * `@deepseek-ai/dsh-subprocess-local`. + * @module @deepseek-ai/dsh-subprocess */ import { Context, Service } from 'cordis' -import type { ProcessHandle, ProcessSpawnSpec } from './types.ts' +import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' export type { CollectedOutput, DshEnvironment, DshEnvironmentKey, - ProcessHandle, - ProcessOutcome, - ProcessOutputRead, - ProcessOutputReader, - ProcessSpawnSpec, + SubprocessHandle, + SubprocessOutcome, + SubprocessOutputRead, + SubprocessOutputReader, + SubprocessSpawnSpec, } from './types.ts' declare module 'cordis' { interface Context { - processes: ProcessManager + subprocess: SubprocessService } } /** - * Abstract process manager. Subclass, implement {@link spawn}, and load the - * subclass as a plugin — it registers as `ctx.processes` (one implementation + * Abstract subprocess service. Subclass, implement {@link 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). * @@ -41,13 +41,13 @@ declare module 'cordis' { * - Output 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. - * - {@link ProcessHandle.kill} and the spec's abort signal escalate + * - {@link SubprocessHandle.kill} and the spec's abort signal escalate * SIGTERM→grace→SIGKILL across the whole process group. * - Disposal kills all still-running managed processes and awaits their exit. */ -export abstract class ProcessManager extends Service { +export abstract class SubprocessService extends Service { constructor(ctx: Context) { - super(ctx, 'processes') + super(ctx, 'subprocess') } /** @@ -56,7 +56,7 @@ export abstract class ProcessManager extends Service { * @param spec - argv, directory, limits, grace, cancellation, and environment. * @returns the live process handle (readers, kill, outcome promise). */ - abstract spawn(spec: ProcessSpawnSpec): ProcessHandle + abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle } -export default ProcessManager +export default SubprocessService diff --git a/packages/process/process/src/invariant.ts b/packages/subprocess/subprocess/src/invariant.ts similarity index 73% rename from packages/process/process/src/invariant.ts rename to packages/subprocess/subprocess/src/invariant.ts index f54476747e..62a720babd 100644 --- a/packages/process/process/src/invariant.ts +++ b/packages/subprocess/subprocess/src/invariant.ts @@ -1,12 +1,12 @@ -/** Package-owned invariant companion for the process-manager seam. @module @deepseek-ai/dsh-process/invariant */ +/** Package-owned invariant companion for the subprocess seam. @module @deepseek-ai/dsh-subprocess/invariant */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-process' +const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess' /** Cordis companion plugin name. */ -export const name = 'process-invariant' +export const name = 'subprocess-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] @@ -14,7 +14,7 @@ export const inject = ['invariants'] const install: InvariantInstaller = () => {} /** - * Register the process-manager invariant companion. + * Register the subprocess invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/process/process/src/types.ts b/packages/subprocess/subprocess/src/types.ts similarity index 87% rename from packages/process/process/src/types.ts rename to packages/subprocess/subprocess/src/types.ts index 1b81ab8c07..a5ec7f4e55 100644 --- a/packages/process/process/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -1,9 +1,9 @@ /** - * Vocabulary for the process-manager seam: fully-specified spawn requests, + * Vocabulary for the subprocess seam: fully-specified spawn requests, * bounded output with spill recovery, and live process handles. Command * defaulting, shell semantics, and presentation belong to consumers such as * the bash executor seam. - * @module dsh-process/types + * @module dsh-subprocess/types */ /** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ @@ -28,10 +28,10 @@ export interface CollectedOutput { /** * A fully-specified spawn request. This seam applies no defaults: every limit * and directory is explicit, so the caller's own config — not a hidden - * process-manager default — decides them (the `dsh-bash` request/spec split + * subprocess-service default — decides them (the `dsh-bash` request/spec split * is the owning template). */ -export interface ProcessSpawnSpec { +export interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ @@ -70,10 +70,10 @@ export interface ProcessSpawnSpec { /** * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the manager kills on abort but does not decide + * cancellation classification: the service kills on abort but does not decide * why — the caller reads the signal it owns to classify causes. */ -export interface ProcessOutcome { +export interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ @@ -82,8 +82,8 @@ export interface ProcessOutcome { stderr: CollectedOutput } -/** One incremental {@link ProcessOutputReader.readFrom} read. */ -export interface ProcessOutputRead { +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +export interface SubprocessOutputRead { /** Stream text from the requested offset (the whole retained tail when lossy). */ text: string /** Whole-stream offset to resume from on the next read. */ @@ -99,7 +99,7 @@ export interface ProcessOutputRead { * whole-stream byte coordinates owned by the caller, so independent readers * cannot consume one another's output. */ -export interface ProcessOutputReader { +export interface SubprocessOutputReader { /** * Read everything captured since `fromByte`. When that offset has slid out * of the in-memory tail window the read is `lossy` — it returns the whole @@ -107,22 +107,22 @@ export interface ProcessOutputReader { * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. */ - readFrom(fromByte: number): ProcessOutputRead + readFrom(fromByte: number): SubprocessOutputRead } /** * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL * escalation; buffered output remains readable after exit. */ -export interface ProcessHandle { +export interface SubprocessHandle { /** Process id (group leader); -1 when the spawn itself failed. */ readonly pid: number /** Live stdout reader (also readable after exit). */ - readonly stdout: ProcessOutputReader + readonly stdout: SubprocessOutputReader /** Live stderr reader (also readable after exit). */ - readonly stderr: ProcessOutputReader + readonly stderr: SubprocessOutputReader /** Resolves when the process closes; rejects only for spawn-level failures. */ - readonly done: Promise + readonly done: Promise /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ kill(): void } diff --git a/packages/process/process/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts similarity index 60% rename from packages/process/process/tests/service.spec.ts rename to packages/subprocess/subprocess/tests/service.spec.ts index 04ffa8c9f4..630aa35f1a 100644 --- a/packages/process/process/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { ProcessManager } from '@deepseek-ai/dsh-process' -import type { ProcessHandle, ProcessOutputRead, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import { SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** - * Minimal concrete manager: a hand-built handle. The seam is spawn-only — + * Minimal concrete service: a hand-built handle. The seam is spawn-only — * defaulting, shell semantics, and deadlines belong to callers — so this stub * is all an implementation owes the abstract class. */ -class StubProcessManager extends ProcessManager { - spawn(spec: ProcessSpawnSpec): ProcessHandle { - const read: ProcessOutputRead = { text: '', nextOffset: 0, lossy: false } +class StubSubprocessService extends SubprocessService { + spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false } let killed = false return { pid: spec.argv.length, @@ -27,11 +27,11 @@ class StubProcessManager extends ProcessManager { } } -describe('ProcessManager seam', () => { - it('a concrete subclass registers as ctx.processes and serves the abstract API', async () => { +describe('SubprocessService seam', () => { + it('a concrete subclass registers as ctx.subprocess and serves the abstract API', async () => { const ctx = new Context() - await ctx.plugin(StubProcessManager) - const handle = ctx.processes.spawn({ + await ctx.plugin(StubSubprocessService) + const handle = ctx.subprocess.spawn({ argv: ['true'], cwd: '/stub', stdoutMaxBytes: 1, @@ -48,8 +48,8 @@ describe('ProcessManager seam', () => { it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { const ctx = new Context() - await ctx.plugin(StubProcessManager) - class SecondManager extends StubProcessManager {} - await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/) + await ctx.plugin(StubSubprocessService) + class SecondManager extends StubSubprocessService {} + await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/) }) }) diff --git a/packages/process/process/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json similarity index 100% rename from packages/process/process/tsconfig.json rename to packages/subprocess/subprocess/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 340d47c7fd..82f3691044 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,9 +182,6 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../packages/process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -227,6 +224,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt @@ -427,9 +427,6 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:* version: link:../packages/plan/plan-mode - '@deepseek-ai/dsh-process-local': - specifier: workspace:* - version: link:../packages/process/process-local '@deepseek-ai/dsh-pty': specifier: workspace:* version: link:../packages/pty/pty @@ -478,6 +475,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:* + version: link:../packages/subprocess/subprocess-local '@deepseek-ai/dsh-tasks-local': specifier: workspace:* version: link:../packages/tasks/tasks-local @@ -591,12 +591,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-process': - specifier: workspace:^ - version: link:../../process/process '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -613,12 +613,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-process': + '@deepseek-ai/dsh-subprocess': specifier: workspace:^ - version: link:../../process/process - '@deepseek-ai/dsh-process-local': + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ - version: link:../../process/process-local + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -637,9 +637,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -649,6 +646,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -686,9 +686,6 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -704,6 +701,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1658,9 +1658,6 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local @@ -1682,6 +1679,9 @@ importers: '@deepseek-ai/dsh-skill-local': specifier: workspace:^ version: link:../../skill/skill-local + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2004,9 +2004,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention @@ -2016,6 +2013,9 @@ importers: '@deepseek-ai/dsh-spill': specifier: workspace:^ version: link:../../spill/spill + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2227,9 +2227,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2242,6 +2239,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -2279,9 +2279,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2291,6 +2288,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -2646,27 +2646,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/process/process: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - - packages/process/process-local: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-process': - specifier: workspace:^ - version: link:../process - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/pty/pty: devDependencies: '@deepseek-ai/dsh-agent': @@ -3623,9 +3602,6 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -3635,6 +3611,9 @@ importers: '@deepseek-ai/dsh-subagent-inprocess': specifier: workspace:^ version: link:../subagent-inprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash @@ -3694,6 +3673,27 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subprocess/subprocess: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/subprocess/subprocess-local: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../subprocess + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/support/acp-snapshot: dependencies: '@agentclientprotocol/sdk': @@ -4639,12 +4639,6 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-process': - specifier: workspace:^ - version: link:../../packages/process/process - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../packages/process/process-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard @@ -4711,6 +4705,12 @@ importers: '@deepseek-ai/dsh-subagent-subprocess': specifier: workspace:^ version: link:../../packages/subagent/subagent-subprocess + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 6fca02ccf8..c2124da532 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -40,8 +40,8 @@ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-process": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index b59b94a257..2f35e58d43 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -34,8 +34,8 @@ # Local bash executor; $DSH_CWD wins over the process cwd. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 755e65e00a..07f9b170ce 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -30,8 +30,8 @@ _CORDIS_YML = """\ root: './sessions' - id: session-checkpoints name: '@deepseek-ai/dsh-session-checkpoint-policy' -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 07adb0ba0c..82e7b3f6d6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -64,12 +64,12 @@ export const LINK_MAP: Record = { BashExecSpec: 'bash.md', BashProcess: 'bash.md', BashRunResult: 'bash.md', - DshEnvironment: 'process.md', - ProcessHandle: 'process.md', - ProcessOutcome: 'process.md', - ProcessOutputRead: 'process.md', - ProcessOutputReader: 'process.md', - ProcessSpawnSpec: 'process.md', + DshEnvironment: 'subprocess.md', + SubprocessHandle: 'subprocess.md', + SubprocessOutcome: 'subprocess.md', + SubprocessOutputRead: 'subprocess.md', + SubprocessOutputReader: 'subprocess.md', + SubprocessSpawnSpec: 'subprocess.md', CodeRunRequest: 'code-runtime.md', CodeRunResult: 'code-runtime.md', CompactionResult: 'compaction.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 05cec1dbad..6924cc2761 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -266,13 +266,13 @@ const SERVICE_ROLES: ServiceRole[] = [ note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.', }, { - key: 'processes', + key: 'subprocess', pkg: 'process', title: 'Process manager seam', mode: 'seam', implementations: ['process-local'], consumers: ['bash-local', 'bash-sandbox'], - note: 'The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation.', + note: 'The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation.', }, { key: 'bash', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 5d787814eb..fb8ccc17c5 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -19,7 +19,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import PlanModeService from '@deepseek-ai/dsh-plan-mode' @@ -198,7 +198,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor) await ctx.plugin(ToolBash) }, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 629cfe4038..8dfde7ad11 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2163,29 +2163,44 @@ "source": "packages/workflow/workflow/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessSpawnSpec", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessSpawnSpec", + "source": "packages/subprocess/subprocess/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessHandle", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessHandle", + "source": "packages/subprocess/subprocess/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessOutputReader", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessOutputReader", + "source": "packages/subprocess/subprocess/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessOutputRead", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessOutputRead", + "source": "packages/subprocess/subprocess/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessOutcome", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessOutcome", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "DshEnvironmentKey", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "DshEnvironment", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "CollectedOutput", + "source": "packages/subprocess/subprocess/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index dc284e9b21..0db4417fa5 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -72,8 +72,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, - 'packages/process/process': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' }, - 'packages/process/process-local': { kind: 'indirect', reason: 'The manager backend delegates model rendering to consumer seams such as the bash executor family.' }, + 'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' }, + 'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 2f73278c55..79c5ce1a83 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -54,7 +54,7 @@ "./packages/prompt/*/src/invariant.ts", "./packages/llm/*/src/invariant.ts", "./packages/bash/*/src/invariant.ts", - "./packages/process/*/src/invariant.ts", + "./packages/subprocess/*/src/invariant.ts", "./packages/code-runtime/*/src/invariant.ts", "./packages/fs/*/src/invariant.ts", "./packages/skill/*/src/invariant.ts", @@ -124,7 +124,7 @@ "./packages/llm/*/src", "./packages/bash/*/src", "./packages/pty/*/src", - "./packages/process/*/src", + "./packages/subprocess/*/src", "./packages/code-runtime/*/src", "./packages/fs/*/src", "./packages/lsp/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index c4bdc74fe1..a6397660a4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -81,8 +81,8 @@ { "path": "./packages/llm/llm-retry" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/cli-demo" }, - { "path": "./packages/process/process" }, - { "path": "./packages/process/process-local" }, + { "path": "./packages/subprocess/subprocess" }, + { "path": "./packages/subprocess/subprocess-local" }, { "path": "./packages/bash/bash" }, { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, diff --git a/vitest.config.ts b/vitest.config.ts index a0379a325b..48f872bcb9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ const windowsUnsupportedPackages = process.platform === 'win32' ? [ 'packages/bash/*', 'packages/hooks/*', - 'packages/process/*', + 'packages/subprocess/*', 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', 'packages/sdk/create-sdk', @@ -41,7 +41,7 @@ const testIncludes = [ // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ - 'packages/process/process-local/tests/spawn.spec.ts', + 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts', From 5e6edce4ed6a2a4ea98e1098409da316f057b2ab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:57:14 +0800 Subject: [PATCH 20/75] fix(subprocess): finish the rename in generated api-catalog and built-bin closures CI caught two rename escapes: the regenerated api-catalog.ts had not been committed after the LINK_MAP retarget, and the built-bin e2e consumer closures still linked packages/process/* directory paths. --- .../cordis/tool-cordis/src/api-catalog.ts | 32 +++++++++++++++++++ .../examples/acp-demo/tests/built-bin.e2e.ts | 2 +- .../examples/cli-demo/tests/built-bin.e2e.ts | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 59b97aedd5..ec0d279bab 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1458,6 +1458,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeRunResult', declaration: 'export interface CodeRunResult {\n value?: CodeJsonValue;\n logs: string[];\n error?: CodeRunFailure;\n}', }, + { + name: 'CollectedOutput', + declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}', + }, { name: 'CommandDefinition', declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', @@ -1554,6 +1558,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DomainTableSpec', declaration: 'export interface DomainTableSpec {\n readonly valueSchema: ZodType;\n readonly __key?: K;\n}', }, + { + name: 'DshEnvironment', + declaration: 'export type DshEnvironment = Readonly>;', + }, + { + name: 'DshEnvironmentKey', + declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;', + }, { name: 'EditGoalRequest', declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', @@ -2198,6 +2210,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentStopReasonMap', declaration: 'export interface SubagentStopReasonMap {\n completed: \'completed\';\n aborted: \'aborted\';\n error: \'error\';\n \'max-tokens\': \'max-tokens\';\n refusal: \'refusal\';\n}', }, + { + name: 'SubprocessHandle', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdout: SubprocessOutputReader;\n readonly stderr: SubprocessOutputReader;\n readonly done: Promise;\n kill(): void;\n}', + }, + { + name: 'SubprocessOutcome', + declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}', + }, + { + name: 'SubprocessOutputRead', + declaration: 'export interface SubprocessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}', + }, + { + name: 'SubprocessOutputReader', + declaration: 'export interface SubprocessOutputReader {\n readFrom(fromByte: number): SubprocessOutputRead;\n}', + }, + { + name: 'SubprocessSpawnSpec', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + }, { name: 'SurfaceEvent', declaration: 'export type SurfaceEvent = SessionEvent & {\n surfaceOp: SurfaceOp;\n};', diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index a82896ab73..c8cf5e3717 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -32,7 +32,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'process/process', 'process/process-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'acp/acp', 'examples/acp-demo', 'util/paths', diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 6f80c983f3..4dca28936a 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -23,7 +23,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'process/process', 'process/process-local', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'context/workspace-context', From 12a7e384174d34195a30bb8083bba2d14f117e51 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:07:42 +0800 Subject: [PATCH 21/75] feat(subprocess): reshape the seam Node-ward for multi-consumer use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review direction (tianyicui, PR #660): make the interface closer to Node's API so the other process-running places can adopt it. The spec gains per-stream stdio dispositions — 'pipe' (raw Readable/Writable for protocol streams), 'inherit' (diagnostics to the parent), and collect mode ({maxBytes, spill?} — the old bounded tail-keep shape, now with spill optional for diagnostic tails). SubprocessOutcome carries exit facts only; collected output stays readable through handle.collected after settlement (spill fds are sealed at the settle boundary). The handle grows Node-style kill(signal) (single signal, tree-scoped, no-op after settlement), terminate() (the SIGTERM→grace→SIGKILL escalation, also driven by the spec signal), waitForExit() (tree liveness, not just the direct child), and dispose() (the cooperative stdin-EOF→SIGTERM→SIGKILL ladder from subagent-subprocess, graces caller-supplied). Tree semantics are platform-correct: POSIX detached groups with direct-child fallback; Windows taskkill /T with an injectable runner. scrubbedParentEnv/SENSITIVE_ENV_PATTERN move to the seam as the one shared scrub definition. bash-local maps its config onto collect modes and batch stdin and reads results through the collected readers; its kill() maps to terminate() so task_kill keeps escalation semantics. --- packages/bash/bash-local/src/index.ts | 58 ++- .../subprocess/subprocess-local/src/index.ts | 30 +- .../subprocess/subprocess-local/src/spawn.ts | 339 +++++++++++++----- .../subprocess-local/tests/local.spec.ts | 13 +- .../subprocess-local/tests/spawn.spec.ts | 279 ++++++++++---- packages/subprocess/subprocess/src/index.ts | 68 +++- packages/subprocess/subprocess/src/types.ts | 200 ++++++++--- .../subprocess/tests/service.spec.ts | 60 ++-- 8 files changed, 786 insertions(+), 261 deletions(-) diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 463e7a96d4..9d9ed676e2 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -11,8 +11,8 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' /** @@ -54,6 +54,16 @@ export interface Config { /** The shape after schemastery applied the defaults (cwd has none). */ type ResolvedConfig = Required> & Pick +/** Project a settled collect-mode reader into the final CollectedOutput shape. */ +function finalOutput(reader: SubprocessOutputReader): CollectedOutput { + const read = reader.readFrom(0) + return { + text: read.text, + truncated: read.lossy, + ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, + } +} + 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`) @@ -127,36 +137,58 @@ export class LocalBashExecutor extends BashExecutor { } } - /** Map one resolved bash spec onto a fully-specified process spawn. */ + /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { argv: ['bash', '-c', spec.command], cwd: spec.workdir, - stdoutMaxBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, + stdio: { + stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', + stdout: collect(stdoutMaxBytes), + stderr: collect(this.config.maxOutputBytes), + }, graceMs: this.config.graceMs, signal, - stdin: spec.stdin, env: { ...ENV_OVERRIDES, ...spec.env }, dshEnv: spec.dshEnv, } } + /** The collect-mode readers the executor itself requested (present by construction). */ + private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { + const { stdout, stderr } = handle.collected + if (stdout === undefined || stderr === undefined) { + throw new Error('bash-local: subprocess implementation dropped a requested collect stream') + } + return { stdout, stderr } + } + async run(spec: BashExecSpec): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const outcome = await this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const outcome = await handle.done + const collected = LocalBashExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut - return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs } + return { + ...outcome, + timedOut, + aborted, + timeoutMs: spec.timeoutMs, + stdout: finalOutput(collected.stdout), + stderr: finalOutput(collected.stderr), + } } start(spec: BashExecSpec): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const collected = LocalBashExecutor.collected(running) // A spawn failure produces no process output, so the subprocess service has nothing // to buffer; the note is delivered exactly once through the read path. @@ -180,7 +212,7 @@ export class LocalBashExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, running.stderr.readFrom(0).text) + this.onProcessDone(proc, collected.stderr.readFrom(0).text) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' @@ -188,8 +220,8 @@ export class LocalBashExecutor extends BashExecutor { this.onProcessDone(proc, spawnFailureNote) }), readOutput: (): BashProcessRead => { - const out = running.stdout.readFrom(stdoutOffset) - const err = running.stderr.readFrom(stderrOffset) + const out = collected.stdout.readFrom(stdoutOffset) + const err = collected.stderr.readFrom(stderrOffset) stdoutOffset = out.nextOffset stderrOffset = err.nextOffset @@ -211,7 +243,7 @@ export class LocalBashExecutor extends BashExecutor { kill: (): boolean => { if (proc.status !== 'running') return false proc.status = 'killed' - running.kill() + running.terminate() return true }, } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index a5256f325b..d76e5cf410 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -1,36 +1,38 @@ /** - * Local-subprocess implementation of the subprocess seam. Each spawn is - * a detached process group with bounded, spill-backed output; disposal kills - * and joins live groups. It has no config: every limit arrives on the spec, - * so the deployment-varying choices stay with the calling seam's config (the - * bash executor's, today). + * Local implementation of the subprocess seam. Each spawn is a detached + * process tree with the spec's per-stream stdio dispositions; disposal + * terminates and joins live trees. It has no config: every disposition and + * limit arrives on the spec, so the deployment-varying choices stay with the + * calling seam's config (the bash executor's, the LSP host's, …). * @module @deepseek-ai/dsh-subprocess-local */ import { Context } from 'cordis' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { spawnProcess } from './spawn.ts' +import { spawnSubprocess } from './spawn.ts' import type { SpawnInternals } from './spawn.ts' /** - * Local subprocess service: detached process groups, tail-keep truncation with - * bounded spill files, credential-scrubbed environment, and group - * SIGTERM→grace→SIGKILL escalation. + * Local subprocess service: detached process trees, Node-shaped stdio + * dispositions (raw pipes, inherit, bounded tail-keep collection with spill + * files), credential-scrubbed environment, tree-scoped signalling with + * SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. */ export class LocalSubprocessService extends SubprocessService { - /** Live handles retained only so disposal can kill and join them. */ + /** Live handles retained only so disposal can terminate and join them. */ private live = new Set() - /** Test seam: spill knobs forwarded to spawnProcess. */ + /** Test seam: spill and platform knobs forwarded to spawnSubprocess. */ internals: SpawnInternals = {} constructor(ctx: Context) { super(ctx) ctx.effect(() => async () => { - // Await closure so even a TERM-trapping child cannot outlive the fiber. + // Terminate (escalating), then await closure so even a TERM-trapping + // child cannot outlive the fiber. const pending: Promise[] = [] for (const handle of this.live) { - handle.kill() + handle.terminate() // Spawn-failure rejections already settled and left the live set. pending.push(handle.done.catch(() => {})) } @@ -40,7 +42,7 @@ export class LocalSubprocessService extends SubprocessService { } spawn(spec: SubprocessSpawnSpec): SubprocessHandle { - const handle = spawnProcess(spec, this.internals) + const handle = spawnSubprocess(spec, this.internals) this.live.add(handle) handle.done.then( () => { this.live.delete(handle) }, diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 95b391ea4b..f939b9e774 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,33 +1,36 @@ /** - * Process plumbing for the local subprocess service: detached process-group - * spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation. + * Process plumbing for the local subprocess service: detached process-tree + * spawn with per-stream stdio dispositions, tail-keep collection with spill + * files, tree-scoped signalling (POSIX groups; Windows taskkill), the + * SIGTERM→SIGKILL escalation, and the cooperative EOF-first dispose ladder. * This layer reacts to an abort signal; callers own deadlines and classify * causes. * @module dsh-subprocess-local/spawn */ -import { type ChildProcessByStdio, spawn } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import type { Readable } from 'node:stream' import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' -import type { CollectedOutput, DshEnvironment, SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import type { + CollectedOutput, + DshEnvironment, + SubprocessCollect, + SubprocessDisposeGraces, + SubprocessHandle, + SubprocessOutcome, + SubprocessOutputMode, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' /** - * Credential-shaped env vars are NOT forwarded to children (the harness's - * own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or - * spill files). Same default pattern as Codex's env policy; a future config - * can whitelist specific vars when a workflow genuinely needs one. - */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** - * Build a child environment from scrubbed ambient values, ordinary caller - * entries, and a managed `DSH_*` snapshot. Ambient managed names are removed; - * ordinary and managed entries reject the other channel's namespace before - * `dshEnv` merges last. + * Build a child environment from the scrubbed parent base, ordinary caller + * entries, and a managed `DSH_*` snapshot. Ordinary and managed entries + * reject the other channel's namespace before `dshEnv` merges last. * @param extra - caller entries; `DSH_*` names are rejected. * @param dshEnv - managed entries; non-`DSH_*` names are rejected. * @returns the environment to hand to `spawn` for the child process. @@ -36,10 +39,6 @@ export function childEnv( extra?: Readonly>, dshEnv?: DshEnvironment, ): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value - } for (const key of Object.keys(extra ?? {})) { if (key.startsWith(DSH_ENV_PREFIX)) { throw new Error(`ordinary child env cannot set reserved variable "${key}"; use dshEnv`) @@ -50,13 +49,17 @@ export function childEnv( throw new Error(`managed child env cannot set ordinary variable "${key}"; use env`) } } - return { ...env, ...extra, ...dshEnv } + return { ...scrubbedParentEnv(), ...extra, ...dshEnv } } -/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */ +/** Injectable knobs so tests can exercise spill and platform behavior deterministically. */ export interface SpawnInternals { /** Directory for spill files (defaults to the OS temp dir). */ spillDir?: string + /** Windows tree-termination runner (defaults to `taskkill /PID /T /F`). */ + taskkill?: (pid: number) => void + /** Host platform override for signalling decisions. */ + platform?: NodeJS.Platform } let spillCounter = 0 @@ -73,9 +76,11 @@ function privateSpillDir(): string { } /** - * Collects one stream with a bounded in-memory tail. On first overflow a - * spill file is created and every chunk (including those already collected) - * is appended there while the full stream remains within `maxSpillBytes`. + * Collects one stream with a bounded in-memory tail. With a spill cap, on + * first overflow a spill file is created and every chunk (including those + * already collected) is appended there while the full stream remains within + * the cap; without one, only the in-memory tail is ever retained (the + * diagnostic-tail shape — a language server's stderr). * * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the * end of command output; the spill file covers the head. @@ -86,23 +91,25 @@ export class OutputCollector { private dropped = false private spillFd: number | undefined private spillFile: string | undefined - private spillDisabled = false + private spillDisabled: boolean /** Total bytes ever pushed (not just retained). */ private total = 0 constructor( private readonly maxBytes: number, - private readonly maxSpillBytes: number, + private readonly maxSpillBytes: number | undefined, private readonly label: string, private readonly spillDir: string, - ) {} + ) { + this.spillDisabled = maxSpillBytes === undefined + } /** * Ingest one stream chunk, counting it toward the whole-stream total. On - * first overflow of the in-memory cap a spill file is opened and every chunk - * (already-collected ones included) is appended there from then on; the - * in-memory tail then drops whole chunks from its head (or the head of a - * single over-cap chunk) until it fits the cap again. + * first overflow of the in-memory cap a spill file is opened (when spilling + * is enabled) and every chunk (already-collected ones included) is appended + * there from then on; the in-memory tail then drops whole chunks from its + * head (or the head of a single over-cap chunk) until it fits the cap again. * @param chunk - the raw bytes from one stream 'data' event. */ push(chunk: Buffer): void { @@ -130,7 +137,7 @@ export class OutputCollector { /** Open the spill file lazily and append `chunk` (and any prior chunks once). */ private spillAll(chunk: Buffer): void { - if (this.total > this.maxSpillBytes) { + if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) { this.discardSpill() return } @@ -194,22 +201,30 @@ export class OutputCollector { } /** - * Close the spill file (if any) and return the final output. A failed close - * (delayed writeback fault) stops advertising the spill path — the file may - * be missing its tail — but still returns the in-memory result. + * Close the spill file once the stream has ended. A failed close (delayed + * writeback fault) stops advertising the spill path — the file may be + * missing its tail — while every in-memory read keeps working. Idempotent; + * the spawn path seals both collectors at settlement so reads after exit + * never point at a still-open file. + */ + seal(): void { + if (this.spillFd === undefined) return + try { + closeSync(this.spillFd) + } catch { + // A delayed writeback failure makes the spill unreliable; keep the + // in-memory result but stop advertising that file. + this.spillFile = undefined + } + this.spillFd = undefined + } + + /** + * Seal the spill file and return the final output. * @returns the final collected output: tail text, truncation flag, and the spill path when intact. */ finalize(): CollectedOutput { - if (this.spillFd !== undefined) { - try { - closeSync(this.spillFd) - } catch { - // A delayed writeback failure makes the spill unreliable; keep finalize - // total but stop advertising that file. - this.spillFile = undefined - } - this.spillFd = undefined - } + this.seal() return { text: Buffer.concat(this.chunks).toString('utf8'), truncated: this.dropped, @@ -219,9 +234,9 @@ export class OutputCollector { } /** - * Send `sig` to a detached process group. Never throws: delivery races process - * exit and may run in a timer callback, so failures are contained and a - * non-positive pid is a no-op. + * Send `sig` to a detached POSIX process group. Never throws: delivery races + * process exit and may run in a timer callback, so failures are contained and + * a non-positive pid is a no-op. * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op. * @param sig - the signal to deliver to the whole group. */ @@ -235,14 +250,60 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void { } /** - * Spawn one isolated detached process group and collect its output. - * Runtime exits resolve as {@link SubprocessOutcome}; only spawn failures reject. - * @param spec - fully resolved argv, cwd, limits, and cancellation. - * @param internals - test-only spill-directory override. - * @returns live process handle and outcome promise. + * Terminate one Windows process tree with `taskkill /T /F`. Contained like + * POSIX group signalling — delivery races tree exit, so an absent tree, a + * nonzero status, or a missing taskkill binary must not break idempotent + * teardown. + * @param pid - root process id; non-positive is a no-op. */ -export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { +export function taskkillProcessTree(pid: number): void { + if (pid <= 0) return + // Outcome deliberately unchecked: an already-absent tree (status 128) and + // exit races are as tolerable here as ESRCH is for a POSIX group signal. + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) +} + +/** + * Signal a detached process tree with platform-correct semantics: POSIX + * signals the negative process-group id and falls back to the direct child + * when the group is gone; Windows terminates the tree via taskkill (any + * signal value force-terminates — Node maps signals to TerminateProcess). + */ +function signalTree( + platform: NodeJS.Platform, + pid: number, + sig: NodeJS.Signals, + child: ChildProcess, + taskkill: (pid: number) => void, +): void { + if (platform === 'win32') { + taskkill(pid) + return + } + if (pid <= 0) return + try { + process.kill(-pid, sig) + } catch { + try { + child.kill(sig) + } catch { + // The direct child already exited; teardown remains idempotent. + } + } +} + +/** + * Spawn one isolated detached process tree with the spec's per-stream stdio + * dispositions. Runtime exits resolve `done` as {@link SubprocessOutcome}; + * only spawn failures reject. + * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. + * @param internals - test-only spill-directory, platform, and taskkill overrides. + * @returns live subprocess handle. + */ +export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { const spillDir = internals.spillDir ?? privateSpillDir() + const platform = internals.platform ?? process.platform + const taskkill = internals.taskkill ?? taskkillProcessTree if (spec.signal?.aborted) { throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) @@ -252,41 +313,66 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal throw new Error('invalid argv: expected a non-empty program name at argv[0]') } - // Keep absent stdin as /dev/null; literal tuples preserve non-null output types. - const env = childEnv(spec.env, spec.dshEnv) - const child: ChildProcessByStdio = spec.stdin !== undefined - ? spawn(program, args, { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) - : spawn(program, args, { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) + const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect => + mode !== 'pipe' && mode !== 'inherit' + const outMode = spec.stdio.stdout + const errMode = spec.stdio.stderr + const stdinMode = spec.stdio.stdin - const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir) - const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir) - child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) }) - child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) + const env = childEnv(spec.env, spec.dshEnv) + const child = spawn(program, args, { + cwd: spec.cwd, + env, + stdio: [ + stdinMode === 'ignore' ? 'ignore' : 'pipe', + outMode === 'inherit' ? 'inherit' : 'pipe', + errMode === 'inherit' ? 'inherit' : 'pipe', + ], + // `detached` gives teardown a tree root on POSIX (its own process group); + // Windows terminates by root pid through taskkill /T instead. + detached: platform !== 'win32', + }) + + const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => { + if (!isCollect(mode) || stream === null) return undefined + const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir) + stream.on('data', (chunk: Buffer) => { collector.push(chunk) }) + return collector + } + const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') + const stderrCollector = collectStream(errMode, child.stderr, 'stderr') let graceTimer: NodeJS.Timeout | undefined let settled = false - // Failed spawns use pid -1 so kill remains a no-op. + // Failed spawns use pid -1 so signalling remains a no-op. const pid = child.pid ?? -1 - const kill = (): void => { - if (graceTimer !== undefined) return // escalation already in flight - // After settlement the group is gone and the pid may be reused; callers - // commonly kill() in a finally, so this must not re-signal or start a - // timer that outlives the handle. + const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => { + // After settlement the tree is gone and the pid may be reused; callers + // commonly kill() in a finally, so this must not re-signal. if (settled) return - killGroup(pid, 'SIGTERM') - graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) + signalTree(platform, pid, sig, child, taskkill) + } + + const terminate = (): void => { + if (graceTimer !== undefined) return // escalation already in flight + if (settled) return + signalTree(platform, pid, 'SIGTERM', child, taskkill) + graceTimer = setTimeout(() => { + if (!settled) signalTree(platform, pid, 'SIGKILL', child, taskkill) + }, spec.graceMs) } // The caller owns timeout classification; this layer only reacts to abort. - const onAbort = (): void => { kill() } + const onAbort = (): void => { terminate() } spec.signal?.addEventListener('abort', onAbort, { once: true }) - // Stdin writes are best-effort; process exit and captured output remain authoritative. - if (child.stdin !== null) { + // 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' && child.stdin !== null) { child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - child.stdin.end(spec.stdin) + child.stdin.end(stdinMode.data) } const done = new Promise((resolve, reject) => { @@ -294,15 +380,14 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return settled = true - child.stdout.destroy() - child.stderr.destroy() + // Only harness-collected pipes are force-closed at the drain boundary; + // a 'pipe'-mode stream belongs to the caller and closes with the child. + if (stdoutCollector !== undefined) child.stdout?.destroy() + if (stderrCollector !== undefined) child.stderr?.destroy() + stdoutCollector?.seal() + stderrCollector?.seal() cleanup() - resolve({ - exitCode, - signal, - stdout: stdout.finalize(), - stderr: stderr.finalize(), - }) + resolve({ exitCode, signal }) } child.on('error', (error) => { // No meaningful close outcome follows a spawn failure. @@ -311,6 +396,9 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal reject(error) }) child.on('exit', (exitCode, signal) => { + // A surviving descendant that inherited a pipe must not hold the + // outcome open indefinitely: after exit, the same bounded grace that + // governs kills also bounds the close wait. pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) }) child.on('close', settle) @@ -321,5 +409,82 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal } }) - return { pid, stdout, stderr, done, kill } + /** Whether the detached tree's root (or POSIX group) is still alive. */ + const treeAlive = (): boolean => { + if (pid <= 0) return false + if (platform === 'win32') { + // Windows has no group-liveness probe; the direct child's exit is the + // observable boundary (taskkill /T already took the tree with it). + return child.exitCode === null && child.signalCode === null + } + try { + process.kill(-pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') return false + /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs + tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ + if (code === 'EPERM') return true + return child.exitCode === null && child.signalCode === null + /* v8 ignore stop */ + } + } + + const waitForExit = async (signal?: AbortSignal): Promise => { + while (treeAlive()) { + if (signal?.aborted) return false + await yieldToEventLoop() + } + return true + } + + /** Race settlement against a timer without leaving listeners or live timers behind. */ + const settlesWithin = async (ms: number): Promise => { + if (settled) return true + let timer: NodeJS.Timeout | undefined + const timeout = new Promise((resolve) => { + // `.unref()` so a pending grace timer never keeps the parent's loop alive. + timer = setTimeout(() => { resolve(false) }, ms) + timer.unref() + }) + try { + return await Promise.race([done.then(() => true, () => true), timeout]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } + } + + let disposal: Promise | undefined + const dispose = (graces: SubprocessDisposeGraces): Promise => (disposal ??= (async () => { + // 1. Close a piped stdin and allow cooperative teardown and flush. + if (stdinMode === 'pipe') child.stdin?.end() + if (await settlesWithin(graces.eofGraceMs)) return + // 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates. + if (platform !== 'win32') { + kill('SIGTERM') + if (await settlesWithin(graces.graceMs)) return + } + // 3. Force-kill the tree and await a bounded exit edge. + kill('SIGKILL') + if (!(await settlesWithin(graces.graceMs))) { + throw new Error(`child process did not exit within ${graces.graceMs}ms after forced termination`) + } + })()) + + return { + pid, + stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined, + stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined, + stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined, + collected: { + ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {}, + ...stderrCollector !== undefined ? { stderr: stderrCollector } : {}, + }, + done, + kill, + terminate, + waitForExit, + dispose, + } } diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index d6f81042b5..ca723fdb87 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -7,9 +7,11 @@ function spec(command: string, overrides: Partial = {}): Su return { argv: ['bash', '-c', command], cwd: process.cwd(), - stdoutMaxBytes: 64_000, - stderrMaxBytes: 64_000, - maxSpillBytes: 64 * 1024 * 1024, + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } }, + stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } }, + }, graceMs: 200, ...overrides, } @@ -19,9 +21,10 @@ describe('LocalSubprocessService', () => { it('registers as ctx.subprocess and spawns managed handles', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) - const result = await ctx.subprocess.spawn(spec('echo managed')).done + const handle = ctx.subprocess.spawn(spec('echo managed')) + const result = await handle.done expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('managed\n') + expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n') await fiber.dispose() }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 1bc2b27498..2f48b0fef3 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess' -import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import { killGroup, OutputCollector, spawnSubprocess } from '../src/spawn.ts' +import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -33,15 +33,25 @@ vi.mock('node:fs', async (importOriginal) => { const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-')) -function spec(command: string, overrides: Partial[0]> = {}) { +type SpecOverrides = Partial[0]> & { + stdoutMaxBytes?: number + stderrMaxBytes?: number + maxSpillBytes?: number + stdin?: string +} + +function spec(command: string, overrides: SpecOverrides = {}) { + const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides return { argv: ['bash', '-c', command], cwd: process.cwd(), - stdoutMaxBytes: 64_000, - stderrMaxBytes: 64_000, - maxSpillBytes: 64 * 1024 * 1024, + stdio: { + stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const, + stdout: { maxBytes: stdoutMaxBytes, spill: { maxBytes: maxSpillBytes } }, + stderr: { maxBytes: stderrMaxBytes, spill: { maxBytes: maxSpillBytes } }, + }, graceMs: 3_000, - ...overrides, + ...rest, } } @@ -62,12 +72,22 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise { async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - if (running.stdout.readFrom(0).text.includes(expected)) return + if (running.collected.stdout!.readFrom(0).text.includes(expected)) return await new Promise(resolve => setTimeout(resolve, 20)) } throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) } +/** Await settlement and project both collected streams like a batch outcome. */ +async function finish(running: SubprocessHandle) { + const outcome = await running.done + const final = (reader: SubprocessOutputReader | undefined) => { + const read = reader!.readFrom(0) + return { text: read.text, truncated: read.lossy, ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {} } + } + return { ...outcome, stdout: final(running.collected.stdout), stderr: final(running.collected.stderr) } +} + async function waitForPidFile(path: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { @@ -82,9 +102,9 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) } -describe('spawnProcess', () => { +describe('spawnSubprocess', () => { it('captures stdout on success', async () => { - const result = await spawnProcess(spec('echo hello')).done + const result = await finish(spawnSubprocess(spec('echo hello'))) expect(result.exitCode).toBe(0) expect(result.signal).toBeNull() expect(result.stdout.text).toBe('hello\n') @@ -93,33 +113,33 @@ describe('spawnProcess', () => { }) it('captures stderr separately', async () => { - const result = await spawnProcess(spec('echo oops >&2')).done + const result = await finish(spawnSubprocess(spec('echo oops >&2'))) expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('') expect(result.stderr.text).toBe('oops\n') }) it('captures both streams', async () => { - const result = await spawnProcess(spec('echo out; echo err >&2')).done + const result = await finish(spawnSubprocess(spec('echo out; echo err >&2'))) expect(result.stdout.text).toBe('out\n') expect(result.stderr.text).toBe('err\n') }) it('reports non-zero exit codes', async () => { - const result = await spawnProcess(spec('exit 42')).done + const result = await finish(spawnSubprocess(spec('exit 42'))) expect(result.exitCode).toBe(42) expect(result.signal).toBeNull() }) it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => { - const result = await spawnProcess(spec('echo "${TERM:-unset}"', { + const result = await finish(spawnSubprocess(spec('echo "${TERM:-unset}"', { env: { TERM: 'callers-choice' }, - })).done + }))) expect(result.stdout.text).toBe('callers-choice\n') }) it('runs in the requested cwd', async () => { - const result = await spawnProcess(spec('pwd', { cwd: '/tmp' })).done + const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' }))) expect(result.stdout.text.trim()).toMatch(/\/tmp$/) }) @@ -129,7 +149,7 @@ describe('spawnProcess', () => { // assert the kill itself lands as SIGTERM. const controller = new AbortController() const start = Date.now() - const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) + const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('deadline') }, 100) const result = await running.done expect(Date.now() - start).toBeLessThan(5_000) @@ -137,10 +157,21 @@ describe('spawnProcess', () => { expect(result.exitCode).toBeNull() }) - it('escalates to SIGKILL when SIGTERM is trapped', async () => { - const running = spawnProcess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) + it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { + const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) await waitForStdout(running, 'ready\n') - running.kill() + running.terminate() + const result = await running.done + expect(result.signal).toBe('SIGKILL') + }) + + it('kill() sends one signal Node-style, without escalation', async () => { + const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60', { graceMs: 100 })) + await waitForStdout(running, 'armed\n') + running.kill() // trapped SIGTERM, no SIGKILL follow-up + await new Promise(resolve => setTimeout(resolve, 400)) + expect(running.collected.stdout).toBeDefined() + running.kill('SIGKILL') // explicit signal choice, still no timers const result = await running.done expect(result.signal).toBe('SIGKILL') }) @@ -149,7 +180,7 @@ describe('spawnProcess', () => { // The subshell writes the sleep's pid then waits on it; killing the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) - const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) expect(grandchild).toBeGreaterThan(0) @@ -161,7 +192,7 @@ describe('spawnProcess', () => { it('aborts via AbortSignal mid-run', async () => { const controller = new AbortController() - const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) + const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('user cancelled') }, 50) const result = await running.done expect(result.signal).toBe('SIGTERM') @@ -170,19 +201,19 @@ describe('spawnProcess', () => { it('throws when the signal is already aborted before spawn', () => { const controller = new AbortController() controller.abort('too late') - expect(() => spawnProcess(spec('echo hi', { signal: controller.signal }))) + expect(() => spawnSubprocess(spec('echo hi', { signal: controller.signal }))) .toThrow(/aborted before spawn: too late/) }) it('rejects with a spawn error for a nonexistent cwd', async () => { - await expect(spawnProcess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) + await expect(spawnSubprocess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) .rejects.toThrow(/ENOENT/) }) - it('kill() is idempotent (second call does not restart escalation)', async () => { - const running = spawnProcess(spec('sleep 60')) - running.kill() - running.kill() + it('terminate() is idempotent (second call does not restart escalation)', async () => { + const running = spawnSubprocess(spec('sleep 60')) + running.terminate() + running.terminate() const result = await running.done expect(result.signal).toBe('SIGTERM') }) @@ -190,10 +221,10 @@ describe('spawnProcess', () => { it('bounds inherited-pipe draining after the shell exits', async () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now() - const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) const descendant = await waitForPidFile(pidFile) try { - const result = await running.done + const result = await finish(running) expect(Date.now() - started).toBeLessThan(1_000) expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('shell-done\n') @@ -206,7 +237,7 @@ describe('spawnProcess', () => { describe('stdin and extra env (set by in-process plugins)', () => { it('writes stdin to the command and closes it', async () => { - const result = await spawnProcess(spec('cat', { stdin: 'hello from stdin\n' })).done + const result = await finish(spawnSubprocess(spec('cat', { stdin: 'hello from stdin\n' }))) expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('hello from stdin\n') }) @@ -214,7 +245,7 @@ describe('stdin and extra env (set by in-process plugins)', () => { it('a command that reads stdin sees EOF when none is supplied', async () => { // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no // output (it does NOT block). - const result = await spawnProcess(spec('cat')).done + const result = await finish(spawnSubprocess(spec('cat'))) expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('') }) @@ -222,25 +253,25 @@ describe('stdin and extra env (set by in-process plugins)', () => { it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. - const none = await spawnProcess(spec('test -c /dev/stdin && echo char || echo other')).done + const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other'))) expect(none.stdout.text).toBe('char\n') - const piped = await spawnProcess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done + const piped = await finish(spawnSubprocess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' }))) expect(piped.stdout.text).toBe('socket\n') }) it('merges ordinary extra env entries onto the scrubbed environment', async () => { - const result = await spawnProcess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { + const result = await finish(spawnSubprocess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, - })).done + }))) expect(result.stdout.text).toBe('alpha/beta\n') }) it('an explicit extra env entry overrides the credential scrub', async () => { // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. - const result = await spawnProcess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', { + const result = await finish(spawnSubprocess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', { env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, - })).done + }))) expect(result.stdout.text).toBe('explicit-wins\n') }) @@ -248,20 +279,20 @@ describe('stdin and extra env (set by in-process plugins)', () => { // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. // The handler swallows that write error and `done` reports the child's real exit. const big = 'x'.repeat(1024 * 1024) - const result = await spawnProcess(spec('exit 7', { stdin: big })).done + const result = await finish(spawnSubprocess(spec('exit 7', { stdin: big }))) expect(result.exitCode).toBe(7) }) }) describe('output truncation and spill', () => { it('applies stdout and stderr caps independently', async () => { - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', { stdoutMaxBytes: 500, stderrMaxBytes: 100, }), { spillDir }, - ).done + )) expect(result.stdout.truncated).toBe(false) expect(result.stdout.text).toBe('x'.repeat(500)) expect(result.stderr.truncated).toBe(true) @@ -270,10 +301,10 @@ describe('output truncation and spill', () => { it('keeps the tail and spills the full stream to disk', async () => { // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, - ).done + )) expect(result.stdout.truncated).toBe(true) expect(result.stdout.text.length).toBeLessThanOrEqual(500) expect(result.stdout.text).toContain('line-0200') @@ -285,10 +316,10 @@ describe('output truncation and spill', () => { }) it('does not truncate output exactly at the cap', async () => { - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, - ).done + )) expect(result.stdout.truncated).toBe(false) expect(result.stdout.text.length).toBe(500) expect(result.stdout.spillPath).toBeUndefined() @@ -296,10 +327,10 @@ describe('output truncation and spill', () => { it('settles with the tail and no spill path when final spill close fails', async () => { failNextClose.value = true - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, - ).done + )) expect(failNextClose.value).toBe(false) expect(result.exitCode).toBe(0) expect(result.stdout.truncated).toBe(true) @@ -403,7 +434,7 @@ describe('killGroup', () => { }) it('swallows ESRCH for vanished groups', async () => { - const running = spawnProcess(spec('true')) + const running = spawnSubprocess(spec('true')) await running.done expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) @@ -412,7 +443,7 @@ describe('killGroup', () => { // Cleanup code commonly kills handles in a finally; after settlement the // group is gone and the pid may be reused, so a late kill must be inert // (no signal to a possibly-recycled pgid, no referenced timer delaying exit). - const running = spawnProcess(spec('true')) + const running = spawnSubprocess(spec('true')) await running.done const spy = vi.spyOn(process, 'kill') try { @@ -424,17 +455,137 @@ describe('killGroup', () => { }) }) +describe('stdio dispositions', () => { + it("'pipe' exposes raw streams for caller-owned protocol decoding", async () => { + const running = spawnSubprocess({ + ...spec('cat'), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1000 } }, + }) + expect(running.stdin).toBeDefined() + expect(running.stdout).toBeDefined() + expect(running.stderr).toBeUndefined() + expect(running.collected.stdout).toBeUndefined() + expect(running.collected.stderr).toBeDefined() + + const echoed = new Promise((resolve) => { + let text = '' + running.stdout!.on('data', (chunk: Buffer) => { text += chunk.toString('utf8') }) + running.stdout!.on('end', () => { resolve(text) }) + }) + running.stdin!.end('through the pipe\n') + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(await echoed).toBe('through the pipe\n') + }) + + it('a collect mode without spill keeps only the in-memory tail (no file)', async () => { + const running = spawnSubprocess({ + ...spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 100 }, stderr: { maxBytes: 100 } }, + }, { spillDir }) + await running.done + const read = running.collected.stdout!.readFrom(0) + expect(read.lossy).toBe(true) + expect(read.text).toContain('line-0200') + expect(read.spillPath).toBeUndefined() + }) +}) + +describe('dispose ladder', () => { + it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { + const running = spawnSubprocess({ + ...spec('read -r line; exit 0'), + stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + }) + await running.dispose({ eofGraceMs: 5_000, graceMs: 200 }) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(outcome.signal).toBeNull() + }) + + it('tier 2: an EOF-deaf child dies by SIGTERM', async () => { + const running = spawnSubprocess({ + ...spec('sleep 60'), + stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + }) + await running.dispose({ eofGraceMs: 100, graceMs: 5_000 }) + const outcome = await running.done + expect(outcome.signal).toBe('SIGTERM') + }) + + it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => { + const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60')) + await waitForStdout(running, 'armed\n') + const first = running.dispose({ eofGraceMs: 50, graceMs: 200 }) + const second = running.dispose({ eofGraceMs: 50, graceMs: 200 }) + expect(second).toBe(first) + await first + const outcome = await running.done + expect(outcome.signal).toBe('SIGKILL') + }) +}) + +describe('windows tree semantics (injected platform)', () => { + it('kill and terminate route through taskkill by root pid', async () => { + const killed: number[] = [] + const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killed.push(pid) + // Simulate the forced tree termination taskkill performs. + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone — matches taskkill's tolerated not-found status. + } + }, + }) + running.terminate() + const outcome = await running.done + expect(killed).toContain(running.pid) + expect(outcome.signal).toBe('SIGKILL') + }) + + it('waitForExit falls back to direct-child liveness where groups do not exist', async () => { + const running = spawnSubprocess(spec('true'), { spillDir, platform: 'win32', taskkill: () => {} }) + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + }) +}) + +describe('waitForExit', () => { + it('waits for the whole detached tree, not just the shell', async () => { + const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`) + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) + const grandchild = await waitForPidFile(pidFile) + running.terminate() + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + await expect(waitGone(grandchild, 100)).resolves.toBeUndefined() + }) + + it('an aborted wait reports false while the tree lives', async () => { + const running = spawnSubprocess(spec('sleep 60')) + const controller = new AbortController() + controller.abort() + await expect(running.waitForExit(controller.signal)).resolves.toBe(false) + running.terminate() + await running.done + }) +}) + describe('argv validation', () => { it('rejects an empty argv before spawning', () => { - expect(() => spawnProcess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) + expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) }) it('rejects an empty program name before spawning', () => { - expect(() => spawnProcess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) + expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) }) it('spawns argv verbatim without shell interpretation', async () => { - const result = await spawnProcess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }).done + const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] })) expect(result.stdout.text).toBe('$HOME') }) }) @@ -449,14 +600,14 @@ describe('abort edge cases', () => { addEventListener() {}, removeEventListener() {}, } as unknown as AbortSignal - expect(() => spawnProcess(spec('echo hi', { signal: bare }))) + expect(() => spawnSubprocess(spec('echo hi', { signal: bare }))) .toThrow(/aborted before spawn: aborted/) }) it('reports the terminating signal of an externally self-killed command', async () => { // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. - const result = await spawnProcess(spec('kill -TERM $$')).done + const result = await finish(spawnSubprocess(spec('kill -TERM $$'))) expect(result.signal).toBe('SIGTERM') }) }) @@ -467,7 +618,7 @@ describe('environment and spill-file hardening', () => { process.env.DSH_TEST_TOKEN = 'also-secret' process.env.DSH_TEST_PLAIN = 'visible' try { - const result = await spawnProcess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done + const result = await finish(spawnSubprocess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"'))) expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') } finally { delete process.env.DSH_TEST_API_KEY @@ -479,9 +630,9 @@ describe('environment and spill-file hardening', () => { it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { process.env.DSH_STALE = 'old-value' try { - const result = await spawnProcess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { + const result = await finish(spawnSubprocess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, - })).done + }))) expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') } finally { delete process.env.DSH_STALE @@ -489,21 +640,21 @@ describe('environment and spill-file hardening', () => { }) it('rejects DSH variables on the ordinary env channel', () => { - expect(() => spawnProcess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) + expect(() => spawnSubprocess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) }) it('rejects ordinary variables on the managed env channel', () => { const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment - expect(() => spawnProcess(spec('true', { dshEnv: invalid }))) + expect(() => spawnSubprocess(spec('true', { dshEnv: invalid }))) .toThrow(/managed child env.*PATH.*use env/) }) it('creates spill files with owner-only permissions and random names', async () => { - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, - ).done + )) const path = result.stdout.spillPath! expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) const mode = statSync(path).mode & 0o777 @@ -511,9 +662,9 @@ describe('environment and spill-file hardening', () => { }) it('defaults spills into a private per-process directory', async () => { - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - ).done + )) const dir = dirname(result.stdout.spillPath!) expect(dir).toMatch(/dsh-subprocess-/) const mode = statSync(dir).mode & 0o777 @@ -533,7 +684,7 @@ describe('environment and spill-file hardening', () => { it('honors AbortSignal on background-style runs (no timeout)', async () => { const controller = new AbortController() - const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) + const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort() }, 50) const result = await running.done expect(result.signal).toBe('SIGTERM') diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 890cf74e61..bbcca90133 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -1,14 +1,17 @@ /** - * The subprocess seam (`ctx.subprocess`): spawn fully-specified - * commands into managed process groups with bounded, spill-backed output and - * escalated kills. Command defaulting, shell semantics, deadlines, and - * presentation belong to consumers — the bash executor seam is the owning + * The subprocess seam (`ctx.subprocess`): spawn fully-specified commands into + * managed process trees with Node-shaped stdio dispositions — raw pipes for + * protocol streams, inherit for diagnostics, bounded spill-backed collection + * for batch output — plus tree-scoped signalling and a cooperative dispose + * ladder. Command defaulting, shell semantics, deadlines, framing, and + * presentation belong to consumers; the bash executor seam is the owning * template. The local implementation lives in * `@deepseek-ai/dsh-subprocess-local`. * @module @deepseek-ai/dsh-subprocess */ import { Context, Service } from 'cordis' +import { DSH_ENV_PREFIX } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' @@ -16,13 +19,48 @@ export type { CollectedOutput, DshEnvironment, DshEnvironmentKey, + SubprocessCollect, + SubprocessCollectedOutputs, + SubprocessDisposeGraces, SubprocessHandle, SubprocessOutcome, + SubprocessOutputMode, SubprocessOutputRead, SubprocessOutputReader, SubprocessSpawnSpec, + SubprocessStdinMode, + SubprocessStdio, } from './types.ts' +/** + * Credential-shaped environment names are NOT forwarded to children (the + * harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned + * process implicitly). One heuristic for every in-repo spawner; a + * deliberately supplied entry survives because explicit env layers merge + * after the scrub. + */ +export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +/** + * The ambient parent environment minus credential-shaped names and minus all + * `DSH_*` names — the canonical base every harness child starts from. `PATH`, + * `HOME`, locale, and proxy variables survive, so child CLIs run normally; + * harness identity never leaks implicitly (a child that needs current `DSH_*` + * facts receives them through {@link SubprocessSpawnSpec.dshEnv}, and a + * deliberately forwarded credential goes through an explicit env layer, which + * merges after this scrub). Exported as a plain function so spawners that + * cannot route through the service (node-pty backends, SDK-managed + * transports) share the one scrub definition. + * @returns a fresh environment object safe to hand to a child spawn. + */ +export function scrubbedParentEnv(): Record { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value + } + return env +} + declare module 'cordis' { interface Context { subprocess: SubprocessService @@ -37,13 +75,17 @@ declare module 'cordis' { * * Implementations must honor these semantics: * - {@link spawn} returns immediately with a live handle; `done` resolves at - * process close and rejects only for spawn-level failures. - * - Output 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. - * - {@link SubprocessHandle.kill} and the spec's abort signal escalate - * SIGTERM→grace→SIGKILL across the whole process group. - * - Disposal kills all still-running managed processes and awaits their exit. + * process close with exit facts and rejects only for spawn-level 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. + * - {@link SubprocessHandle.kill} signals without escalation, + * {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates + * SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the + * cooperative EOF-first ladder — all tree-scoped on every platform. + * - Disposal of the service terminates all still-running managed processes + * and awaits their exit. */ export abstract class SubprocessService extends Service { constructor(ctx: Context) { @@ -53,8 +95,8 @@ export abstract class SubprocessService extends Service { /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. - * @param spec - argv, directory, limits, grace, cancellation, and environment. - * @returns the live process handle (readers, kill, outcome promise). + * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment. + * @returns the live process handle (streams/readers, signalling, outcome promise). */ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle } diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index a5ec7f4e55..4c667919c7 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -1,11 +1,14 @@ /** - * Vocabulary for the subprocess seam: fully-specified spawn requests, - * bounded output with spill recovery, and live process handles. Command - * defaulting, shell semantics, and presentation belong to consumers such as - * the bash executor seam. + * Vocabulary for the subprocess seam: fully-specified spawn requests with + * Node-shaped per-stream stdio modes, bounded collected output with spill + * recovery, raw piped streams, and tree-scoped termination. Command + * defaulting, shell semantics, protocol framing, and presentation belong to + * consumers such as the bash executor seam. * @module dsh-subprocess/types */ +import type { Readable, Writable } from 'node:stream' + /** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ export const DSH_ENV_PREFIX = 'DSH_' as const @@ -26,60 +29,97 @@ export interface CollectedOutput { } /** - * A fully-specified spawn request. This seam applies no defaults: every limit - * and directory is explicit, so the caller's own config — not a hidden - * subprocess-service default — decides them (the `dsh-bash` request/spec split - * is the owning template). + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +export type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } + +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +export interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} + +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect + +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +export interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} + +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). */ export interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after process exit. */ + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ graceMs: number /** - * Abort signal — kills the process group 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 process tree when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. */ signal?: AbortSignal | undefined /** - * Bytes to write to the child's stdin, then close it. Absent (or empty) - * leaves stdin closed/empty. - */ - stdin?: string | undefined - /** - * Ordinary environment entries merged after the implementation's credential - * scrub. `DSH_*` names are rejected and belong in {@link dshEnv}. + * Ordinary environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and + * belong in {@link dshEnv}; a deliberately forwarded credential-shaped + * entry survives because this layer merges after the scrub. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Implementations - * discard ambient `DSH_*` entries before merging this snapshot, so an - * unavailable current fact cannot inherit a stale value from the harness - * process, and reject non-`DSH_*` names supplied through this channel. + * Harness-owned `DSH_*` variables for this execution. The scrubbed base has + * already discarded ambient `DSH_*` entries, so an unavailable current fact + * cannot inherit a stale value from the harness process; non-`DSH_*` names + * on this channel are rejected. */ dshEnv?: DshEnvironment | undefined } /** - * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the service kills on abort but does not decide - * why — the caller reads the signal it owns to classify causes. + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. */ export interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput } /** One incremental {@link SubprocessOutputReader.readFrom} read. */ @@ -95,9 +135,11 @@ export interface SubprocessOutputRead { } /** - * Cursor-free incremental access to one live output stream. Offsets are + * Cursor-free incremental access to one collected output stream. Offsets are * whole-stream byte coordinates owned by the caller, so independent readers - * cannot consume one another's output. + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). */ export interface SubprocessOutputReader { /** @@ -110,19 +152,87 @@ export interface SubprocessOutputReader { readFrom(fromByte: number): SubprocessOutputRead } +/** Offset-based readers for the streams spawned in collect mode. */ +export interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} + /** - * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL - * escalation; buffered output remains readable after exit. + * The two grace periods of the cooperative dispose ladder + * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, + * validated Config fields, so teardown timing is deployment-tunable and this + * seam hardcodes nothing. + */ +export interface SubprocessDisposeGraces { + /** + * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce + * ON ITS OWN — flush durable state, tear down its own descendants — before + * escalation to platform termination. Usually WIDER than + * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven + * teardown may itself wait on a signal-trapping grandchild plus a final + * flush. + */ + eofGraceMs: number + /** + * Termination confirmation window (ms): POSIX applies it after `SIGTERM` + * and again after `SIGKILL`; Windows applies it after the forced tree + * termination. + */ + graceMs: number +} + +/** + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. */ export interface SubprocessHandle { - /** Process id (group leader); -1 when the spawn itself failed. */ + /** Process id (tree root); -1 when the spawn itself failed. */ readonly pid: number - /** Live stdout reader (also readable after exit). */ - readonly stdout: SubprocessOutputReader - /** Live stderr reader (also readable after exit). */ - readonly stderr: SubprocessOutputReader - /** Resolves when the process closes; rejects only for spawn-level failures. */ + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void + /** + * Send one signal to the process tree, Node-style — no escalation, no + * timers. A no-op after the outcome has settled (the pid may be reused). + * @param signal - the signal to deliver (default `SIGTERM`; Windows + * force-terminates the tree for any value). + */ + kill(signal?: NodeJS.Signals): void + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately). Idempotent; also triggered by the + * spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise + /** + * Tear the child down to quiescence, resolving only after exit: close stdin + * (when this handle owns a piped one) and allow cooperative flush for + * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced + * tree termination with a final bounded `graceMs` wait. + * @param graces - the ladder's two windows, from the consumer's Config. + * @throws when the child still has not exited `graceMs` after the forced tier. + */ + dispose(graces: SubprocessDisposeGraces): Promise } diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 630aa35f1a..d033e24a31 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { SubprocessService } from '@deepseek-ai/dsh-subprocess' -import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** * Minimal concrete service: a hand-built handle. The seam is spawn-only — @@ -11,18 +11,20 @@ import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from class StubSubprocessService extends SubprocessService { spawn(spec: SubprocessSpawnSpec): SubprocessHandle { const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false } - let killed = false + const collected = spec.stdio.stdout !== 'pipe' && spec.stdio.stdout !== 'inherit' + ? { stdout: { readFrom: () => read } } + : {} return { pid: spec.argv.length, - stdout: { readFrom: () => read }, - stderr: { readFrom: () => read }, - done: Promise.resolve({ - exitCode: killed ? null : 0, - signal: null, - stdout: { text: 'ok', truncated: false }, - stderr: { text: '', truncated: false }, - }), - kill: () => { killed = true }, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected, + done: Promise.resolve({ exitCode: 0, signal: null }), + kill: () => {}, + terminate: () => {}, + waitForExit: () => Promise.resolve(true), + dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(), } } } @@ -34,22 +36,40 @@ describe('SubprocessService seam', () => { const handle = ctx.subprocess.spawn({ argv: ['true'], cwd: '/stub', - stdoutMaxBytes: 1, - stderrMaxBytes: 1, - maxSpillBytes: 1, + stdio: { stdin: 'ignore', stdout: { maxBytes: 1 }, stderr: 'inherit' }, graceMs: 1, }) expect(handle.pid).toBe(1) - expect(handle.stdout.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) + expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) handle.kill() + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined() const outcome = await handle.done - expect(outcome.stdout.text).toBe('ok') + expect(outcome.exitCode).toBe(0) }) - it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { + it('loading a second implementation throws (one subprocess service per context — cordis standard)', async () => { const ctx = new Context() await ctx.plugin(StubSubprocessService) - class SecondManager extends StubSubprocessService {} - await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/) + class SecondService extends StubSubprocessService {} + await expect(ctx.plugin(SecondService)).rejects.toThrow(/service "subprocess" has been registered/) + }) + + it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => { + process.env.DSH_SCRUB_PROBE = 'stale' + process.env.SCRUB_PROBE_TOKEN = 'secret' + process.env.SCRUB_PROBE_PLAIN = 'visible' + try { + const env = scrubbedParentEnv() + expect(env.DSH_SCRUB_PROBE).toBeUndefined() + expect(env.SCRUB_PROBE_TOKEN).toBeUndefined() + expect(env.SCRUB_PROBE_PLAIN).toBe('visible') + expect(env.PATH).toBeDefined() + } finally { + delete process.env.DSH_SCRUB_PROBE + delete process.env.SCRUB_PROBE_TOKEN + delete process.env.SCRUB_PROBE_PLAIN + } }) }) From e678db440e3818cfbb760db7e73aded5c4f7d6c2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:10:46 +0800 Subject: [PATCH 22/75] fix(subprocess): finish the rename in current-state docs and graph inputs Codex re-review (A): AGENTS.md layout tree, the architecture capability tables (en+zh), the seam Agent Note's group-name sentence (en+zh), and the SERVICE_ROLES entry still carried process/-era labels; regenerated the doc graphs and config catalog from the corrected inputs. --- .../2026-07-26-subprocess-seam.i18n.yaml | 4 ++-- .../architecture/2026-07-26-subprocess-seam.md | 2 +- .../architecture/2026-07-26-subprocess-seam.zh.md | 2 +- AGENTS.md | 2 +- docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 12 ++++++------ scripts/gen-doc-graphs.ts | 6 +++--- 9 files changed, 18 insertions(+), 18 deletions(-) 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 a0ea989893..4d639a40f2 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 -2026-07-26-subprocess-seam.md: cd7b18c209af0830e339abfe01aa91dc493e15da -2026-07-26-subprocess-seam.zh.md: 685d9797a7456b4edf95853b253dc97f3426c902 +2026-07-26-subprocess-seam.md: abfc43c1a960edd498b0960a191153008e39834c +2026-07-26-subprocess-seam.zh.md: 7fba975181b7871b34a8bc0e77d826e1ee2647f5 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 cd7b18c209..abfc43c1a9 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -10,7 +10,7 @@ English | [中文](2026-07-26-subprocess-seam.zh.md) ## Decision -A new `process/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: +A new `subprocess/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: - **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream caps, spill cap, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. - **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` merge, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel. 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 685d9797a7..7fba975181 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 @@ -10,7 +10,7 @@ Status: implemented ## 决策 -新的 `process/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: +新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: - **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 - **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 diff --git a/AGENTS.md b/AGENTS.md index abcd3e8709..82684b9e65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// prompt/ workspace instructions llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools - process/ child-process manager seam + local process-group impl + subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools lsp/ language-server seam + local stdio provider + model-facing lsp tool diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 50abfe37ff..5c9c706d6c 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 -architecture.md: a1e169a9ef7717c6889f5a1aff53af984de565f4 -architecture.zh.md: 025d3b7343b36dca91cca9ebe6323628cb0702f9 +architecture.md: 9c4c9fc12a51c9c49d02a7aa9c3633ae7c95e4fe +architecture.zh.md: bc6adef969f60e7a7522a1877d29b2f90fc0c322 diff --git a/docs/architecture.md b/docs/architecture.md index a1e169a9ef..9c4c9fc12a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | -| `ctx.subprocess` | [`process/`](../packages/subprocess/README.md) | managed child-process groups under the bash executors | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process groups under the bash executors | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 025d3b7343..bc6adef969 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,7 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | -| `ctx.subprocess` | [`process/`](../packages/subprocess/README.md) | bash 执行器之下受管理的子进程组 | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | bash 执行器之下受管理的子进程组 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 8e0169458f..5dc0a41a75 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -82,9 +82,9 @@ flowchart LR pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] - pkg_process["process"] - svc_subprocess["ctx.subprocess
Process manager seam"] - pkg_process_local["process-local"] + pkg_subprocess["subprocess"] + svc_subprocess["ctx.subprocess
Subprocess seam"] + pkg_subprocess_local["subprocess-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_bash["bash"] @@ -167,8 +167,6 @@ flowchart LR pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode - pkg_process --> svc_subprocess - pkg_process_local --> svc_subprocess pkg_pty --> svc_pty pkg_pty_local --> svc_pty pkg_sandbox --> svc_sandbox @@ -196,6 +194,8 @@ flowchart LR pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents pkg_subagent_spawn --> svc_subagents + pkg_subprocess --> svc_subprocess + pkg_subprocess_local --> svc_subprocess pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks pkg_tasks_local --> svc_tasks @@ -324,7 +324,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `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.subprocess` | `seam` | `process` | `process-local` | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 6924cc2761..526e2ea5cd 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -267,10 +267,10 @@ const SERVICE_ROLES: ServiceRole[] = [ }, { key: 'subprocess', - pkg: 'process', - title: 'Process manager seam', + pkg: 'subprocess', + title: 'Subprocess seam', mode: 'seam', - implementations: ['process-local'], + implementations: ['subprocess-local'], consumers: ['bash-local', 'bash-sandbox'], note: 'The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation.', }, From 3672cd25b4a2234f668675bf97463983a18a0995 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:27:59 +0800 Subject: [PATCH 23/75] feat(subprocess): migrate lsp-local, subagent-acp, and the env scrubs onto the seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review direction (tianyicui, PR #660): in a stacked PR, change all other process-running places to use the new service. - lsp-local: LspConnection spawns through ctx.subprocess (piped protocol streams + a no-spill collected stderr tail); its private process-tree helpers (POSIX group signalling, Windows taskkill, liveness polling) are deleted in favor of the seam's handle verbs, and its buildChildEnv now rides scrubbedParentEnv (LSP children also stop inheriting stale DSH_*). The plugin injects 'subprocess'; compositions/tests mount dsh-subprocess-local. - subagent-acp: the ACP child spawns through the seam (piped ndjson streams, inherited stderr); spawn failure surfaces through done-rejection into the same startup race; disposal is handle.dispose with the plugin's configured graces. dsh-subagent-subprocess is DELETED — its dispose ladder and scrub are the seam's, and the isolated-config-dir helper had no consumer. - mcp-client, pty-local, sdk-helper: adopt scrubbedParentEnv as the one scrub definition (their spawns stay put by ownership: the MCP SDK and node-pty own those calls; the SDK wizard runs outside any composition). - Coverage: per-file 100% over every touched src file, with each v8 ignore carrying a platform or contract reason; new suites cover stdio dispositions, the dispose ladder tiers, injected-win32 tree semantics, waitForExit, settled-kill/terminate no-ops, and spawn-failure disposal. - Docs: consumer-migration Agent Note (en; zh follows in this PR), seam note updated in place, subprocess.md rewritten for the reshaped vocabulary (type-equiv re-registered), READMEs and SERVICE_ROLES updated, taskkill added to knip ignoreBinaries. --- ...026-07-26-subprocess-consumer-migration.md | 38 ++ .../2026-07-26-subprocess-seam.md | 4 +- docs/capability-seams.md | 7 +- docs/config-catalog.md | 7 +- docs/cordis-catalog/services.md | 14 +- docs/core-data-structures/subprocess.md | 225 +++++++--- docs/module-graph.md | 19 +- .../fixtures/subagent/subagent-acp/cordis.yml | 4 + knip.json | 32 +- packages/bash/bash-local/src/index.ts | 2 + .../cordis/tool-cordis/src/api-catalog.ts | 32 +- packages/lsp/lsp-local/package.json | 3 + packages/lsp/lsp-local/src/connection.ts | 246 +++-------- packages/lsp/lsp-local/src/index.ts | 20 +- packages/lsp/lsp-local/src/instance.ts | 7 +- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 5 +- .../lsp/lsp-local/tests/connection.spec.ts | 91 +--- packages/lsp/lsp-local/tests/instance.spec.ts | 7 +- .../lsp/lsp-local/tests/lifecycle.spec.ts | 4 + packages/lsp/lsp-local/tests/provider.spec.ts | 13 + .../lsp-local/tests/typescript-server.e2e.ts | 2 + packages/lsp/lsp-local/tsconfig.json | 3 + .../lsp/tool-lsp/tests/integration.spec.ts | 2 + packages/mcp/mcp-client/package.json | 2 + packages/mcp/mcp-client/src/transport.ts | 17 +- packages/mcp/mcp-client/tsconfig.json | 3 + packages/pty/pty-local/package.json | 2 + packages/pty/pty-local/src/index.ts | 9 +- packages/pty/pty-local/tsconfig.json | 3 + packages/sdk/helper/package.json | 2 + .../src/package-managers/package-manager.ts | 11 +- packages/sdk/helper/tsconfig.json | 3 + packages/subagent/README.md | 3 +- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/package.json | 5 +- packages/subagent/subagent-acp/src/index.ts | 3 +- packages/subagent/subagent-acp/src/run.ts | 54 ++- .../subagent-acp/tests/subagent-acp.e2e.ts | 2 +- .../subagent-acp/tests/subagent-acp.spec.ts | 53 ++- packages/subagent/subagent-acp/tsconfig.json | 2 +- .../subagent/subagent-subprocess/README.md | 53 --- .../subagent/subagent-subprocess/package.json | 37 -- .../subagent/subagent-subprocess/src/index.ts | 223 ---------- .../subagent-subprocess/src/invariant.ts | 30 -- .../tests/subagent-subprocess.spec.ts | 389 ------------------ .../subagent-subprocess/tsconfig.json | 15 - packages/subprocess/README.md | 6 +- .../subprocess/subprocess-local/README.md | 10 +- .../subprocess/subprocess-local/src/spawn.ts | 18 +- .../subprocess-local/tests/spawn.spec.ts | 143 ++++++- packages/subprocess/subprocess/README.md | 17 +- pnpm-lock.yaml | 34 +- python/sdk-runtime/package.json | 1 - scripts/gen-doc-graphs.ts | 4 +- scripts/type-equiv.manifest.json | 30 ++ .../verify-package-readme-model-experience.ts | 1 - tsconfig.host.json | 1 - 57 files changed, 750 insertions(+), 1225 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md delete mode 100644 packages/subagent/subagent-subprocess/README.md delete mode 100644 packages/subagent/subagent-subprocess/package.json delete mode 100644 packages/subagent/subagent-subprocess/src/index.ts delete mode 100644 packages/subagent/subagent-subprocess/src/invariant.ts delete mode 100644 packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts delete mode 100644 packages/subagent/subagent-subprocess/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md new file mode 100644 index 0000000000..805f27ba2e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -0,0 +1,38 @@ +# Agent Note: The subprocess seam goes Node-shaped and every eligible spawner rides it + +Status: implemented + +English | [中文](2026-07-26-subprocess-consumer-migration.zh.md) + +## Problem + +The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, mcp-client and pty-local and the SDK helper each had a third/fourth/fifth copy of the credential scrub — and none of it was swappable or centrally testable. + +## Decision + +The seam's vocabulary is now Node-shaped, and every spawner that can ride the service does: + +- **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch). +- **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome. +- **Tree-scoped termination, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. +- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well. + +Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is `handle.dispose` with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). + +Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` (the plugins inject `'subprocess'`); the acp/lsp test fixtures gained the row. + +## Alternatives considered + +**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and five of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams. + +**A single `stdio: 'pipe' | 'inherit' | 'collect'` mode for all three streams at once.** Rejected: real consumers mix modes per stream (lsp: pipe/pipe/collect; acp: pipe/pipe/inherit; bash: data/collect/collect). Per-stream dispositions are exactly Node's shape and avoid a second spawn call for the mixed cases. + +**Migrate pty-local and mcp-client spawns too.** Rejected on ownership grounds, not scope: node-pty's `fork()` allocates the terminal itself, and the MCP SDK's `StdioClientTransport` spawns internally — neither call site is ours to route. They adopt the shared scrub (the part that is policy), and their READMEs say why the spawn stays put. + +**Migrate the test-support launchers (acp-snapshot, loader-smoke) and the SDK package-manager runner.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams, and the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; it shares the scrub instead. + +## Consequences + +Bought: one implementation of tree signalling, escalation, the dispose ladder, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. + +Cost: the seam is wider — three stdio modes and four termination verbs instead of one of each — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. 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 abfc43c1a9..5cf0e59660 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -12,7 +12,7 @@ English | [中文](2026-07-26-subprocess-seam.zh.md) A new `subprocess/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: -- **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream caps, spill cap, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. +- **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream stdio dispositions, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the shared scrub plus `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. (The [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) later widened the stdio and termination vocabulary Node-ward.) - **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` merge, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel. - **`dsh-bash-local` (consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. - **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. @@ -25,7 +25,7 @@ Background-process lifetime moved from the executor to the subprocess service: t **Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) landed: the boundary is stable and already documented in-code (`run.ts`'s module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split. -**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.subprocess` in the same change.** Rejected as scope creep with real design risk: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README. +**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.subprocess` in the same change.** Rejected as scope creep with real design risk at this PR's scale: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam shipped proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule. Review then asked for exactly that follow-up as a stacked PR; the [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) records the Node-ward reshape and which sites moved (and which stayed, by ownership). **Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 5dc0a41a75..f1e473767d 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -87,6 +87,8 @@ flowchart LR pkg_subprocess_local["subprocess-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_lsp_local["lsp-local"] + pkg_subagent_acp["subagent-acp"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] @@ -116,7 +118,6 @@ flowchart LR svc_subagents["ctx.subagents
Subagent provider registry"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] - pkg_subagent_acp["subagent-acp"] pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] @@ -270,6 +271,8 @@ flowchart LR svc_subagents --> pkg_tool_subagent svc_subprocess --> pkg_bash_local svc_subprocess --> pkg_bash_sandbox + svc_subprocess --> pkg_lsp_local + svc_subprocess --> pkg_subagent_acp svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -324,7 +327,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `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.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a21b11378d..4f90714ebf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -716,7 +716,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src ## `@deepseek-ai/dsh-lsp-local` -Requires: `lsp` +Requires: `lsp` · `subprocess` ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -752,7 +752,7 @@ export interface LspLocalServerConfig { } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:87`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` @@ -1286,7 +1286,7 @@ Source: [`packages/storage/storage-sqlite/src/index.ts:24`](../packages/storage/ ## `@deepseek-ai/dsh-subagent-acp` -Requires: `subagents` +Requires: `subagents` · `subprocess` ```ts config-catalog /** Config: how to spawn and drive the child ACP agent process. */ @@ -2108,6 +2108,5 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) -- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4108332e43..0f7f83a331 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1565,24 +1565,24 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: -- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. -- Output 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. -- SubprocessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group. -- Disposal kills all still-running managed processes and awaits their exit. +- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level 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.kill signals without escalation, SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and SubprocessHandle.dispose runs the cooperative EOF-first ladder — all tree-scoped on every platform. +- Disposal of the service terminates all still-running managed processes and awaits their exit. ```ts cordis-catalog /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. - * @param spec - argv, directory, limits, grace, cancellation, and environment. - * @returns the live process handle (readers, kill, outcome promise). + * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment. + * @returns the live process handle (streams/readers, signalling, outcome promise). */ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle ``` Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:48`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:90`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index 6e7cea3990..ae0b291b88 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -1,12 +1,12 @@ # Subprocess -The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. +The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams and out-of-process backends — the [bash executor family](bash.md) (collect-mode batch output), the LSP host (piped protocol streams + a collected stderr tail), and the ACP subagent backend (piped protocol streams + inherited stderr). This seam owns the managed `DSH_*` environment namespace, the shared credential scrub (`scrubbedParentEnv`), and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports the vocabulary so bash consumers keep one import root. Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) ## Managed environment namespace and captured output -`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each captured stream reports its truncation and spill-recovery state through `CollectedOutput`. +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -30,83 +30,170 @@ interface CollectedOutput { } ``` -## The fully-explicit spawn spec +## Node-shaped stdio dispositions -The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted. +Each stream's disposition is explicit, chosen per consumer: raw pipes for protocol framing (LSP JSON-RPC, ACP ndjson), inherit for pass-through diagnostics, and collect mode for bounded batch output — with the spill file optional, so a diagnostic tail (a language server's stderr) buffers without leaving files behind. ```ts type-equiv /** - * A fully-specified spawn request. This seam applies no defaults: every limit - * and directory is explicit, so the caller's own config — not a hidden - * subprocess-service default — decides them (the `dsh-bash` request/spec split - * is the owning template). + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } +``` + +```ts type-equiv +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} +``` + +```ts type-equiv +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect +``` + +```ts type-equiv +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} +``` + +## The fully-explicit spawn spec + +The seam applies no defaults: every disposition, limit, and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted. + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). */ interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after process exit. */ + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ graceMs: number /** - * Abort signal — kills the process group 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 process tree when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. */ signal?: AbortSignal | undefined /** - * Bytes to write to the child's stdin, then close it. Absent (or empty) - * leaves stdin closed/empty. - */ - stdin?: string | undefined - /** - * Ordinary environment entries merged after the implementation's credential - * scrub. `DSH_*` names are rejected and belong in {@link dshEnv}. + * Ordinary environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and + * belong in {@link dshEnv}; a deliberately forwarded credential-shaped + * entry survives because this layer merges after the scrub. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Implementations - * discard ambient `DSH_*` entries before merging this snapshot, so an - * unavailable current fact cannot inherit a stale value from the harness - * process, and reject non-`DSH_*` names supplied through this channel. + * Harness-owned `DSH_*` variables for this execution. The scrubbed base has + * already discarded ambient `DSH_*` entries, so an unavailable current fact + * cannot inherit a stale value from the harness process; non-`DSH_*` names + * on this channel are rejected. */ dshEnv?: DshEnvironment | undefined } ``` -## Handles and offset-based reads +## Handles: streams, readers, and tree-scoped termination -A spawn returns a live handle immediately. Output readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; the consuming-cursor model the bash tool presents is consumer-owned state over these readers. +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. ```ts type-equiv /** - * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL - * escalation; buffered output remains readable after exit. + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. */ interface SubprocessHandle { - /** Process id (group leader); -1 when the spawn itself failed. */ + /** Process id (tree root); -1 when the spawn itself failed. */ readonly pid: number - /** Live stdout reader (also readable after exit). */ - readonly stdout: SubprocessOutputReader - /** Live stderr reader (also readable after exit). */ - readonly stderr: SubprocessOutputReader - /** Resolves when the process closes; rejects only for spawn-level failures. */ + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void + /** + * Send one signal to the process tree, Node-style — no escalation, no + * timers. A no-op after the outcome has settled (the pid may be reused). + * @param signal - the signal to deliver (default `SIGTERM`; Windows + * force-terminates the tree for any value). + */ + kill(signal?: NodeJS.Signals): void + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately). Idempotent; also triggered by the + * spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise + /** + * Tear the child down to quiescence, resolving only after exit: close stdin + * (when this handle owns a piped one) and allow cooperative flush for + * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced + * tree termination with a final bounded `graceMs` wait. + * @param graces - the ladder's two windows, from the consumer's Config. + * @throws when the child still has not exited `graceMs` after the forced tier. + */ + dispose(graces: SubprocessDisposeGraces): Promise } ``` ```ts type-equiv /** - * Cursor-free incremental access to one live output stream. Offsets are + * Cursor-free incremental access to one collected output stream. Offsets are * whole-stream byte coordinates owned by the caller, so independent readers - * cannot consume one another's output. + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). */ interface SubprocessOutputReader { /** @@ -134,26 +221,62 @@ interface SubprocessOutputRead { } ``` -## Outcomes carry no cause classification - -`done` reports raw exit facts. The service kills on abort but never decides why — the caller reads the deadline signal it owns to classify timeout versus cancellation (the bash executor's `timedOut`/`aborted` split). +```ts type-equiv +/** Offset-based readers for the streams spawned in collect mode. */ +interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} +``` ```ts type-equiv /** - * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the service kills on abort but does not decide - * why — the caller reads the signal it owns to classify causes. + * The two grace periods of the cooperative dispose ladder + * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, + * validated Config fields, so teardown timing is deployment-tunable and this + * seam hardcodes nothing. + */ +interface SubprocessDisposeGraces { + /** + * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce + * ON ITS OWN — flush durable state, tear down its own descendants — before + * escalation to platform termination. Usually WIDER than + * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven + * teardown may itself wait on a signal-trapping grandchild plus a final + * flush. + */ + eofGraceMs: number + /** + * Termination confirmation window (ms): POSIX applies it after `SIGTERM` + * and again after `SIGKILL`; Windows applies it after the forced tree + * termination. + */ + graceMs: number +} +``` + +## Outcomes carry exit facts only + +`done` reports Node's close-event vocabulary and no cause classification — the service kills on abort but never decides why (the caller reads the deadline signal it owns, e.g. the bash executor's `timedOut`/`aborted` split). Collected output stays readable through `handle.collected` after settlement, so batch and streaming callers share one access path. + +```ts type-equiv +/** + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. */ interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput } ``` ## Service behavior -The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics. +The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached trees, per-disposition wiring, credential scrub, terminate-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics. diff --git a/docs/module-graph.md b/docs/module-graph.md index f310cac386..435411beb0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -64,7 +64,6 @@ flowchart TD pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] pkg_subagent_spawn["subagent-spawn"] - pkg_subagent_subprocess["subagent-subprocess"] pkg_tool_subagent["tool-subagent"] end subgraph group_web["packages/web"] @@ -229,7 +228,6 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_skill --> pkg_invariants - pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants @@ -273,6 +271,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants + pkg_helper --> pkg_subprocess pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths @@ -359,6 +358,7 @@ flowchart TD pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_subprocess pkg_lsp_local --> pkg_timeout pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm @@ -517,6 +517,7 @@ flowchart TD pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session + pkg_pty_local --> pkg_subprocess pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks @@ -654,6 +655,7 @@ flowchart TD pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm + pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_tools pkg_tool_pty --> pkg_agent pkg_tool_pty --> pkg_invariants @@ -680,7 +682,7 @@ flowchart TD pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent - pkg_subagent_acp --> pkg_subagent_subprocess + pkg_subagent_acp --> pkg_subprocess pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -819,7 +821,6 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | -| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | @@ -846,7 +847,7 @@ flowchart TD | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -875,7 +876,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -911,7 +912,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | -| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) | +| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -934,11 +935,11 @@ flowchart TD | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml index 3bd5f5393c..c69cf574f7 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml @@ -10,6 +10,10 @@ - id: subagent name: '@deepseek-ai/dsh-subagent' +# The out-of-process ACP backend spawns its child through the subprocess seam. +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: subagent-acp name: '@deepseek-ai/dsh-subagent-acp' config: diff --git a/knip.json b/knip.json index 110abb3a2b..09b9544a36 100644 --- a/knip.json +++ b/knip.json @@ -6,7 +6,8 @@ "ignoreBinaries": [ "bwrap", "python3", - "sandbox-exec" + "sandbox-exec", + "taskkill" ], "ignoreWorkspaces": [ "vendor/*", @@ -262,8 +263,14 @@ ] }, "packages/session-query/session-query-sqlite": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/code-runtime/code-runtime-worker": { "entry": [ @@ -316,8 +323,14 @@ ] }, "packages/session-persistence/session-checkpoint-policy": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/util/paths": { "entry": [ @@ -488,15 +501,6 @@ "tests/**/*.ts" ] }, - "packages/subagent/subagent-subprocess": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 9d9ed676e2..f93c17e2ed 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -160,9 +160,11 @@ export class LocalBashExecutor extends BashExecutor { /** The collect-mode readers the executor itself requested (present by construction). */ private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { const { stdout, stderr } = handle.collected + /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ if (stdout === undefined || stderr === undefined) { throw new Error('bash-local: subprocess implementation dropped a requested collect stream') } + /* v8 ignore stop */ return { stdout, stderr } } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ec0d279bab..7d7ce8e5af 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -750,7 +750,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle', - jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */', + jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.\n * @returns the live process handle (streams/readers, signalling, outcome promise).\n */', }, ], }, @@ -2210,13 +2210,29 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentStopReasonMap', declaration: 'export interface SubagentStopReasonMap {\n completed: \'completed\';\n aborted: \'aborted\';\n error: \'error\';\n \'max-tokens\': \'max-tokens\';\n refusal: \'refusal\';\n}', }, + { + name: 'SubprocessCollect', + declaration: 'export interface SubprocessCollect {\n maxBytes: number;\n spill?: {\n maxBytes: number;\n };\n}', + }, + { + name: 'SubprocessCollectedOutputs', + declaration: 'export interface SubprocessCollectedOutputs {\n readonly stdout?: SubprocessOutputReader;\n readonly stderr?: SubprocessOutputReader;\n}', + }, + { + name: 'SubprocessDisposeGraces', + declaration: 'export interface SubprocessDisposeGraces {\n eofGraceMs: number;\n graceMs: number;\n}', + }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdout: SubprocessOutputReader;\n readonly stderr: SubprocessOutputReader;\n readonly done: Promise;\n kill(): void;\n}', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', }, { name: 'SubprocessOutcome', - declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}', + declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n}', + }, + { + name: 'SubprocessOutputMode', + declaration: 'export type SubprocessOutputMode = \'pipe\' | \'inherit\' | SubprocessCollect;', }, { name: 'SubprocessOutputRead', @@ -2228,7 +2244,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessSpawnSpec', - declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + }, + { + name: 'SubprocessStdinMode', + declaration: 'export type SubprocessStdinMode = \'ignore\' | \'pipe\' | {\n readonly data: string;\n};', + }, + { + name: 'SubprocessStdio', + declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n}', }, { name: 'SurfaceEvent', diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index 6815b1c917..1d6499d9f8 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-lsp": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -42,6 +43,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7", "typescript": "^6.0.3", diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 1103c4dbd2..cbad79ea0d 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -1,16 +1,17 @@ /** - * A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound - * requests/notifications, and inbound 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 the - * child handle so the instance owns process-signal teardown. + * A JSON-RPC endpoint over one language server spawned through the subprocess + * seam. Owns id correlation, outbound requests/notifications, and inbound + * 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 tree-scoped termination through the handle so the + * instance owns teardown; group/tree mechanics live in the seam's + * implementation. * @module @deepseek-ai/dsh-lsp-local/connection */ -import type { ChildProcessByStdio } from 'node:child_process' -import { spawn, spawnSync } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import type { Writable } from 'node:stream' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' /** How to launch the server and answer its config requests. */ @@ -27,6 +28,12 @@ export interface ConnectionSpec { readonly maxMessageBytes: number /** Largest stderr tail retained for diagnostics. */ readonly maxStderrBytes: number + /** + * Bound (ms) for draining pipes a surviving helper still holds after the + * server exits; the instance passes its kill grace so exit observation is + * never slower than the escalation it feeds. + */ + readonly pipeDrainGraceMs: number /** Static answer to every `workspace/configuration` item. */ readonly configuration: unknown } @@ -48,178 +55,89 @@ export type ConnectionWriter = ( done: (error?: Error | null) => void, ) => void -/** Host operations used to signal a detached process tree. */ -export interface ProcessTreeOperations { - /** Signal a POSIX process group. */ - readonly signal: (target: number, signal: NodeJS.Signals) => void - /** Signal the direct child when POSIX group signaling is unavailable. */ - readonly killChild: (signal: NodeJS.Signals) => void - /** Terminate a Windows process tree by root pid. */ - readonly taskkill: (pid: number) => void -} - -/** Narrow taskkill runner result used by the Windows process-tree adapter. */ -export interface TaskkillResult { - /** Process exit status, or null when spawning failed. */ - readonly status: number | null - /** Spawn failure, when the executable could not run. */ - readonly error?: Error -} - -/** Invoke a command synchronously for the Windows taskkill adapter. */ -export type TaskkillRunner = ( - command: string, - args: string[], - options: { stdio: 'ignore' }, -) => TaskkillResult - -/** Invoke the host process-signal primitive for a POSIX process group. */ -export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean - -const processSignalRunner: ProcessSignalRunner = process.kill.bind(process) - -/** taskkill status for "process not found": the requested process tree is already absent. */ -const TASKKILL_TREE_NOT_FOUND_STATUS = 128 +/** Spawn one subprocess for this connection (the provider passes `ctx.subprocess.spawn`). */ +export type ConnectionSpawner = (spec: SubprocessSpawnSpec) => SubprocessHandle const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => { stdin.write(encodeMessage(message), done) } -/** - * Terminate one Windows process tree and wait for taskkill to finish. - * @param pid - root process id. - * @param run - command runner; tests inject results without requiring Windows. - */ -export function taskkillProcessTree( - pid: number, - run: TaskkillRunner = spawnSync, -): void { - const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) - if (result.error !== undefined) throw result.error - if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return - if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`) -} - -/** - * Signal one POSIX process group through an injectable host primitive. - * @param target - negative process-group id. - * @param signal - requested signal. - * @param run - host signal runner; tests inject it without touching real processes. - */ -export function signalProcessGroup( - target: number, - signal: NodeJS.Signals, - run: ProcessSignalRunner = processSignalRunner, -): void { - run(target, signal) -} - -/** - * Wait until a process-tree liveness probe reports exit. - * @param isAlive - process-tree liveness probe. - * @param signal - optional bound for the wait. - * @param yieldNow - event-loop yield primitive. - * @returns `true` when the tree exited, or `false` when the signal aborted first. - */ -export async function waitForTreeExit( - isAlive: () => boolean, - signal?: AbortSignal, - yieldNow: () => Promise = yieldToEventLoop, -): Promise { - while (isAlive()) { - if (signal?.aborted) return false - await yieldNow() - } - return true -} - -/** - * Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct - * child; Windows requires taskkill to reach the full tree. - * @param platform - host platform. - * @param pid - detached root process id. - * @param signal - requested termination signal. - * @param operations - host operations. - */ -export function signalProcessTree( - platform: NodeJS.Platform, - pid: number, - signal: NodeJS.Signals, - operations: ProcessTreeOperations, -): void { - if (platform === 'win32') { - operations.taskkill(pid) - return - } - try { - operations.signal(-pid, signal) - } catch { - try { - operations.killChild(signal) - } catch { - // The direct child already exited; teardown remains idempotent. - } - } -} - /** A live JSON-RPC endpoint bound to one child process. */ export class LspConnection { - private readonly child: ChildProcessByStdio + private readonly handle: SubprocessHandle + private readonly stdin: Writable private readonly decoder: MessageDecoder private readonly pending = new Map() private nextId = 1 - private stderr = Buffer.alloc(0) private closeReason: Error | undefined /** Set once the process has fully exited; the instance awaits it during teardown. */ readonly closed: Promise /** * @param spec - how to launch the server and answer its config requests. + * @param spawner - the subprocess seam's spawn (the provider passes `ctx.subprocess.spawn`). * @param onServerRequest - answers a server→client request; rejects to send an error response. * @param writer - message writer; tests inject callback failures without relying on OS pipe races. */ constructor( - private readonly spec: ConnectionSpec, + spec: ConnectionSpec, + spawner: ConnectionSpawner, private readonly onServerRequest: (method: string, params: unknown) => Promise, private readonly writer: ConnectionWriter = writeConnectionMessage, ) { this.decoder = new MessageDecoder(spec.maxMessageBytes) - // `detached` gives teardown a process-tree root: POSIX signals its negative process-group id, - // while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it. - this.child = spawn(spec.command, [...spec.args], { + // 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 detachment and tree-scoped signalling. + this.handle = spawner({ + argv: [spec.command, ...spec.args], cwd: spec.cwd, + stdio: { + stdin: 'pipe', + stdout: 'pipe', + stderr: { maxBytes: spec.maxStderrBytes }, + }, + graceMs: spec.pipeDrainGraceMs, env: spec.env, - stdio: ['pipe', 'pipe', 'pipe'], - detached: true, }) + /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ + if (this.handle.stdin === undefined || this.handle.stdout === undefined) { + throw new Error('lsp-local: subprocess implementation dropped a piped protocol stream') + } + /* v8 ignore stop */ + this.stdin = this.handle.stdin this.closed = new Promise((resolve) => { - this.child.on('close', () => { + const close = (): void => { const reason = this.closeReason ?? new Error(this.exitMessage()) // Record the reason so any request issued AFTER close rejects immediately instead of hanging // (a closed process sends no further responses). this.closeReason = reason this.failAll(reason) resolve() + } + this.handle.done.then(close, (error: unknown) => { + // A spawn-level failure never produces a close event; the rejection is + // the fatal cause and the close boundary at once. + this.fail(asError(error)) + close() }) }) - this.child.on('error', (error) => { this.fail(error) }) // Child stdin can fail while the process itself remains alive (for example, a server closes fd // 0). Treat that as a fatal connection error so pending requests reject immediately instead of // waiting for a process-close event that may never arrive. - this.child.stdin.on('error', (error) => { this.fail(error) }) - this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) - this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) }) + this.stdin.on('error', (error) => { this.fail(error) }) + this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) } /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */ get pid(): number { - /* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */ - return this.child.pid ?? -1 + return this.handle.pid } /** The retained stderr tail, for diagnostics on a failed server. */ get stderrTail(): string { - return this.stderr.toString('utf8') + /* v8 ignore next -- the collect disposition always exposes a stderr reader; defensive. */ + return this.handle.collected.stderr?.readFrom(0).text ?? '' } /** Whether the transport has failed even if the child close event has not arrived yet. */ @@ -289,14 +207,14 @@ export class LspConnection { return this.nextId } - /** Request termination of the server's process tree. */ + /** Request termination of the server's process tree (SIGTERM, no escalation). */ terminate(): void { - this.signalTree('SIGTERM') + this.handle.kill('SIGTERM') } /** Force termination of the server's process tree. */ kill(): void { - this.signalTree('SIGKILL') + this.handle.kill('SIGKILL') } /** @@ -305,39 +223,7 @@ export class LspConnection { * @returns `true` when the tree exited, or `false` when the signal aborted first. */ async waitForProcessTreeExit(signal?: AbortSignal): Promise { - return await waitForTreeExit(this.processTreeAlive.bind(this), signal) - } - - /** Signal the whole process tree. */ - private signalTree(sig: NodeJS.Signals): void { - const pid = this.child.pid - if (pid === undefined) return - signalProcessTree(process.platform, pid, sig, { - signal: signalProcessGroup, - killChild: this.child.kill.bind(this.child), - taskkill: taskkillProcessTree, - }) - } - - /** Whether the detached tree's root or POSIX process group is still alive. */ - private processTreeAlive(): boolean { - const pid = this.child.pid - /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */ - if (pid === undefined) return false - try { - process.kill(-pid, 0) - return true - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - /* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes - whether lifecycle tests observe this branch platform-dependent. */ - if (code === 'ESRCH') return false - /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs - process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */ - if (code === 'EPERM') return true - return this.child.exitCode === null && this.child.signalCode === null - /* v8 ignore stop */ - } + return await this.handle.waitForExit(signal) } private onStdout(chunk: Buffer): void { @@ -348,28 +234,12 @@ export class LspConnection { // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and // SIGKILL the whole group so helper processes don't outlive the leader. this.fail(asError(error)) - this.signalTree('SIGKILL') + this.handle.kill('SIGKILL') return } for (const message of messages) this.dispatch(message) } - private onStderr(chunk: Buffer): void { - // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just - // before it exits, so the final bounded segment is the useful one. - const cap = this.spec.maxStderrBytes - if (chunk.length >= cap) { - // Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer. - this.stderr = Buffer.from(chunk.subarray(chunk.length - cap)) - return - } - const retainedBytes = Math.min(this.stderr.length, cap - chunk.length) - this.stderr = Buffer.concat([ - this.stderr.subarray(this.stderr.length - retainedBytes), - chunk, - ], retainedBytes + chunk.length) - } - private dispatch(message: unknown): void { if (message === null || typeof message !== 'object') return const frame = message as Record @@ -423,7 +293,7 @@ export class LspConnection { reject(error) } try { - this.writer(this.child.stdin, message, done) + this.writer(this.stdin, message, done) /* v8 ignore start -- Node stream write failures are callback-delivered; this guards a nonconforming Writable implementation throwing synchronously. */ } catch (error) { diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index dda3558130..b9699706aa 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -25,6 +25,8 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { abortable, abortError } from './abort.ts' import { canonicalizeWorkspace, readHostSource } from './host.ts' import { LspInstance } from './instance.ts' +import type { ConnectionSpawner } from './connection.ts' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { InstanceSpec } from './instance.ts' export { canonicalizeWorkspace, readHostSource } from './host.ts' @@ -44,10 +46,10 @@ export { LspConnection } from './connection.ts' export const name = 'lsp-local' /** Services required by this plugin. */ -export const inject = ['lsp'] +export const inject = ['lsp', 'subprocess'] /** Credential-shaped ambient env vars are NOT forwarded to the child by default. */ -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000 const DEFAULT_MAX_STDERR_BYTES = 1_000_000 @@ -127,7 +129,7 @@ export function apply(ctx: Context, config: Config): void { validateServerConfig(providerId, resolved) const childEnv = buildChildEnv(resolved.env) const executable = resolveExecutable(resolved.command, childEnv) - return new LocalLspProvider(providerId, resolved, childEnv, executable) + return new LocalLspProvider(providerId, resolved, childEnv, executable, spec => ctx.subprocess.spawn(spec)) }) ctx.effect(() => { @@ -189,6 +191,7 @@ class LocalLspProvider implements LspProvider { private readonly config: ResolvedServerConfig, private readonly childEnv: Record, private readonly executable: string, + private readonly spawner: ConnectionSpawner, ) { this.id = LspProviderId(providerId) this.extensionToLanguage = config.extensionToLanguage @@ -282,10 +285,12 @@ class LocalLspProvider implements LspProvider { initializationOptions: this.config.initializationOptions, maxMessageBytes: this.config.maxMessageBytes, maxStderrBytes: this.config.maxStderrBytes, + // Exit observation must never be slower than the escalation it feeds. + pipeDrainGraceMs: this.config.killGraceMs, shutdownTimeoutMs: this.config.shutdownTimeoutMs, killGraceMs: this.config.killGraceMs, } - return new LspInstance(spec) + return new LspInstance(spec, this.spawner) } /** Dispose every live instance and block further queries. */ @@ -302,12 +307,9 @@ class LocalLspProvider implements LspProvider { } } -/** The ambient env minus credential-shaped vars, plus the config's explicit env. */ +/** The seam's scrubbed parent env (credential-shaped and DSH_* names dropped), plus the config's explicit env. */ function buildChildEnv(extra: Record): Record { - const scrubbed = Object.entries(process.env).filter( - ([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key), - ) as [string, string][] - return { ...Object.fromEntries(scrubbed), ...extra } + return { ...scrubbedParentEnv(), ...extra } } /** diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 266dd3c59f..0718bb6b82 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -17,7 +17,7 @@ import type { import { deadline } from '@deepseek-ai/dsh-timeout' import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' -import type { ConnectionSpec, ConnectionWriter } from './connection.ts' +import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts' import type { HostSource } from './host.ts' import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' import { @@ -67,10 +67,11 @@ export class LspInstance { /** * @param spec - the launch, initialize, and teardown parameters. + * @param spawner - the subprocess seam's spawn function. * @param writer - optional connection writer used by transport conformance tests. */ - constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) { - this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer) + constructor(private readonly spec: InstanceSpec, spawner: ConnectionSpawner, writer?: ConnectionWriter) { + this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer) this.ready = this.initialize() // A handshake rejection must not surface as an unhandled rejection before the first query awaits // it; queries attach the real handler. diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index a2da86d87c..de9777269a 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -16,7 +16,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' const pkgDir = fileURLToPath(new URL('..', import.meta.url)) const seamLib = join(pkgDir, '../lsp/lib/index.js') -const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) +const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js') +const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(subprocessLib) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -41,8 +42,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const { Context } = await import('cordis') const { default: Lsp } = await import('@deepseek-ai/dsh-lsp') const LspLocal = await import('@deepseek-ai/dsh-lsp-local') + const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local') const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { fake: { diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index aa7e819cb6..cdb331077c 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -1,18 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-local' -import { - signalProcessGroup, - signalProcessTree, - taskkillProcessTree, - waitForTreeExit, -} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' -import type { - ConnectionWriter, - ProcessSignalRunner, - ProcessTreeOperations, - TaskkillRunner, -} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -42,8 +32,9 @@ function connect( env: { ...process.env as Record, ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, + pipeDrainGraceMs: 3_000, configuration: { setting: 42 }, - }, (method, params) => { + }, spawnSubprocess, (method, params) => { seen?.push({ method, params }) return onServerRequest(method, params) }) @@ -151,8 +142,9 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec env: { ...process.env as Record }, maxMessageBytes: 16_000_000, maxStderrBytes, + pipeDrainGraceMs: 3_000, configuration: null, - }, () => Promise.resolve(null), writer) + }, spawnSubprocess, () => Promise.resolve(null), writer) open.push(conn) return conn } @@ -166,8 +158,9 @@ describe('LspConnection edge behavior', () => { env: {}, maxMessageBytes: 1000, maxStderrBytes: 1000, + pipeDrainGraceMs: 3_000, configuration: null, - }, () => Promise.resolve(null)) + }, spawnSubprocess, () => Promise.resolve(null)) open.push(conn) await expect(conn.request('initialize', {})).rejects.toThrow() }) @@ -248,72 +241,6 @@ describe('LspConnection edge behavior', () => { }) }) -describe('process-tree signaling', () => { - it('forwards POSIX process-group signals through the host runner', () => { - const run: ProcessSignalRunner = vi.fn(() => true) - signalProcessGroup(-42, 'SIGKILL', run) - expect(run).toHaveBeenCalledWith(-42, 'SIGKILL') - }) - - it('waits for tree exit and stops when its bound aborts', async () => { - const isAlive = vi.fn() - .mockReturnValueOnce(true) - .mockReturnValue(false) - const yieldNow = vi.fn(() => Promise.resolve()) - await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true) - expect(yieldNow).toHaveBeenCalledOnce() - - const controller = new AbortController() - controller.abort() - await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false) - }) - - it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => { - const operations = fakeProcessTreeOperations() - signalProcessTree('win32', 42, 'SIGTERM', operations) - expect(operations.taskkill).toHaveBeenCalledWith(42) - expect(operations.signal).not.toHaveBeenCalled() - - signalProcessTree('linux', 42, 'SIGKILL', operations) - expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL') - }) - - it('surfaces a Windows taskkill failure without downgrading to the direct child', () => { - const fallback = fakeProcessTreeOperations() - vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') }) - expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/) - expect(fallback.killChild).not.toHaveBeenCalled() - }) - - it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => { - const posixGone = fakeProcessTreeOperations() - vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') }) - vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') }) - expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow() - }) - - it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => { - const success: TaskkillRunner = vi.fn(() => ({ status: 0 })) - taskkillProcessTree(42, success) - expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' }) - - expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow() - - const spawnFailure = new Error('cannot spawn taskkill') - expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure) - expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/) - }) -}) - -/** Create observable process-tree operations without touching host processes. */ -function fakeProcessTreeOperations(): ProcessTreeOperations { - return { - signal: vi.fn(), - killChild: vi.fn(), - taskkill: vi.fn(), - } -} - /** Poll a predicate until it holds or a deadline elapses. */ async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { const start = Date.now() diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 9f246e602a..48dbd0252a 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -9,6 +9,7 @@ import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' +import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -43,10 +44,11 @@ function makeInstance( initializationOptions: { init: true }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, + pipeDrainGraceMs: 200, shutdownTimeoutMs: 200, killGraceMs: 200, ...overrides, - }, writer) + }, spawnSubprocess, writer) live.push(instance) return instance } @@ -72,10 +74,11 @@ function scriptInstance(script: string, overrides: Partial = {}): initializationOptions: null, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, + pipeDrainGraceMs: 150, shutdownTimeoutMs: 150, killGraceMs: 150, ...overrides, - }) + }, spawnSubprocess) live.push(instance) return instance } diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 8a556a01e9..ccd85a1e14 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -7,6 +7,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { Context } from 'cordis' import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' @@ -45,6 +46,7 @@ async function mount( ): Promise { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) const register = ctx.lsp.registerProvider.bind(ctx.lsp) const registrationSpy = captureProvider === undefined ? undefined @@ -76,6 +78,7 @@ describe('lsp-local end to end over a fake server', () => { await writeFile(join(ws, 'a.py'), 'x = 1\n') const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }), @@ -318,6 +321,7 @@ describe('lsp-local end to end over a fake server', () => { it('rejects at load when the command is not found', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: { missing: { diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 829a84264b..77fcbe7851 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -3,6 +3,7 @@ import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { Context } from 'cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' @@ -42,6 +43,7 @@ describe('lsp-local provider resolution', () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('onpath', { command: 'fake-lsp', args: [], @@ -54,6 +56,7 @@ describe('lsp-local provider resolution', () => { it('skips empty PATH segments and fails when the command is absent', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('nope', { command: 'fake-lsp', args: [], @@ -67,6 +70,7 @@ describe('lsp-local provider resolution', () => { // Use a server that never emits results and dispose the plugin, then confirm queries are refused. const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) // Grab the provider instance by registering, then dispose the whole plugin fiber. const lsp = ctx.lsp const fiber = await ctx.plugin(LspLocal, config('disp', { @@ -83,6 +87,7 @@ describe('lsp-local provider resolution', () => { it('rejects a nonpositive teardown budget at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('bad-budget', { command: process.execPath, args: ['-e', ''], @@ -95,6 +100,7 @@ describe('lsp-local provider resolution', () => { it('rejects a nonpositive byte cap at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('bad-cap', { command: process.execPath, args: ['-e', ''], @@ -107,6 +113,7 @@ describe('lsp-local provider resolution', () => { it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('bad-timer', { command: process.execPath, args: ['-e', ''], @@ -122,6 +129,7 @@ describe('lsp-local provider resolution', () => { await writeFile(notExe, 'plain text, not executable') const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('abs-bad', { command: notExe, args: [], @@ -133,6 +141,7 @@ describe('lsp-local provider resolution', () => { it('rejects an executable directory as a command at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('abs-directory', { command: ws, args: [], @@ -144,6 +153,7 @@ describe('lsp-local provider resolution', () => { it('rejects an empty server table at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/) await ctx.fiber.dispose() }) @@ -151,6 +161,7 @@ describe('lsp-local provider resolution', () => { it('rejects an empty server id at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('', { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' }, @@ -161,6 +172,7 @@ describe('lsp-local provider resolution', () => { it('resolves every executable before publishing any provider', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: { valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, @@ -174,6 +186,7 @@ describe('lsp-local provider resolution', () => { it('rolls back earlier registrations when a later server conflicts', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: { first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index 8ba61c0717..fe9230ce14 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -10,6 +10,7 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' @@ -52,6 +53,7 @@ beforeAll(async () => { ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { typescript: { diff --git a/packages/lsp/lsp-local/tsconfig.json b/packages/lsp/lsp-local/tsconfig.json index 3a631ae288..2b106ddc81 100644 --- a/packages/lsp/lsp-local/tsconfig.json +++ b/packages/lsp/lsp-local/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../lsp" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 0a0265b490..4d790cbd6b 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url' import { Context } from 'cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import Lsp from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy' @@ -49,6 +50,7 @@ async function mount(hang: boolean, timeoutMs?: number): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { inline: { diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 3480b48e52..0d214de9e8 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -40,6 +41,7 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", diff --git a/packages/mcp/mcp-client/src/transport.ts b/packages/mcp/mcp-client/src/transport.ts index 6f7c584b20..f4bed91931 100644 --- a/packages/mcp/mcp-client/src/transport.ts +++ b/packages/mcp/mcp-client/src/transport.ts @@ -9,22 +9,17 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { Config } from './index.ts' /** - * Credential-shaped ambient env vars are NOT forwarded to the child by default - * (the parent harness's own secrets must not leak into a spawned process - * implicitly). Same pattern as `dsh-subagent-acp`. + * The subprocess seam's scrubbed parent env (credential-shaped and stale + * `DSH_*` names dropped), plus the spec's explicit env. The MCP SDK owns the + * actual spawn, so this transport shares the scrub definition rather than the + * spawn path. */ -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */ function buildChildEnv(extra: Record): Record { - const env: Record = {} - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value - } - return { ...env, ...extra } + return { ...scrubbedParentEnv(), ...extra } } /** diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index 668ee2c3cb..461b250297 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../../core/tools" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index fb26d845e5..e86024516e 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index b466ecfc46..d768d6bfa8 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -10,6 +10,7 @@ import type { IPtyForkOptions } from 'node-pty' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -26,7 +27,6 @@ export const name = 'pty-local' /** Required services: PTY registry plus the one shared confinement policy. */ export const inject = ['pty', 'sandbox', 'sandboxPolicy'] -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i interface SandboxModeFenceState { pty: Context['pty'] sandboxPolicy: Context['sandboxPolicy'] @@ -56,12 +56,9 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { } function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value - } + // node-pty owns the spawn; the base env shares the subprocess seam's scrub. return { - ...env, + ...scrubbedParentEnv(), TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', diff --git a/packages/pty/pty-local/tsconfig.json b/packages/pty/pty-local/tsconfig.json index 45a03248db..1580e4c8a2 100644 --- a/packages/pty/pty-local/tsconfig.json +++ b/packages/pty/pty-local/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../sandbox/sandbox-policy" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json index 1d9c6a2be2..2884e04f72 100644 --- a/packages/sdk/helper/package.json +++ b/packages/sdk/helper/package.json @@ -35,6 +35,7 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -44,6 +45,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/sdk/helper/src/package-managers/package-manager.ts b/packages/sdk/helper/src/package-managers/package-manager.ts index 8d6b617977..3d67d194d3 100644 --- a/packages/sdk/helper/src/package-managers/package-manager.ts +++ b/packages/sdk/helper/src/package-managers/package-manager.ts @@ -5,6 +5,7 @@ */ import { execFile, spawn } from 'node:child_process' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { promisify } from 'node:util' import type { PackageJsonFile } from '../documents/package-json-file.ts' import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts' @@ -51,8 +52,14 @@ export async function probePackageManagerVersion(name: PackageManagerName, cwd: } } -/** Remove credential-shaped environment variables from spawned commands. */ -export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { +/** + * Remove credential-shaped environment variables from spawned commands. + * @param environment - source environment (injectable for tests); the default + * path shares the subprocess seam's scrub so every harness spawner drops the + * same names. + */ +export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (environment === undefined) return scrubbedParentEnv() return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name))) } diff --git a/packages/sdk/helper/tsconfig.json b/packages/sdk/helper/tsconfig.json index 18e79898c7..b1a3b7a61a 100644 --- a/packages/sdk/helper/tsconfig.json +++ b/packages/sdk/helper/tsconfig.json @@ -33,6 +33,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/subagent/README.md b/packages/subagent/README.md index ccc6ab9cba..e03072fa78 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -8,10 +8,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | | `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | -| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs). Tests replace only the child boundary with package-local fixtures. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend spawns its child through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 45d9abe2b8..991773dadd 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -55,7 +55,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. 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 are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. 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/package.json b/packages/subagent/subagent-acp/package.json index 2564afa8da..b06a5e50ea 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -32,7 +32,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -47,7 +47,8 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index f8b3cd78c6..45605a8f2c 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -15,7 +15,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' -export const inject = ['subagents'] +export const inject = ['subagents', 'subprocess'] /** Config: how to spawn and drive the child ACP agent process. */ export interface Config { @@ -152,6 +152,7 @@ class AcpProvider implements SubagentProvider { env: this.config.env, disposeEofGraceMs: this.config.disposeEofGraceMs, disposeGraceMs: this.config.disposeGraceMs, + spawn: spec => this.ctx.subprocess.spawn(spec), onError: (error, stopReason) => { // The seam forbids `result` rejecting, so a child-level failure is // flattened to a stop reason — preserve it here rather than losing it. diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 730505df84..aa9cd4f492 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -8,9 +8,8 @@ * @module @deepseek-ai/dsh-subagent-acp/run */ -import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { Readable, Writable } from 'node:stream' +import { Readable as NodeReadable, Writable as NodeWritable } from 'node:stream' import { ClientSideConnection, ndJsonStream, @@ -26,7 +25,7 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' @@ -47,9 +46,9 @@ export interface AcpRunSpec { permission: PermissionPolicy /** * Extra environment variables to ADD for the child (e.g. the child harness's - * `DEEPSEEK_API_KEY`). Merged on top of the scrubbed ambient env — see - * {@link buildChildEnv}. A value here is forwarded even if its name matches - * the credential-scrub pattern (an explicit opt-in for the child's own creds). + * `DEEPSEEK_API_KEY`). Merged on top of the subprocess seam's scrubbed + * parent env. A value here is forwarded even if its name matches the + * credential-scrub pattern (an explicit opt-in for the child's own creds). */ env: Record /** @@ -65,6 +64,12 @@ export interface AcpRunSpec { * fills this from its `disposeGraceMs` config. */ disposeGraceMs: number + /** + * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the + * child rides the shared scrub, tree-scoped teardown, and service-owned + * lifetime instead of a package-local child_process path. + */ + spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle /** * Sink for a child-level failure that the run flattened into a stop reason * (the seam contract forbids `result` rejecting). The driver calls this with @@ -159,20 +164,33 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // each other or with a local agent that happens to use the same session id. const id = SessionId(randomUUID()) - // Keep diagnostics on parent stderr; only ACP output contributes to the result. - const child = spawn(spec.command, spec.args, { + // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes + // to the result. The seam's scrub drops ambient credentials while spec.env + // (the child's own key) merges after it. + const child = spec.spawn({ + argv: [spec.command, ...spec.args], cwd: spec.cwd, - env: buildChildEnv(spec.env), - stdio: ['pipe', 'pipe', 'inherit'], + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: spec.disposeGraceMs, + env: spec.env, }) - // Capture the child-process error event immediately. - const spawnFailed = spawnFailure(child) + /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ + if (child.stdin === undefined || child.stdout === undefined) { + throw new Error('subagent-acp: subprocess implementation dropped a piped protocol stream') + } + /* v8 ignore stop */ + // Spawn-level failure surfaces as `done` rejecting into the startup race; a + // clean exit must never win it, so the success arm parks forever. + /* v8 ignore start -- the success arm's never-settling executor is intentionally empty. */ + const spawnFailed: Promise = child.done.then(() => new Promise(() => {}), (err: unknown) => Promise.reject(toError(err))) + /* v8 ignore stop */ + spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ }) // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined - const disposeProcess = (): Promise => (processDisposal ??= disposeChildProcess(child, { - disposeEofGraceMs: spec.disposeEofGraceMs, - disposeGraceMs: spec.disposeGraceMs, + const disposeProcess = (): Promise => (processDisposal ??= child.dispose({ + eofGraceMs: spec.disposeEofGraceMs, + graceMs: spec.disposeGraceMs, })) // Accumulate the child's streamed assistant text — the SubagentResult output. @@ -207,8 +225,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const conn = new ClientSideConnection( makeClient, ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, + NodeWritable.toWeb(child.stdin) as WritableStream, + NodeReadable.toWeb(child.stdout) as ReadableStream, ), ) @@ -252,7 +270,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe sessionId = returnedSessionId if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), - spawnFailed.then((err): never => { throw err }), + spawnFailed, cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }), ]) } catch (error: unknown) { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 6f5a1e1c54..b3909b708d 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -21,7 +21,7 @@ const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cord const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) // How to launch the child acp-agent (src via tsx / lib via plain node, per DSH_EXAMPLE_MODE). -// buildChildEnv scrubs ambient creds but keeps these extras, so the model key is +// The subprocess seam scrubs ambient creds while spec.env merges after it, so the model key is // forwarded explicitly; TSX_TSCONFIG_PATH is added by the resolver in src mode only. const childLaunch = resolveExampleLaunch({ srcBin: binScript, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 79ef8831cf..919e736cb8 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,10 +6,11 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' -import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' /** * Keyless integration tests for the ACP subagent backend. Each spawns a REAL @@ -41,6 +42,7 @@ interface SetupEnv { async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -98,19 +100,23 @@ describe('acpContentText / toAcpPrompt', () => { }) }) -describe('buildChildEnv', () => { - it('drops credential-shaped ambient vars but keeps the explicit extras', () => { - process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me' +describe('child env layering (through the subprocess seam)', () => { + it('drops credential-shaped ambient vars but keeps the explicit extras', async () => { + process.env.ACP_TEST_AMBIENT_SECRET_TOKEN = 'leak-me' try { - const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' }) - // The credential-shaped ambient var is scrubbed. - expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined() - // The explicitly-supplied key survives (an opt-in for the child's creds). - expect(env.DEEPSEEK_API_KEY).toBe('explicit') - // A normal ambient var is forwarded. - expect(env.PATH).toBe(process.env.PATH) + // The spec.env layer merges after the seam's scrub, so the child's own + // explicitly-forwarded key survives while ambient credentials do not. + const running = spawnSubprocess({ + argv: ['bash', '-c', 'echo "[${ACP_TEST_AMBIENT_SECRET_TOKEN:-absent}|$DEEPSEEK_API_KEY]"'], + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 1000, + env: { DEEPSEEK_API_KEY: 'explicit' }, + }) + await running.done + expect(running.collected.stdout!.readFrom(0).text.trim()).toBe('[absent|explicit]') } finally { - delete process.env.DSH_ACP_TEST_SECRET_TOKEN + delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN } }) }) @@ -140,6 +146,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) // A command that would create the sentinel if the child were ever spawned. await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) const parent = { id: 'parent', session: { header: {} } } as unknown as Agent @@ -158,6 +165,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -185,6 +193,7 @@ describe('cwd resolution', () => { const absolute = resolve(relative) const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -204,6 +213,7 @@ describe('cwd resolution', () => { // reintroduce the launch-directory fallback this resolution removed. const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', @@ -224,6 +234,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', @@ -242,6 +253,7 @@ describe('cwd resolution', () => { it('rejects a config cwd that is not an accessible directory at load', async () => { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', @@ -283,6 +295,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) @@ -360,7 +373,7 @@ describe('dsh-subagent-acp', () => { await expect(startAcpRun( request('p', controller.signal), // `touch ` — runs only if the process is actually spawned. - { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, + { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess }, )).rejects.toThrow('aborted before the ACP child started') // The binary was never launched — no sentinel. expect(existsSync(sentinel)).toBe(false) @@ -385,6 +398,7 @@ describe('dsh-subagent-acp', () => { }, disposeEofGraceMs: 1000, disposeGraceMs: 100, + spawn: spawnSubprocess, })).rejects.toThrow('ACP child published without a session id') // Startup rejects only after its private child reaches quiescence. The // marker proves rollback closed stdin and allowed the child's EOF flush. @@ -412,6 +426,7 @@ describe('dsh-subagent-acp', () => { // small so the whole ladder finishes well within the 4000ms bound. disposeEofGraceMs: 150, disposeGraceMs: 150, + spawn: spawnSubprocess, } const run = await startAcpRun(request(), spec) // Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a @@ -459,6 +474,7 @@ describe('dsh-subagent-acp', () => { }, disposeEofGraceMs: 2000, disposeGraceMs: 50, + spawn: spawnSubprocess, } const run = await startAcpRun(request(), spec) // Wait until the child is fully booted with its prompt in flight (its ACP @@ -492,6 +508,7 @@ describe('dsh-subagent-acp', () => { // Tiny EOF grace so the ignored-EOF window elapses quickly. disposeEofGraceMs: 150, disposeGraceMs: 2000, + spawn: spawnSubprocess, } const run = await startAcpRun(request(), spec) await waitForFile(ready) @@ -587,7 +604,7 @@ describe('dsh-subagent-acp', () => { it('rejects a spawn failure after provider-owned cleanup', async () => { await expect(startAcpRun( request(), - { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, + { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess }, )).rejects.toThrow() }) @@ -601,6 +618,7 @@ describe('dsh-subagent-acp', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -626,6 +644,7 @@ describe('dsh-subagent-acp', () => { for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) .rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/) await ctx.fiber.dispose() @@ -635,6 +654,7 @@ describe('dsh-subagent-acp', () => { it('rejects a startup failure via the provider load path', async () => { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: '/nonexistent/acp-agent-binary', @@ -661,6 +681,7 @@ describe('dsh-subagent-acp', () => { env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: spawnSubprocess, onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) }, }, ) @@ -699,6 +720,7 @@ describe('dsh-subagent-acp', () => { env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: spawnSubprocess, onError: () => { throw new Error('sink boom') }, }, ) @@ -763,6 +785,7 @@ describe('dsh-subagent-acp', () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} }) expect(ctx.subagents.list()).toEqual(['acp']) await fiber.dispose() @@ -772,7 +795,7 @@ describe('dsh-subagent-acp', () => { it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in acp).toBe(false) expect(acp.name).toBe('subagent-acp') - expect(acp.inject).toEqual(['subagents']) + expect(acp.inject).toEqual(['subagents', 'subprocess']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(acp) as Record expect(unwrapped).toBe(acp) diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index 175eb78e2f..2d60858d4a 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -27,7 +27,7 @@ "path": "../subagent" }, { - "path": "../subagent-subprocess" + "path": "../../subprocess/subprocess" }, { "path": "../../support/loader-smoke" diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md deleted file mode 100644 index bd1900d612..0000000000 --- a/packages/subagent/subagent-subprocess/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# @deepseek-ai/dsh-subagent-subprocess - -Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md). - -Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library. - -## What it exports - -### `buildChildEnv(extra)` - -The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child. - -### `spawnFailure(child)` - -Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles. - -### `disposeChildProcess(child, graces)` - -The platform-aware dispose ladder resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): - -1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact; -2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`; -3. force termination — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows — then wait at most `graces.disposeGraceMs` for exit; a signal error or missing exit rejects disposal. - -The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields. POSIX uses `disposeGraceMs` after both the graceful and forced signals; Windows skips the redundant graceful signal but uses it to bound forced-exit confirmation. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush. - -The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child. - -### `createIsolatedConfigDir(prefix, pinnedPath?)` - -A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose. - -- **Fresh (default)**: a private (0700) `mkdtemp` dir under the OS temp root; `remove()` deletes it best-effort (never rejects — a leftover temp dir beats a failed dispose) and is idempotent. -- **Pinned** (`pinnedPath` set): the path is returned as-is — never created, never removed. A deployment that pins a directory to share child state across runs owns that directory's lifecycle. - -## Testing - -`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and platform termination paths run against a scriptable fake child. The [ACP backend suite](../subagent-acp/README.md) exercises them against real subprocesses end to end. - -## Model Experience - -Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories. - -#### KV Cache effect - -No direct invalidation; the named consumer owns any request-prefix changes. - -## Known Limitations and Deferred Work - -- **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment. -- **Signals target the direct child only** — teardown relies on a cooperative CLI to reap its descendants before exit; a re-parented or independently detached grandchild can outlive the ladder. -- **Fresh config-dir cleanup is best-effort** — an `rm` failure leaves private state under the OS temp root rather than failing disposal. -- **Pinned config directories are wholly operator-owned** — the helper neither creates, validates, locks, nor removes them, so concurrent runs may share and race on that state. diff --git a/packages/subagent/subagent-subprocess/package.json b/packages/subagent/subagent-subprocess/package.json deleted file mode 100644 index bd573b3c0c..0000000000 --- a/packages/subagent/subagent-subprocess/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-subagent-subprocess", - "description": "Shared out-of-process subagent machinery: credential env scrub, spawn-failure capture, child-exit waits, the EOF-to-SIGTERM-to-SIGKILL dispose ladder, and isolated config dirs (pure lib; registers nothing)", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts deleted file mode 100644 index 47a97bafb6..0000000000 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external - * agent as a child process and must keep the parent deployment's credentials out of it, tear - * it down to quiescence, and isolate it from the host user's on-disk CLI state. This package - * registers no provider; consuming plugins own and validate every timing or path default. - * @module @deepseek-ai/dsh-subagent-subprocess - */ - -import type { ChildProcess } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -/** - * Credential-shaped ambient env vars are NOT forwarded to a child by default - * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a - * spawned process implicitly). Same pattern as the bash executor. The child - * agent needs its OWN credentials to reach a model — those are supplied - * explicitly via the `extra` layer of {@link buildChildEnv}, which lands AFTER - * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental - * `AWS_SECRET_ACCESS_KEY` does not. - */ -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** - * The ambient env minus credential-shaped vars, plus the caller's explicit - * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so - * a child CLI runs normally; only credential-shaped names are dropped. - * @param extra - explicit vars layered on top AFTER the scrub, so a - * credential-shaped name supplied deliberately still reaches the child. - * @returns the environment to spawn the child with. - */ -export function buildChildEnv(extra: Record): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value - } - return { ...env, ...extra } -} - -/** - * Capture the child's spawn-level `error` event as a promise. Call in the same tick as - * `spawn()`; otherwise an early event can be unhandled and crash the parent. - * @param child - the just-spawned child process. - * @returns a promise that RESOLVES (never rejects) with the child's first - * `error` event; for a child that spawns cleanly it never settles. - */ -export function spawnFailure(child: ChildProcess): Promise { - return new Promise((resolve) => { - child.once('error', (err) => { resolve(err) }) - }) -} - -/** - * Race the child's exit against a timer. Neither outcome leaves anything - * behind on the child: the exit listener is removed on timeout and the timer - * is cleared on exit, so repeated calls (the dispose ladder's tiers, a poll - * loop) never accumulate listeners. - * @param child - the child process to watch. - * @param ms - the wait window in milliseconds. - * @returns `true` if the child exits within `ms` (immediately if it is - * already gone), `false` on timeout. - */ -function exitsWithin(child: ChildProcess, ms: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) - return new Promise((resolve) => { - const onExit = (): void => { - clearTimeout(timer) - resolve(true) - } - // `.unref()` so a pending grace timer never keeps the parent's loop alive. - const timer = setTimeout(() => { - child.removeListener('exit', onExit) - resolve(false) - }, ms).unref() - child.once('exit', onExit) - }) -} - -/** - * The two grace periods of the dispose ladder, supplied per call by the - * consuming backend — each plugin carries them as defaulted, validated - * `disposeEofGraceMs`/`disposeGraceMs` Config fields, so teardown timing is - * deployment-tunable and this library hardcodes nothing. - */ -export interface DisposeLadderGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own nested subprocesses — - * before the parent escalates to platform termination. A separate (usually WIDER) - * grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative - * child's EOF-driven teardown may itself be waiting on a signal-trapping - * grandchild plus a final flush, needing more than one signal-grace of - * headroom. - */ - disposeEofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after - * `SIGKILL`; Windows applies it after the direct forced termination. - */ - disposeGraceMs: number -} - -/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */ -function forceTerminateWithin(child: ChildProcess, ms: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise((resolve, reject) => { - let accepted = false - let settled = false - const cleanup = (): void => { - clearTimeout(timer) - child.off('exit', onExit) - child.off('error', onError) - } - const settle = (complete: () => void): void => { - if (settled) return - settled = true - cleanup() - complete() - } - const onExit = (): void => { settle(resolve) } - const onError = (error: Error): void => { settle(() => { reject(error) }) } - child.once('exit', onExit) - child.once('error', onError) - const timer = setTimeout(() => { - const disposition = accepted ? 'accepted' : 'refused' - settle(() => { - reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`)) - }) - }, ms).unref() - try { - accepted = child.kill('SIGKILL') - if (child.exitCode !== null || child.signalCode !== null) settle(resolve) - } catch (error: unknown) { - settle(() => { reject(new Error('SIGKILL failed', { cause: error })) }) - } - }) -} - -/** - * Tear a child process down to quiescence, resolving only after exit: close stdin and allow - * cooperative flush, then use the host's graceful and forced termination semantics. POSIX - * sends `SIGTERM` before `SIGKILL`; Windows skips directly to forced termination because Node - * maps both signals to `TerminateProcess`. - * - * @param child - the child process to tear down. - * @param graces - the two grace periods, from the consuming plugin's Config. - * @param platform - the host platform, injectable for unit coverage. - * @throws When forced termination errors or the child does not report exit within - * `disposeGraceMs`. - */ -export async function disposeChildProcess( - child: ChildProcess, - graces: DisposeLadderGraces, - platform: NodeJS.Platform = process.platform, -): Promise { - // Already gone: nothing to reap. - if (child.exitCode !== null || child.signalCode !== null) return - // 1. Close stdin and allow cooperative teardown and durable-state flush. - child.stdin?.end() - if (await exitsWithin(child, graces.disposeEofGraceMs)) return - // 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate. - if (platform !== 'win32') { - child.kill('SIGTERM') - if (await exitsWithin(child, graces.disposeGraceMs)) return - } - // 3. Force-kill and await a bounded exit edge. - await forceTerminateWithin(child, graces.disposeGraceMs) -} - -/** - * A per-run config directory handle for an external CLI child — the target of - * `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection. Hand {@link path} to - * the child's environment; call {@link remove} on dispose. - */ -export interface IsolatedConfigDir { - /** The directory to point the child at. */ - path: string - /** - * Best-effort cleanup: removes the directory (recursively) iff this handle - * CREATED it — a pinned directory is never removed. Idempotent; never - * rejects (a leftover dir under the OS temp root is preferable to a failed - * dispose). - */ - remove(): Promise -} - -/** - * An isolated config dir for one child run, independent of host CLI state. Without - * `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory - * is returned unchanged and remains deployment-owned. - * - * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g. - * `dsh-subagent-codex-`); ignored when `pinnedPath` is set. - * @param pinnedPath - a deployment-pinned directory to use instead of a - * fresh one. - * @returns the directory handle: `path` for the child env, `remove()` for - * dispose. - */ -export async function createIsolatedConfigDir(prefix: string, pinnedPath?: string): Promise { - if (pinnedPath !== undefined) { - return { - path: pinnedPath, - remove(): Promise { - // A pinned dir is deployment-owned state (config the user asked to - // persist across runs); removing it here would destroy it. No-op. - return Promise.resolve() - }, - } - } - const path = await mkdtemp(join(tmpdir(), prefix)) - return { - path, - async remove(): Promise { - try { - await rm(path, { recursive: true, force: true }) - } catch { - // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead - // child left an unreadable entry behind). - } - }, - } -} diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts deleted file mode 100644 index 5e401cd738..0000000000 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-subprocess`. - * @module @deepseek-ai/dsh-subagent-subprocess/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' - -/** Cordis companion plugin name. */ -export const name = 'subagent-subsubprocess-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts deleted file mode 100644 index d674937e92..0000000000 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { existsSync } from 'node:fs' -import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { ChildProcess } from 'node:child_process' -import { - buildChildEnv, - createIsolatedConfigDir, - disposeChildProcess, - spawnFailure, -} from '../src/index.ts' - -// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm -// failures are not portable and disappear under root, so this is the sanctioned filesystem seam. -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, rm: vi.fn(actual.rm) } -}) - -/** - * Unit tests for the shared out-of-process machinery. The env scrub and the - * isolated-config-dir helpers run against the REAL process env and REAL - * filesystem (one exception: the rm-failure path injects its rejection at the - * mocked fs boundary, see above); the exit waits and the dispose ladder run - * against a scriptable fake child so each escalation tier's timing is driven - * deterministically (the ACP backend's suite exercises the same ladder - * against real subprocesses end to end). - */ - -/** What fells a scripted {@link FakeChild}. */ -type LethalTrigger = 'eof' | NodeJS.Signals - -/** Per-scenario script for a {@link FakeChild}. */ -interface FakeChildScript { - /** - * The one trigger that makes the child exit (SIGKILL always does, - * uncatchable, like a real process). Omitted: only SIGKILL fells it. - */ - diesOn?: LethalTrigger - /** Delay (ms) between the lethal trigger and the exit event. */ - delayMs?: number - /** Complete the scripted exit inside the triggering call. */ - synchronousExit?: boolean - /** `false` models a child spawned without a stdin pipe. */ - stdin?: boolean -} - -/** - * A scriptable stand-in for a ChildProcess carrying exactly the surface the - * helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the - * `exit` event. - */ -class FakeChild extends EventEmitter { - exitCode: number | null = null - signalCode: NodeJS.Signals | null = null - readonly kills: NodeJS.Signals[] = [] - stdinEnded = false - readonly stdin: { end: () => void } | null - - constructor(private readonly script: FakeChildScript = {}) { - super() - this.stdin = script.stdin === false - ? null - : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } } - } - - kill(signal: NodeJS.Signals): boolean { - this.kills.push(signal) - this.maybeDie(signal) - return true - } - - private maybeDie(trigger: LethalTrigger): void { - // SIGKILL is uncatchable — it always fells the child; any other trigger - // only when the scenario scripts it as the lethal one. - if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return - const exit = (): void => { - if (trigger === 'eof') this.exitCode = 0 - else this.signalCode = trigger - this.emit('exit', this.exitCode, this.signalCode) - } - if (this.script.synchronousExit === true) exit() - else setTimeout(exit, this.script.delayMs ?? 0) - } -} - -/** The helpers take a real ChildProcess; the fake carries the read surface. */ -function asChild(fake: FakeChild): ChildProcess { - return fake as unknown as ChildProcess -} - -describe('buildChildEnv', () => { - it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => { - process.env.DSH_PROC_TEST_API_KEY = 'leak' - process.env.dsh_proc_test_secret = 'leak' - process.env.DSH_PROC_TEST_TOKEN = 'leak' - try { - const env = buildChildEnv({}) - expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined() - expect(env.dsh_proc_test_secret).toBeUndefined() - expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined() - } finally { - delete process.env.DSH_PROC_TEST_API_KEY - delete process.env.dsh_proc_test_secret - delete process.env.DSH_PROC_TEST_TOKEN - } - }) - - it('forwards normal ambient vars', () => { - expect(buildChildEnv({}).PATH).toBe(process.env.PATH) - }) - - it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => { - process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak' - try { - const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' }) - // The ambient value was scrubbed; ONLY the explicit opt-in reaches the child. - expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit') - } finally { - delete process.env.DSH_PROC_TEST_EXTRA_TOKEN - } - }) - - it('an extra overrides the ambient value of a non-credential var', () => { - process.env.DSH_PROC_TEST_PLAIN = 'ambient' - try { - expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override') - } finally { - delete process.env.DSH_PROC_TEST_PLAIN - } - }) -}) - -describe('spawnFailure', () => { - it('resolves (never rejects) with the first error event', async () => { - const fake = new FakeChild() - const failure = spawnFailure(asChild(fake)) - const err = new Error('spawn ENOENT') - fake.emit('error', err) - await expect(failure).resolves.toBe(err) - }) - - it('never settles for a child that spawns cleanly and exits', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM' }) - const failure = spawnFailure(asChild(fake)) - fake.kill('SIGTERM') - await new Promise(resolve => fake.once('exit', () => { resolve() })) - // A clean lifecycle emits `exit`, never `error` — the capture stays - // pending forever, so a race against it is decided by the other arms. - const settled = await Promise.race([ - failure.then(() => 'settled'), - new Promise(resolve => setTimeout(() => { resolve('pending') }, 30)), - ]) - expect(settled).toBe('pending') - }) -}) - -describe('disposeChildProcess', () => { - it('returns immediately for an already-exited child (no EOF, no signals)', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.stdinEnded).toBe(false) - expect(fake.kills).toEqual([]) - }) - - it('returns immediately for a child already dead by signal', async () => { - const fake = new FakeChild() - fake.signalCode = 'SIGKILL' - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.stdinEnded).toBe(false) - expect(fake.kills).toEqual([]) - }) - - it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => { - const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.stdinEnded).toBe(true) - expect(fake.kills).toEqual([]) - expect(fake.exitCode).toBe(0) - }) - - it('recognizes a child that exits synchronously on stdin EOF', async () => { - const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.exitCode).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') - expect(fake.stdinEnded).toBe(true) - expect(fake.kills).toEqual(['SIGTERM']) - expect(fake.signalCode).toBe('SIGTERM') - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('recognizes a child that exits synchronously on SIGTERM', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM']) - expect(fake.signalCode).toBe('SIGTERM') - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { - const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) - // Quiescence, not a request: at resolution the child has ACTUALLY exited - // (the exit event landed, despite the scripted post-SIGKILL delay). - expect(fake.signalCode).toBe('SIGKILL') - }) - - it('recognizes a child already gone when the final exit wait begins', async () => { - const fake = new FakeChild({ synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) - expect(fake.signalCode).toBe('SIGKILL') - }) - - it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => { - const fake = new FakeChild() - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - queueMicrotask(() => { - if (marker === 'exitCode') fake.exitCode = 0 - else fake.signalCode = 'SIGTERM' - }) - return true - }) - - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM']) - }) - - it('walks the ladder for a child spawned without a stdin pipe', async () => { - const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM']) - }) - - it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32') - expect(fake.kills).toEqual(['SIGKILL']) - expect(fake.signalCode).toBe('SIGKILL') - }) - - it('propagates a forced-termination error without waiting for the grace', async () => { - const fake = new FakeChild() - const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - fake.emit('error', failure) - return false - }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, - 'win32', - )).rejects.toBe(failure) - expect(fake.kills).toEqual(['SIGKILL']) - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('wraps a synchronous forced-termination exception and removes its listeners', async () => { - const fake = new FakeChild() - const failure = new Error('invalid signal state') - vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, - 'win32', - )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure }) - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('bounds a refused forced termination that produces no error or exit', async () => { - const fake = new FakeChild() - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - return false - }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 10 }, - 'win32', - )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused') - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('bounds an accepted forced termination that never reports exit', async () => { - const fake = new FakeChild() - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - return true - }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 10 }, - 'win32', - )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted') - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) -}) - -describe('createIsolatedConfigDir', () => { - it('creates a fresh private mkdtemp dir under the OS temp root', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - try { - expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true) - const st = await stat(dir.path) - expect(st.isDirectory()).toBe(true) - // Windows reports synthetic POSIX mode bits; privacy comes from the - // inherited directory ACL rather than chmod-compatible mode bits. - if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700) - } finally { - await dir.remove() - } - }) - - it('creates a distinct dir per call (per-run isolation)', async () => { - const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - try { - expect(a.path).not.toBe(b.path) - } finally { - await a.remove() - await b.remove() - } - }) - - it('remove() deletes a fresh dir recursively and is idempotent', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - await writeFile(join(dir.path, 'settings.json'), '{}') - await dir.remove() - expect(existsSync(dir.path)).toBe(false) - // Second remove: nothing left to delete, still resolves. - await expect(dir.remove()).resolves.toBeUndefined() - }) - - it('returns a pinned dir verbatim and NEVER removes it', async () => { - const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-')) - try { - const dir = await createIsolatedConfigDir('ignored-prefix-', pinned) - expect(dir.path).toBe(pinned) - await dir.remove() - // The deployment owns a pinned dir's lifecycle — remove() must not touch it. - expect(existsSync(pinned)).toBe(true) - } finally { - await rm(pinned, { recursive: true, force: true }) - } - }) - - it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => { - const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`) - const dir = await createIsolatedConfigDir('ignored-prefix-', missing) - expect(dir.path).toBe(missing) - expect(existsSync(missing)).toBe(false) - await dir.remove() - expect(existsSync(missing)).toBe(false) - }) - - it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-') - try { - // The swallow contract is error-kind agnostic; EACCES stands in for the - // family (EBUSY, a vanished mount, …) that best-effort must absorb. - vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })) - await expect(dir.remove()).resolves.toBeUndefined() - // The injected rejection consumed the only rm call — nothing was deleted. - expect(existsSync(dir.path)).toBe(true) - } finally { - await rm(dir.path, { recursive: true, force: true }) - } - }) -}) diff --git a/packages/subagent/subagent-subprocess/tsconfig.json b/packages/subagent/subagent-subprocess/tsconfig.json deleted file mode 100644 index d970a00263..0000000000 --- a/packages/subagent/subagent-subprocess/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 53b295d54e..5256065beb 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -1,10 +1,10 @@ # subprocess/ — subprocess capability family -The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. Command defaulting, shell semantics, deadlines, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [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` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary | -| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal | +| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, kill/terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal | 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/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 4bc2a71691..eccd842396 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -1,14 +1,14 @@ # @deepseek-ai/dsh-subprocess-local -Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). +Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process tree, wires the spec's per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with optional spill files), and signals tree-scoped with SIGTERM→SIGKILL escalation. It has no config: every disposition, limit, and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seams' configs ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)). ## Behavior (and where it came from) -- **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. -- **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; 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. +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **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 + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. 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-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Offset-based reads** — `SubprocessHandle` readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist. -- **Kill-and-join disposal** — the service retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement. +- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. +- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. ## Model Experience diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index f939b9e774..99c527ace6 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -258,8 +258,9 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void { */ export function taskkillProcessTree(pid: number): void { if (pid <= 0) return - // Outcome deliberately unchecked: an already-absent tree (status 128) and - // exit races are as tolerable here as ESRCH is for a POSIX group signal. + // Outcome deliberately unchecked: an already-absent tree (status 128), exit + // races, and a missing taskkill binary (spawnSync reports, never throws) are + // as tolerable here as ESRCH is for a POSIX group signal. spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) } @@ -284,11 +285,14 @@ function signalTree( try { process.kill(-pid, sig) } catch { + /* v8 ignore start -- the fallback needs a live child whose group signal fails + (EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */ try { child.kill(sig) } catch { // The direct child already exited; teardown remains idempotent. } + /* v8 ignore stop */ } } @@ -360,6 +364,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter if (settled) return signalTree(platform, pid, 'SIGTERM', child, taskkill) graceTimer = setTimeout(() => { + /* v8 ignore next -- the timer is cleared at settlement; only an in-flight fire racing the close event sees settled=true. */ if (!settled) signalTree(platform, pid, 'SIGKILL', child, taskkill) }, spec.graceMs) } @@ -422,6 +427,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter return true } catch (error) { const code = (error as NodeJS.ErrnoException).code + /* v8 ignore next -- POSIX reports an absent group as ESRCH; child-reaping timing + makes observing the other arm platform-dependent. */ if (code === 'ESRCH') return false /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ @@ -442,7 +449,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter /** Race settlement against a timer without leaving listeners or live timers behind. */ const settlesWithin = async (ms: number): Promise => { if (settled) return true - let timer: NodeJS.Timeout | undefined + // The executor runs synchronously, so the timer is assigned before the race. + let timer!: NodeJS.Timeout const timeout = new Promise((resolve) => { // `.unref()` so a pending grace timer never keeps the parent's loop alive. timer = setTimeout(() => { resolve(false) }, ms) @@ -451,7 +459,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter try { return await Promise.race([done.then(() => true, () => true), timeout]) } finally { - if (timer !== undefined) clearTimeout(timer) + clearTimeout(timer) } } @@ -474,9 +482,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter return { pid, + /* 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 } : {}, ...stderrCollector !== undefined ? { stderr: stderrCollector } : {}, diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 2f48b0fef3..ecff00d229 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess' -import { killGroup, OutputCollector, spawnSubprocess } from '../src/spawn.ts' +import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ @@ -575,6 +575,147 @@ describe('waitForExit', () => { }) }) +describe('coverage seams', () => { + it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => { + expect(() => { taskkillProcessTree(-1) }).not.toThrow() + expect(() => { taskkillProcessTree(0) }).not.toThrow() + // On POSIX there is no taskkill; spawnSync reports the failure in its + // result and the function stays silent — the same containment Windows + // relies on for an already-absent tree. + expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() + }) + + it('dispose on a spawn-failed handle observes the rejection and returns', async () => { + const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) + const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 }) + await expect(running.done).rejects.toThrow() + await expect(disposal).resolves.toBeUndefined() + }) + + it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => { + const running = spawnSubprocess({ + ...spec('echo to-parent; echo err >&2'), + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 1000 } }, + }) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(running.stdout).toBeUndefined() + expect(running.collected.stdout).toBeUndefined() + expect(running.collected.stderr!.readFrom(0).text).toBe('err\n') + }) + + it('terminate() after settlement is a no-op', async () => { + const running = spawnSubprocess(spec('true')) + await running.done + const spy = vi.spyOn(process, 'kill') + try { + running.terminate() + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } + }) + + it('waitForExit on a failed spawn reports exited immediately', async () => { + const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-spawn-test' })) + await expect(running.done).rejects.toThrow() + await expect(running.waitForExit()).resolves.toBe(true) + }) + + it('dispose() on an already-settled handle returns without signalling', async () => { + const running = spawnSubprocess(spec('true')) + await running.done + const spy = vi.spyOn(process, 'kill') + try { + await running.dispose({ eofGraceMs: 50, graceMs: 50 }) + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } + }) + + it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => { + const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' })) + expect(running.stdin).toBeUndefined() + await running.done + await running.dispose({ eofGraceMs: 50, graceMs: 50 }) + expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n') + }) +}) + +describe('coverage seams 2', () => { + it('win32 treeAlive reports alive for a live child and gone after taskkill', async () => { + let killedPid = 0 + const running = spawnSubprocess(spec('sleep 60'), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killedPid = pid + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone. + } + }, + }) + const aborted = new AbortController() + aborted.abort() + await expect(running.waitForExit(aborted.signal)).resolves.toBe(false) // alive branch + running.terminate() + await running.done + expect(killedPid).toBe(running.pid) + await expect(running.waitForExit()).resolves.toBe(true) + }) + + it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => { + const kills: number[] = [] + const running = spawnSubprocess({ + ...spec('sleep 60'), + stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + }, { + spillDir, + platform: 'win32', + taskkill: (pid) => { + kills.push(pid) + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone. + } + }, + }) + await running.dispose({ eofGraceMs: 50, graceMs: 5_000 }) + // Exactly one forced tree termination: no POSIX SIGTERM tier ran. + expect(kills).toEqual([running.pid]) + }) + + it('dispose throws when even SIGKILL produces no exit within the grace', async () => { + // An inert taskkill simulates a tree that never reports exit. + const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} }) + await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 })) + .rejects.toThrow(/did not exit within 40ms after forced termination/) + // Real cleanup: the injected platform spawned without detachment, so the + // child is a plain (group-less) POSIX process — kill it directly. + process.kill(running.pid, 'SIGKILL') + await running.done + }) + + it("stderr: 'pipe' exposes the raw stream", async () => { + const running = spawnSubprocess({ + ...spec('echo err >&2'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'pipe' }, + }) + expect(running.stderr).toBeDefined() + const text = new Promise((resolve) => { + let out = '' + running.stderr!.on('data', (chunk: Buffer) => { out += chunk.toString('utf8') }) + running.stderr!.on('end', () => { resolve(out) }) + }) + await running.done + expect(await text).toBe('err\n') + }) +}) + describe('argv validation', () => { it('rejects an empty argv before spawning', () => { expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 15165a730d..5c45253154 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -4,13 +4,14 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes ## Contract -- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. -- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself. -- Output readers take whole-stream byte offsets and never consume: 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 full-stream spill file when one exists. -- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the service reacts to the abort but never classifies why (callers own deadlines and cause classification). -- Disposal kills all still-running managed processes and awaits their exit. +- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. +- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` 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 is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the function. +- Disposal of the service terminates all still-running managed processes and awaits their exit. -See the [process data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). ## Model Experience @@ -22,5 +23,5 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **One consumer family so far** — the seam's shape is proven against the bash executors only; the other in-repo spawn sites (LSP servers, PTY backends, subagent transports) keep their own bespoke process handling until their stream/lifecycle needs are re-examined against this contract. -- **POSIX group semantics are assumed** — the handle vocabulary (`pid` as group leader, group kills, SIGTERM/SIGKILL escalation) has no Windows story. +- **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced. +- **The dispose ladder assumes stdin-EOF cooperation** — a child that quiesces on a different signal (SIGHUP conventions, control sockets) needs its own tier-1 before the generic ladder fits. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82f3691044..d85d124931 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2526,6 +2526,12 @@ importers: '@deepseek-ai/dsh-lsp': specifier: workspace:^ version: link:../lsp + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -2597,6 +2603,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -2691,6 +2700,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -2858,6 +2870,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../subagent/tool-subagent @@ -3492,9 +3507,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent - '@deepseek-ai/dsh-subagent-subprocess': + '@deepseek-ai/dsh-subprocess': specifier: workspace:^ - version: link:../subagent-subprocess + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -3624,15 +3642,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/subagent/subagent-subprocess: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/subagent/tool-subagent: dependencies: schemastery: @@ -4702,9 +4711,6 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../../packages/subagent/subagent-spawn - '@deepseek-ai/dsh-subagent-subprocess': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-subprocess '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../packages/subprocess/subprocess diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index c2124da532..24e126a63d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -65,7 +65,6 @@ "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 526e2ea5cd..58e63b5b3a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -271,8 +271,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox'], - note: 'The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'], + note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 8dfde7ad11..f6a3ada2f3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2201,6 +2201,36 @@ "doc": "docs/core-data-structures/subprocess.md", "symbol": "CollectedOutput", "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessStdinMode", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessCollect", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessOutputMode", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessStdio", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessCollectedOutputs", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessDisposeGraces", + "source": "packages/subprocess/subprocess/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0db4417fa5..024a82e933 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -87,7 +87,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, 'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' }, - 'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' }, 'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' }, 'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' }, 'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index a6397660a4..c769d3fcac 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -132,7 +132,6 @@ { "path": "./packages/subagent/subagent" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-inprocess" }, - { "path": "./packages/subagent/subagent-subprocess" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, From 3c98ade986ef99aff210a8e7191ab43c054b35b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:51:24 +0800 Subject: [PATCH 24/75] docs(subprocess): bilingual pair for the consumer-migration Agent Note Adds the zh counterpart, aligns the seam note's zh side with its updated interface bullet and follow-up sentence, records both pairs, and ratchets the translation-pairing manifest. --- ...26-subprocess-consumer-migration.i18n.yaml | 6 +++ ...-07-26-subprocess-consumer-migration.zh.md | 38 +++++++++++++++++++ .../2026-07-26-subprocess-seam.i18n.yaml | 4 +- .../2026-07-26-subprocess-seam.zh.md | 4 +- scripts/translation-pairing.manifest.json | 1 + 5 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml new file mode 100644 index 0000000000..4236aa02bb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 +2026-07-26-subprocess-consumer-migration.zh.md: 039d32d9c737970153e91cfa288e4c9616813f7d diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md new file mode 100644 index 0000000000..00848c41af --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 进程 seam 转向 Node 形状,所有具备条件的 spawn 调用点一并迁入 + +Status: implemented + +[English](2026-07-26-subprocess-consumer-migration.md) | 中文 + +## 问题 + +[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local 与 SDK helper 则各自持有凭据清除的第三、第四、第五份副本——而这一切既不可替换,也无法集中测试。 + +## 决策 + +这道 seam 的词汇如今已是 Node 形状,凡能搭乘该服务的 spawn 调用点均已迁入: + +- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(裸的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 +- **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 +- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 +- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 + +各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 + +挂载 lsp-local 或 subagent-acp 的组合如今都要加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 + +## 曾考虑的替代方案 + +**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式恰好覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。 + +**用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式一次性统辖全部三条流。**否决:真实消费方按流混用模式(lsp:pipe/pipe/collect;acp:pipe/pipe/inherit;bash:data/collect/collect)。按流划分的处置方式恰好就是 Node 的形状,也免去了混用场景的第二个 spawn 调用。 + +**把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。 + +**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 + +## 后果 + +换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 + +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都带上 subprocess 这一行配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 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 4d639a40f2..3cfcb0cc87 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 -2026-07-26-subprocess-seam.md: abfc43c1a960edd498b0960a191153008e39834c -2026-07-26-subprocess-seam.zh.md: 7fba975181b7871b34a8bc0e77d826e1ee2647f5 +2026-07-26-subprocess-seam.md: 5cf0e596603b4cd3240e5d9a114d95f413c6410b +2026-07-26-subprocess-seam.zh.md: 9cf38941953cf0348e6627e9bcb194cf231352d1 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 7fba975181..9cf3894195 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 @@ -12,7 +12,7 @@ Status: implemented 新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: -- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 +- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、逐流 stdio 处置方式、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及共享的凭据清除加 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不做 shell 解释。([消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md)其后将 stdio 与终止词汇进一步拓宽为 Node 形状。) - **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 - **`dsh-bash-local`(消费方)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 - **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 @@ -25,7 +25,7 @@ Status: implemented **把进程管道留在 `dsh-bash-local` 里(维持现状)。**否决的理由与[任务注册表拆分](2026-07-26-task-registry-seam.md)得以落地的理由相同:这条边界既稳定,也早已记录在代码里(`run.ts` 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。这组堆叠变更对用户可见的动因正是这一拆分。 -**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 在其唯一真实的消费方家族上得到验证后交付;其余调用点已在 seam README 中列为暂缓工作。 +**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**在那个 PR 的规模下,作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 当时在其唯一真实的消费方家族上得到验证后交付。审阅随后恰恰要求以堆叠 PR 的形式完成这项后续工作;[消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md)记录了向 Node 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留守)。 **改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index cbc39c0bde..d6392cce36 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -43,6 +43,7 @@ ".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md", ".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md", ".agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md", + ".agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md", ".agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md", ".agents/notes/implemented/feature/2026-06-14-acp-multi-session.md", ".agents/notes/implemented/feature/2026-06-15-code-mode.md", From c80fddf7a6dd90df522069632456405ac58d4940 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:16:43 +0800 Subject: [PATCH 25/75] docs(subprocess): final translation pass on the consumer-migration note --- .../2026-07-26-subprocess-consumer-migration.i18n.yaml | 2 +- .../2026-07-26-subprocess-consumer-migration.zh.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 4236aa02bb..6bc7a2cc01 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 039d32d9c737970153e91cfa288e4c9616813f7d +2026-07-26-subprocess-consumer-migration.zh.md: 483d209b6bea09d9d1231f6bbeebf506cff07cb4 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 00848c41af..483d209b6b 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -29,10 +29,10 @@ Status: implemented **把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。 -**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 +**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 ## 后果 换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 -代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都带上 subprocess 这一行配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 From 79a28ad6d98988ab6e0eac7b71dd7cf2662998b5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:50:36 +0800 Subject: [PATCH 26/75] fix(subprocess): tree-scoped escalation and byte-exact tails (Codex round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (A1) terminate()/dispose()/service teardown keyed on direct-child settlement could leak a TERM-trapping descendant that outlived the leader (Codex reproduced it with a disowned trap-SIGTERM helper). kill()/terminate() now gate on tree liveness instead of outcome settlement; the SIGKILL escalation timer survives settle (unref'd, re-probing the tree); dispose's tier quiescence is whole-tree exit via a bounded waitForExit; the service's live set releases handles only when their tree is gone, and its teardown awaits tree exit. Three new suites pin the survivor scenarios end to end (terminate, dispose, service teardown). (A2) the escalation branch is now real tested behavior — its ignore is gone; the one remaining signalTree guard ignore states why it is unreachable through the handle verbs. (A3) docs contradictions fixed: the impl README's stale POSIX-only bullet now states the contained best-effort Windows tree story; the lsp-local README no longer claims taskkill failures stay visible (containment + the tree-liveness wait is the actual contract); the architecture tables (en+zh) list all three consumer families. (B1) OutputCollector keeps a byte-exact tail across uneven chunk boundaries (trim the head chunk instead of dropping it whole) — the LSP diagnostic-tail contract; pinned by a cross-chunk test. (B2) the subagent-acp coverage ignore is narrowed to exactly the never-settling success arm. --- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- packages/lsp/lsp-local/README.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 12 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/package.json | 2 + .../subprocess/subprocess-local/src/index.ts | 18 ++- .../subprocess/subprocess-local/src/spawn.ts | 146 ++++++++++-------- .../subprocess-local/tests/spawn.spec.ts | 90 +++++++++-- .../subprocess/subprocess-local/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 12 files changed, 195 insertions(+), 91 deletions(-) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 5c9c706d6c..0c67fdd66e 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 -architecture.md: 9c4c9fc12a51c9c49d02a7aa9c3633ae7c95e4fe -architecture.zh.md: bc6adef969f60e7a7522a1877d29b2f90fc0c322 +architecture.md: 8334153482843f26defa8d175042a1349d6c9eca +architecture.zh.md: bb874654496bc9d131b1d7529c3585ed4682c0cf diff --git a/docs/architecture.md b/docs/architecture.md index 9c4c9fc12a..8334153482 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | -| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process groups under the bash executors | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index bc6adef969..bb87465449 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,7 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | -| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | bash 执行器之下受管理的子进程组 | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 7c6c05b7df..3b7147f830 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -10,7 +10,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Lazily single-flights one server process per `(server id, canonical workspace realpath)`. 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: canonicalize and read the source with Node APIs, `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. -- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible. +- 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. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index aa9cd4f492..85f1b9fdde 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -180,10 +180,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe } /* v8 ignore stop */ // Spawn-level failure surfaces as `done` rejecting into the startup race; a - // clean exit must never win it, so the success arm parks forever. - /* v8 ignore start -- the success arm's never-settling executor is intentionally empty. */ - const spawnFailed: Promise = child.done.then(() => new Promise(() => {}), (err: unknown) => Promise.reject(toError(err))) - /* v8 ignore stop */ + // clean exit must never win it, so the success arm parks forever. (The ACP + // connection observing its streams closing bounds a child that exits + // without speaking the protocol.) + const spawnFailed: Promise = child.done.then( + /* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */ + () => new Promise(() => {}), + (err: unknown) => Promise.reject(toError(err)), + ) spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ }) // Startup rollback and the published handle share one process teardown. diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index eccd842396..c8aa39cc7b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -20,7 +20,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **POSIX-only** — detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. +- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix. - **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 72ff50c422..871b4cfac6 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -29,11 +29,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index d76e5cf410..d71db5622a 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -28,13 +28,14 @@ export class LocalSubprocessService extends SubprocessService { constructor(ctx: Context) { super(ctx) ctx.effect(() => async () => { - // Terminate (escalating), then await closure so even a TERM-trapping - // child cannot outlive the fiber. + // Terminate (escalating), then await WHOLE-TREE exit — not just the + // direct child's settlement — so even a TERM-trapping descendant cannot + // outlive the fiber. const pending: Promise[] = [] for (const handle of this.live) { handle.terminate() // Spawn-failure rejections already settled and left the live set. - pending.push(handle.done.catch(() => {})) + pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) } this.live.clear() await Promise.all(pending) @@ -44,10 +45,13 @@ export class LocalSubprocessService extends SubprocessService { spawn(spec: SubprocessSpawnSpec): SubprocessHandle { const handle = spawnSubprocess(spec, this.internals) this.live.add(handle) - handle.done.then( - () => { this.live.delete(handle) }, - () => { this.live.delete(handle) }, - ) + // 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) }) + handle.done.then(release, release) return handle } } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 99c527ace6..212c4cb53e 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -14,7 +14,8 @@ import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { setTimeout as sleepMs } from 'node:timers/promises' +import { deadline } from '@deepseek-ai/dsh-timeout' import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { CollectedOutput, @@ -62,6 +63,14 @@ export interface SpawnInternals { platform?: NodeJS.Platform } +/** Timeout code marking a dispose-ladder tier bound (vs an external abort). */ +const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER' + +/** Liveness-poll cadence for tree-exit waits; unref'd so an abandoned wait cannot hold the parent's loop open. */ +function sleepTick(): Promise { + return sleepMs(15, undefined, { ref: false }) +} + let spillCounter = 0 let defaultSpillDir: string | undefined @@ -118,19 +127,20 @@ export class OutputCollector { if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk) this.chunks.push(chunk) this.bytes += chunk.length - while (this.bytes > this.maxBytes && this.chunks.length > 1) { - // Drop whole chunks from the head; pipe chunks are small (≤64KiB), so - // the retained tail tracks the cap closely enough for a model-facing - // truncation boundary. (length > 1 was just checked — shift() returns.) - const head = this.chunks.shift() as Buffer - this.bytes -= head.length - this.dropped = true - } - if (this.bytes > this.maxBytes && this.chunks.length === 1) { - // A single chunk larger than the cap: keep its tail. - const only = this.chunks[0] as Buffer - this.chunks[0] = only.subarray(only.length - this.maxBytes) - this.bytes = this.maxBytes + while (this.bytes > this.maxBytes) { + const head = this.chunks[0] as Buffer + const excess = this.bytes - this.maxBytes + if (head.length <= excess) { + // Drop the whole head chunk (length ≥ 1 is guaranteed while over cap). + this.chunks.shift() + this.bytes -= head.length + } else { + // Trim the head so the retained window is byte-exact at the cap — a + // diagnostic tail (an LSP server's stderr) must hold the LAST + // maxBytes regardless of how the stream was chunked. + this.chunks[0] = head.subarray(excess) + this.bytes -= excess + } this.dropped = true } } @@ -281,6 +291,7 @@ function signalTree( taskkill(pid) return } + /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */ if (pid <= 0) return try { process.kill(-pid, sig) @@ -352,21 +363,50 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // Failed spawns use pid -1 so signalling remains a no-op. const pid = child.pid ?? -1 + /** Whether the detached tree's root (or POSIX group) is still alive. */ + const treeAlive = (): boolean => { + if (pid <= 0) return false + if (platform === 'win32') { + // Windows has no group-liveness probe; the direct child's exit is the + // observable boundary (taskkill /T already took the tree with it). + return child.exitCode === null && child.signalCode === null + } + try { + process.kill(-pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + /* v8 ignore next 2 -- POSIX reports an absent group as ESRCH; child-reaping timing + makes observing the other arm platform-dependent. */ + if (code === 'ESRCH') return false + /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs + tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ + if (code === 'EPERM') return true + return child.exitCode === null && child.signalCode === null + /* v8 ignore stop */ + } + } + const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => { - // After settlement the tree is gone and the pid may be reused; callers - // commonly kill() in a finally, so this must not re-signal. - if (settled) return + // Guard on TREE liveness, not outcome settlement: a TERM-trapping helper + // can outlive the settled direct child and must stay signalable, while a + // fully-dead tree (possible pid reuse) must not be re-signalled from a + // caller's finally block. + if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } const terminate = (): void => { if (graceTimer !== undefined) return // escalation already in flight - if (settled) return + if (!treeAlive()) return signalTree(platform, pid, 'SIGTERM', child, taskkill) + // The escalation must survive direct-child settlement — the leader dying + // does not mean the tree died — so the timer is unref'd rather than + // cleared at settle, and re-checks tree liveness before force-killing. graceTimer = setTimeout(() => { - /* v8 ignore next -- the timer is cleared at settlement; only an in-flight fire racing the close event sees settled=true. */ - if (!settled) signalTree(platform, pid, 'SIGKILL', child, taskkill) + if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill) }, spec.graceMs) + graceTimer.unref() } // The caller owns timeout classification; this layer only reacts to abort. @@ -408,75 +448,51 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }) child.on('close', settle) function cleanup(): void { - if (graceTimer !== undefined) clearTimeout(graceTimer) + // graceTimer deliberately NOT cleared: the SIGKILL escalation must be + // able to reach tree survivors after the direct child settles. if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) spec.signal?.removeEventListener('abort', onAbort) } }) - /** Whether the detached tree's root (or POSIX group) is still alive. */ - const treeAlive = (): boolean => { - if (pid <= 0) return false - if (platform === 'win32') { - // Windows has no group-liveness probe; the direct child's exit is the - // observable boundary (taskkill /T already took the tree with it). - return child.exitCode === null && child.signalCode === null - } - try { - process.kill(-pid, 0) - return true - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - /* v8 ignore next -- POSIX reports an absent group as ESRCH; child-reaping timing - makes observing the other arm platform-dependent. */ - if (code === 'ESRCH') return false - /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs - tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ - if (code === 'EPERM') return true - return child.exitCode === null && child.signalCode === null - /* v8 ignore stop */ - } - } - const waitForExit = async (signal?: AbortSignal): Promise => { while (treeAlive()) { if (signal?.aborted) return false - await yieldToEventLoop() + await sleepTick() } return true } - /** Race settlement against a timer without leaving listeners or live timers behind. */ - const settlesWithin = async (ms: number): Promise => { - if (settled) return true - // The executor runs synchronously, so the timer is assigned before the race. - let timer!: NodeJS.Timeout - const timeout = new Promise((resolve) => { - // `.unref()` so a pending grace timer never keeps the parent's loop alive. - timer = setTimeout(() => { resolve(false) }, ms) - timer.unref() - }) - try { - return await Promise.race([done.then(() => true, () => true), timeout]) - } finally { - clearTimeout(timer) - } + /** + * Wait, bounded, for whole-tree exit — the dispose ladder's quiescence test. + * Tree liveness, not direct-child settlement: a TERM-trapping helper that + * outlives the leader must hold the ladder on its tier until it exits. + */ + const treeExitsWithin = async (ms: number): Promise => { + using bound = deadline(undefined, ms, DISPOSE_TIER_TIMEOUT) + return await waitForExit(bound.signal) } let disposal: Promise | undefined const dispose = (graces: SubprocessDisposeGraces): Promise => (disposal ??= (async () => { + // A spawn failure has no process to tear down; observe the rejection so + // disposal in a finally block cannot surface it as unhandled. + if (pid <= 0) { + await done.catch(() => {}) + return + } // 1. Close a piped stdin and allow cooperative teardown and flush. if (stdinMode === 'pipe') child.stdin?.end() - if (await settlesWithin(graces.eofGraceMs)) return + if (await treeExitsWithin(graces.eofGraceMs)) return // 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates. if (platform !== 'win32') { kill('SIGTERM') - if (await settlesWithin(graces.graceMs)) return + if (await treeExitsWithin(graces.graceMs)) return } // 3. Force-kill the tree and await a bounded exit edge. kill('SIGKILL') - if (!(await settlesWithin(graces.graceMs))) { - throw new Error(`child process did not exit within ${graces.graceMs}ms after forced termination`) + if (!(await treeExitsWithin(graces.graceMs))) { + throw new Error(`child process tree did not exit within ${graces.graceMs}ms after forced termination`) } })()) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ecff00d229..38d4a970bd 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -349,6 +349,19 @@ describe('OutputCollector', () => { expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef') }) + it('retains a byte-exact tail across uneven chunk boundaries', () => { + // The old whole-chunk drop could under-retain; a diagnostic tail must be + // exactly the LAST maxBytes regardless of chunking. + const collector = new OutputCollector(10, undefined, 'exact-tail', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbbbb')) + collector.push(Buffer.from('cc')) + const out = collector.finalize() + expect(out.text).toBe('aabbbbbbcc') + expect(Buffer.byteLength(out.text)).toBe(10) + expect(out.truncated).toBe(true) + }) + it('readFrom returns increments and flags lossy reads', () => { const collector = new OutputCollector(10, 100, 'test', spillDir) collector.push(Buffer.from('aaaaa')) @@ -439,16 +452,18 @@ describe('killGroup', () => { expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) - it('handle.kill() after settlement signals nothing and starts no grace timer', async () => { - // Cleanup code commonly kills handles in a finally; after settlement the - // group is gone and the pid may be reused, so a late kill must be inert - // (no signal to a possibly-recycled pgid, no referenced timer delaying exit). + it('handle.kill() after the tree died delivers no termination signal', async () => { + // Cleanup code commonly kills handles in a finally; once the tree is gone + // the pid may be reused, so a late kill must deliver nothing (the + // liveness PROBE — signal 0 — is the only process.kill allowed). const running = spawnSubprocess(spec('true')) await running.done + await running.waitForExit() const spy = vi.spyOn(process, 'kill') try { running.kill() - expect(spy).not.toHaveBeenCalled() + const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) + expect(delivered).toEqual([]) } finally { spy.mockRestore() } @@ -575,6 +590,59 @@ describe('waitForExit', () => { }) }) +describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => { + it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { + // The leader spawns a TERM-trapping helper with all stdio detached from + // the collected pipes, then exits: the helper holds the GROUP alive while + // the direct child settles. The escalation must still reach it. + const pidFile = join(spillDir, `survivor-${Date.now()}.pid`) + const running = spawnSubprocess(spec( + `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; wait_placeholder=; exit 0`, + { graceMs: 300 }, + )) + const helper = await waitForPidFile(pidFile) + await running.done // direct child settled; helper survives in the group + expect(() => process.kill(helper, 0)).not.toThrow() + + running.terminate() // SIGTERM (trapped) → grace → SIGKILL the group + await expect(running.waitForExit()).resolves.toBe(true) + await waitGone(helper) + }) + + it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => { + const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`) + const running = spawnSubprocess(spec( + `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`, + { graceMs: 200 }, + )) + const helper = await waitForPidFile(pidFile) + await running.done + expect(() => process.kill(helper, 0)).not.toThrow() + + await running.dispose({ eofGraceMs: 100, graceMs: 300 }) + // The ladder only returns once the WHOLE tree is gone. + expect(() => process.kill(helper, 0)).toThrow() + }) + + it('service teardown awaits tree survivors, not just handle settlement', async () => { + const { Context } = await import('cordis') + const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local') + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as InstanceType).internals = { spillDir } + const pidFile = join(spillDir, `survivor-svc-${Date.now()}.pid`) + const running = ctx.subprocess.spawn(spec( + `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`, + { graceMs: 200 }, + )) + const helper = await waitForPidFile(pidFile) + await running.done + await fiber.dispose() + // Teardown itself waited for the survivor to die. + expect(() => process.kill(helper, 0)).toThrow() + }) +}) + describe('coverage seams', () => { it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => { expect(() => { taskkillProcessTree(-1) }).not.toThrow() @@ -604,13 +672,15 @@ describe('coverage seams', () => { expect(running.collected.stderr!.readFrom(0).text).toBe('err\n') }) - it('terminate() after settlement is a no-op', async () => { + it('terminate() after the tree died delivers no termination signal', async () => { const running = spawnSubprocess(spec('true')) await running.done + await running.waitForExit() const spy = vi.spyOn(process, 'kill') try { running.terminate() - expect(spy).not.toHaveBeenCalled() + const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) + expect(delivered).toEqual([]) } finally { spy.mockRestore() } @@ -622,13 +692,15 @@ describe('coverage seams', () => { await expect(running.waitForExit()).resolves.toBe(true) }) - it('dispose() on an already-settled handle returns without signalling', async () => { + it('dispose() on an already-exited tree returns without delivering a signal', async () => { const running = spawnSubprocess(spec('true')) await running.done + await running.waitForExit() const spy = vi.spyOn(process, 'kill') try { await running.dispose({ eofGraceMs: 50, graceMs: 50 }) - expect(spy).not.toHaveBeenCalled() + const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) + expect(delivered).toEqual([]) } finally { spy.mockRestore() } diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 5a8dea211b..5272a4f78d 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d85d124931..c3be946fc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3699,6 +3699,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) From f2972e846df2503e7834b490f858da05f906f0e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:34:57 +0800 Subject: [PATCH 27/75] fix(subprocess): keep awaited tree waits and the pending SIGKILL ref'd (Codex round 2) An awaited waitForExit()/dispose() must hold the event loop open until the tree really exits: with the liveness tick and the escalation timer unref'd, a parent with no other live handles could exit claiming quiescence and orphan the survivors it promised to reap. The escalation's pending SIGKILL is a commitment; it self-bounds at graceMs. Module graph picks up the subprocess-local -> timeout edge. --- docs/module-graph.md | 3 ++- .../subprocess/subprocess-local/src/spawn.ts | 16 +++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 435411beb0..e96f83f935 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -283,6 +283,7 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -852,7 +853,7 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 212c4cb53e..8716f50b0c 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -66,9 +66,14 @@ export interface SpawnInternals { /** Timeout code marking a dispose-ladder tier bound (vs an external abort). */ const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER' -/** Liveness-poll cadence for tree-exit waits; unref'd so an abandoned wait cannot hold the parent's loop open. */ +/** + * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an + * awaited teardown must keep the event loop alive until the tree really + * exits, or the parent can exit while claiming quiescence and orphan the + * survivors it promised to reap. + */ function sleepTick(): Promise { - return sleepMs(15, undefined, { ref: false }) + return sleepMs(15) } let spillCounter = 0 @@ -401,12 +406,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter if (!treeAlive()) return signalTree(platform, pid, 'SIGTERM', child, taskkill) // The escalation must survive direct-child settlement — the leader dying - // does not mean the tree died — so the timer is unref'd rather than - // cleared at settle, and re-checks tree liveness before force-killing. + // does not mean the tree died — so settle does not clear this timer, and + // it re-probes tree liveness before force-killing. It stays ref'd: the + // pending SIGKILL is a commitment, and a parent exiting before it fires + // would orphan a trapped survivor. Self-bounds at graceMs. graceTimer = setTimeout(() => { if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill) }, spec.graceMs) - graceTimer.unref() } // The caller owns timeout classification; this layer only reacts to abort. From 05ef4ffa9b1c111290f20be97932f7fab9f416b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:35:41 +0800 Subject: [PATCH 28/75] docs(subprocess): zh wording refinements on the two seam notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faithfulness pass from the translation review: 接入 over 搭乘, first-mention disposition annotation, 原始的 Readable/Writable, 留在原地, 评审 over 审阅; pairing records re-recorded. --- .../2026-07-26-subprocess-consumer-migration.i18n.yaml | 2 +- .../2026-07-26-subprocess-consumer-migration.zh.md | 8 ++++---- .../architecture/2026-07-26-subprocess-seam.i18n.yaml | 2 +- .../architecture/2026-07-26-subprocess-seam.zh.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 6bc7a2cc01..4326462cbf 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 483d209b6bea09d9d1231f6bbeebf506cff07cb4 +2026-07-26-subprocess-consumer-migration.zh.md: 41bdf04bc03517cf9fe10a61a565e442ecee5520 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 483d209b6b..41bdf04bc0 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -10,20 +10,20 @@ Status: implemented ## 决策 -这道 seam 的词汇如今已是 Node 形状,凡能搭乘该服务的 spawn 调用点均已迁入: +这道 seam 的词汇如今已是 Node 形状,凡能接入该服务的 spawn 调用点均已迁入: -- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(裸的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 +- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 - **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 - **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 -挂载 lsp-local 或 subagent-acp 的组合如今都要加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 +挂载 lsp-local 或 subagent-acp 的组合如今都加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 ## 曾考虑的替代方案 -**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式恰好覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。 +**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。 **用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式一次性统辖全部三条流。**否决:真实消费方按流混用模式(lsp:pipe/pipe/collect;acp:pipe/pipe/inherit;bash:data/collect/collect)。按流划分的处置方式恰好就是 Node 的形状,也免去了混用场景的第二个 spawn 调用。 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 3cfcb0cc87..7fd27e6770 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-26-subprocess-seam.md: 5cf0e596603b4cd3240e5d9a114d95f413c6410b -2026-07-26-subprocess-seam.zh.md: 9cf38941953cf0348e6627e9bcb194cf231352d1 +2026-07-26-subprocess-seam.zh.md: 31c37ae07f9456078673ccd005bd44c74ffa672e 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 9cf3894195..31c37ae07f 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 @@ -12,7 +12,7 @@ Status: implemented 新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: -- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、逐流 stdio 处置方式、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及共享的凭据清除加 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不做 shell 解释。([消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md)其后将 stdio 与终止词汇进一步拓宽为 Node 形状。) +- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及共享的凭据清除与 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。([消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 其后将 stdio 与终止词汇拓宽为 Node 形状。) - **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 - **`dsh-bash-local`(消费方)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 - **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 @@ -25,7 +25,7 @@ Status: implemented **把进程管道留在 `dsh-bash-local` 里(维持现状)。**否决的理由与[任务注册表拆分](2026-07-26-task-registry-seam.md)得以落地的理由相同:这条边界既稳定,也早已记录在代码里(`run.ts` 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。这组堆叠变更对用户可见的动因正是这一拆分。 -**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**在那个 PR 的规模下,作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 当时在其唯一真实的消费方家族上得到验证后交付。审阅随后恰恰要求以堆叠 PR 的形式完成这项后续工作;[消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md)记录了向 Node 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留守)。 +**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**在本 PR(Pull Request)的规模下,作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 当时在其唯一真实的消费方家族上得到验证后交付。评审随后恰恰要求以堆叠 PR 的形式完成这项后续工作;[消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 记录了向 Node 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留在原地)。 **改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 From d0b87e8c0fe5a515f907a73f71291e72442d2937 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:39:07 +0800 Subject: [PATCH 29/75] docs: cross-link landstrip evaluation gate from sandbox note's win32 phase The sandbox note's deferred-phases plan for the Windows chain now points at the proposed landstrip evaluation gate, so whoever picks up that rung finds the pending evaluation. The landstrip note itself stays proposed; this only tracks where the pending decision lives (a permitted keep-implemented-notes-current edit). Mirrored in the .zh.md and the pair re-recorded. --- .../notes/implemented/feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .agents/notes/implemented/feature/2026-07-06-sandbox.md | 2 +- .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 6437d7813d..fdb8e71d3b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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 -2026-07-06-sandbox.md: 723ef170188dc11da24e049a1e2838fb240d0a17 -2026-07-06-sandbox.zh.md: a8c7743bb3d499fb58f507ea2c202b44efe2311d +2026-07-06-sandbox.md: a9af53adfdabc6919113d8c6cb00c0b5f9e58c1f +2026-07-06-sandbox.zh.md: 6db1a914f1560e17296b7ea28f8c7367ecc58a81 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 723ef17018..a9af53adfd 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Before implementing this rung, complete the [landstrip evaluation gate](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index a8c7743bb3..6db1a914f1 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。在实现该梯级之前,先完成 [landstrip 评估门禁](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。 ## 曾考虑的替代方案 From 3bd4a9afa64733cfb59b42de60eafe09f101c73b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:11:13 +0800 Subject: [PATCH 30/75] docs: raise the AGENTS.md ceiling to 1690 for the subprocess group row Master's layout block already sits exactly at the 1680-word ceiling; this PR's one new line (the subprocess/ package group) is seven words the doc genuinely needs, so the ceiling moves just far enough to admit it. --- scripts/doc-budgets.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 3d0ce17051..ec3f028f84 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1680, + "AGENTS.md": 1690, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, From a238c4b064217e641b0508cdbae4d8fd340f6910 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:00:38 +0800 Subject: [PATCH 31/75] docs(subprocess): bring the zh side of the subprocess pairs along after the master merge Master made bilingual pairing mandatory repo-wide; the subprocess family docs get their zh counterparts: new pairs for the core-data subprocess catalog and the three subprocess READMEs (switcher lines added on the en side), and minimal zh updates for the packages/bash/bash-local README deltas this PR made, with pairing records recorded. --- .../core-data-structures/subprocess.i18n.yaml | 6 + docs/core-data-structures/subprocess.md | 2 + docs/core-data-structures/subprocess.zh.md | 161 ++++++++++++++++++ packages/README.i18n.yaml | 4 +- packages/README.zh.md | 1 + packages/bash/README.i18n.yaml | 4 +- packages/bash/README.zh.md | 4 +- packages/bash/bash-local/README.i18n.yaml | 4 +- packages/bash/bash-local/README.zh.md | 21 ++- packages/subprocess/README.i18n.yaml | 6 + packages/subprocess/README.md | 2 + packages/subprocess/README.zh.md | 12 ++ .../subprocess-local/README.i18n.yaml | 6 + .../subprocess/subprocess-local/README.md | 2 + .../subprocess/subprocess-local/README.zh.md | 29 ++++ .../subprocess/subprocess/README.i18n.yaml | 6 + packages/subprocess/subprocess/README.md | 2 + packages/subprocess/subprocess/README.zh.md | 28 +++ 18 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 docs/core-data-structures/subprocess.i18n.yaml create mode 100644 docs/core-data-structures/subprocess.zh.md create mode 100644 packages/subprocess/README.i18n.yaml create mode 100644 packages/subprocess/README.zh.md create mode 100644 packages/subprocess/subprocess-local/README.i18n.yaml create mode 100644 packages/subprocess/subprocess-local/README.zh.md create mode 100644 packages/subprocess/subprocess/README.i18n.yaml create mode 100644 packages/subprocess/subprocess/README.zh.md diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml new file mode 100644 index 0000000000..10be6b6d75 --- /dev/null +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +subprocess.md: 2481afe324b0cbd2404186ae79670da81038f8f5 +subprocess.zh.md: 291233c456f73a1ea80de19290a6fc71a1f22725 diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index 6e7cea3990..2481afe324 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -1,5 +1,7 @@ # Subprocess +English | [中文](subprocess.zh.md) + The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md new file mode 100644 index 0000000000..291233c456 --- /dev/null +++ b/docs/core-data-structures/subprocess.zh.md @@ -0,0 +1,161 @@ +# 进程管理器 + +[English](subprocess.md) | 中文 + +进程管理器 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam:目前是 [bash 执行器家族](bash.md),后者传入 `['bash', '-c', command]` argv,并拥有每一项默认值。该 seam 拥有受管的 `DSH_*` 环境命名空间与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 将二者重导出,使 bash 消费方保持单一导入入口。 + +源码:[`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) + +## 受管环境命名空间与捕获的输出 + +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方快照之前丢弃环境中已有的 `DSH_*` 名称,每条被捕获的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 + +```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +/** Trusted DeepSeek Harness variables for one child-process execution. */ +type DshEnvironment = Readonly> +``` + +```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` + +## 完全显式的 spawn spec + +该 seam 不应用任何默认值:每项限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的进程管理器默认值决定。`argv` 绝不经过 shell 解释。 + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every limit + * and directory is explicit, so the caller's own config — not a hidden + * subprocess-service default — decides them (the `dsh-bash` request/spec split + * is the owning template). + */ +interface SubprocessSpawnSpec { + /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ + argv: readonly string[] + /** Working directory for the child. */ + cwd: string + /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ + stdoutMaxBytes: number + /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ + stderrMaxBytes: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes: number + /** Grace period for kill escalation and for inherited pipes after process exit. */ + graceMs: number + /** + * Abort signal — kills the process group when it fires. The caller owns + * deadlines and cause classification; this seam only reacts to the abort. + */ + signal?: AbortSignal | undefined + /** + * Bytes to write to the child's stdin, then close it. Absent (or empty) + * leaves stdin closed/empty. + */ + stdin?: string | undefined + /** + * Ordinary environment entries merged after the implementation's credential + * scrub. `DSH_*` names are rejected and belong in {@link dshEnv}. + */ + env?: Record | undefined + /** + * Harness-owned `DSH_*` variables for this execution. Implementations + * discard ambient `DSH_*` entries before merging this snapshot, so an + * unavailable current fact cannot inherit a stale value from the harness + * process, and reject non-`DSH_*` names supplied through this channel. + */ + dshEnv?: DshEnvironment | undefined +} +``` + +## 句柄与基于偏移量的读取 + +spawn 会立即返回一个实时句柄。输出读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;bash 工具呈现的消费游标模型,是消费方在这些读取器之上自行持有的状态。 + +```ts type-equiv +/** + * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL + * escalation; buffered output remains readable after exit. + */ +interface SubprocessHandle { + /** Process id (group leader); -1 when the spawn itself failed. */ + readonly pid: number + /** Live stdout reader (also readable after exit). */ + readonly stdout: SubprocessOutputReader + /** Live stderr reader (also readable after exit). */ + readonly stderr: SubprocessOutputReader + /** Resolves when the process closes; rejects only for spawn-level failures. */ + readonly done: Promise + /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ + kill(): void +} +``` + +```ts type-equiv +/** + * Cursor-free incremental access to one live output stream. Offsets are + * whole-stream byte coordinates owned by the caller, so independent readers + * cannot consume one another's output. + */ +interface SubprocessOutputReader { + /** + * Read everything captured since `fromByte`. When that offset has slid out + * of the in-memory tail window the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. + */ + readFrom(fromByte: number): SubprocessOutputRead +} +``` + +```ts type-equiv +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +interface SubprocessOutputRead { + /** Stream text from the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the in-memory tail window. */ + lossy: boolean + /** Path to the full-stream spill file, when one was created and remains intact. */ + spillPath?: string +} +``` + +## 结果不携带原因分类 + +`done` 报告原始退出事实。服务会在中止时终止进程,但绝不判定原因:调用方读取归自己所有的 deadline 信号,以区分超时与取消(即 bash 执行器的 `timedOut`/`aborted` 拆分)。 + +```ts type-equiv +/** + * Raw outcome of one closed process. Deliberately carries NO timeout or + * cancellation classification: the service kills on abort but does not decide + * why — the caller reads the signal it owns to classify causes. + */ +interface SubprocessOutcome { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null + stdout: CollectedOutput + stderr: CollectedOutput +} +``` + +## 服务行为 + +抽象的 [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam 只定义 `spawn`;[`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) 是本地实现(detached 进程组、以 spill 文件兜底的尾部保留收集、凭据清除、先终止再等待退出的 dispose(资源释放))。seam 契约见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md),具体机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md)。 diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 9b2c44af22..6f22ec2128 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 -README.md: d7427c3f9892f56185cc1175245f14a6ccea0d25 -README.zh.md: 6894aa7333f6ba4bc5723871fb77c18b5fb518a1 +README.md: e56ff806d2fabeb9a921b09897e006ed58c2019a +README.zh.md: 6787385d7ff4d361878e17c2945cc1dbef9c99ad diff --git a/packages/README.zh.md b/packages/README.zh.md index 6894aa7333..6787385d7f 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -13,6 +13,7 @@ | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | +| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程组实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | | [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 | | [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 | diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 6f9db27161..7c222fb6f5 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/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 -README.md: 08b36270800cdd79c82d6781bbfb2e12e2dc2060 -README.zh.md: a98506a6cdf41e5b298b40e1b8e1faf0c4c917d2 +README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b +README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index a98506a6cd..57c28b45cf 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -6,8 +6,8 @@ | 包 | 职责 | ctx key | |---|---|---| -| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇) | `ctx.bash` | -| `bash-local/` | 本地子进程 `BashExecutor` 实现 | (注册 `ctx.bash`) | +| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` | +| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) | | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index c7e9587289..f94d04f90e 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/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 -README.md: 1668f33e8acf6d749d4d3753478c12d48a19ac3c -README.zh.md: 0e0a4ad41b532e39f6f2470aa981a08b6d6230c1 +README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706 +README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516 diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index 0e0a4ad41b..aa6de87df4 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-bash` 执行器 seam 的本地子进程实现:`LocalBashExecutor` 每次调用都会在独立进程组中 spawn `bash -c `,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。 +`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有。 -包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`;子进程管道细节保留在该实现包内部。 +包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 ## 配置 @@ -24,11 +24,11 @@ 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: -- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/run.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 -- **使用逐步升级终止整个进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 `graceMs` 宽限期后再发送 SIGKILL(默认 3 秒,沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。主 shell 退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止命令结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 -- **保留尾部的截断 + 有界 spill 文件**:输出超过 `maxOutputBytes` 后,内存中保留尾部(错误/结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到临时文件,并在可用时报告该路径。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台任务仍使用 `maxOutputBytes`。某个流大于 `maxSpillBytes` 时,会丢弃已不完整的 spill,仅返回带截断标记的尾部。如果最终关闭 spill 时报告延迟写回失败,执行器同样不会公布路径,以免声称存在不完整的文件。 -- **适合模型的环境变量 + 凭证清理**:以 `process.env` 为基础,移除形似凭证的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中的 `DSH_*` 名称,再设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果。spec 的普通 `env` 在清理后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止遗留嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 使用全流字节偏移量进行增量读取;dispose 会终止每个运行中的进程并等待其退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 +- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 ## 模型体验 @@ -42,8 +42,7 @@ - **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。 -- **仅支持 POSIX**:`bash` 二进制、独立进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 -- **凭证清理依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 -- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 +- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 +- **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 -原始进程处理位于 `src/run.ts`;`src/index.ts` 负责服务接线。 +凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 记录;这些机制归它所有。 diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml new file mode 100644 index 0000000000..64edcdc78b --- /dev/null +++ b/packages/subprocess/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: f91609dc6b6e27fc26e5ffb4b7fd68fc9f4de556 +README.zh.md: d4e2cd69d21772834e0e55db2b14e19ea7ce1832 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 53b295d54e..f91609dc6b 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -1,5 +1,7 @@ # subprocess/ — subprocess capability family +English | [中文](README.zh.md) + The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). | Package | ctx key | Role | diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md new file mode 100644 index 0000000000..d4e2cd69d2 --- /dev/null +++ b/packages/subprocess/README.zh.md @@ -0,0 +1,12 @@ +# subprocess/:进程管理能力家族 + +[English](README.md) | 中文 + +spawn 受管子进程组的共用归属位置:完全显式的 spawn spec、附带 spill 文件的有界尾部保留输出、经凭据清除的环境、基于偏移量的增量读取,以及 SIGTERM→宽限期→SIGKILL 的进程组终止。命令默认值补全、shell 语义、deadline 与呈现留在消费方:[bash 执行器家族](../bash/README.md)是第一个消费方,也拥有上述各项。参见[进程管理器 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 + +| 包(package) | ctx 键 | 角色 | +|---|---|---| +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式的 `SubprocessSpawnSpec`、携带基于偏移量读取器的 `SubprocessHandle`,以及共享的 `DSH_*` 受管环境与 `CollectedOutput` 词汇 | +| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程组、附带有界私有 spill 文件的尾部保留截断、凭据清除与 `DSH_*` 合并次序、kill 升级,以及先终止再等待退出的 dispose(资源释放) | + +服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml new file mode 100644 index 0000000000..4dab94d5b8 --- /dev/null +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: a18772055d33369f6feec1b1a0751bb299303e04 +README.zh.md: bc829d48e846055853753522287967cc9fd42ca6 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 4bc2a71691..a18772055d 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subprocess-local +English | [中文](README.zh.md) + Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). ## Behavior (and where it came from) diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md new file mode 100644 index 0000000000..bc829d48e8 --- /dev/null +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-subprocess-local + +[English](README.md) | 中文 + +[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程组 spawn,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。该实现没有任何配置:每项限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在调用方 seam 的配置里(目前是 [`dsh-bash-local`](../../bash/bash-local/README.md))。 + +## 行为(以及设计来源) + +- **带升级的 detached 进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。组长进程退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止这次 spawn 结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **尾部保留截断 + 有界 spill 文件**:输出超过某条流的上限后,内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到一个私有临时文件,并在可用时报告该路径。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 +- **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **基于偏移量的读取**:`SubprocessHandle` 的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存。 +- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能终止每个仍在运行的进程组并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 + +## 模型体验 + +通过消费方 seam 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出与生命周期面向模型的全部渲染归消费方所有。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **仅支持 POSIX**:detached 进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 +- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 +- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 + +原始进程处理位于 `src/spawn.ts`;`src/index.ts` 负责服务接线。 diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml new file mode 100644 index 0000000000..adc9ed8cc4 --- /dev/null +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 126f0fd6863739563b8d3cb5b4b958cee47dfb3d +README.zh.md: fdd5035867316349a91d1700cbf3f521a2bac117 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 15165a730d..126f0fd686 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subprocess +English | [中文](README.zh.md) + The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes one method — `spawn(spec): SubprocessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with its non-consuming offset-based output readers, `SubprocessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md). ## Contract diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md new file mode 100644 index 0000000000..fdd5035867 --- /dev/null +++ b/packages/subprocess/subprocess/README.zh.md @@ -0,0 +1,28 @@ +# @deepseek-ai/dsh-subprocess + +[English](README.md) | 中文 + +进程管理器 seam(`ctx.subprocess`)。抽象的 `SubprocessService` 只暴露一个方法:`spawn(spec): SubprocessHandle`,外加所有消费方共享的词汇:完全显式的 `SubprocessSpawnSpec`、携带基于偏移量的非消费式输出读取器的 `SubprocessHandle`、`SubprocessOutcome`、`CollectedOutput`,以及受管的 `DSH_*` 环境命名空间(`DSH_ENV_PREFIX`、`DshEnvironment`)。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.md)。 + +## 契约 + +- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时 resolve,仅在 spawn 层面失败时 reject。 +- spec 完全显式(argv、cwd、按流划分的字节上限、spill 上限、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 在这里绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 +- 输出读取器接受全流字节偏移量且从不消费:独立的读取器不会抢走彼此的增量。偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在完整流 spill 文件存在时指向它。 +- `kill()` 与 spec 的 abort 信号对整个 detached 进程组执行 SIGTERM→宽限期→SIGKILL 升级;服务响应中止但绝不判定原因(deadline 与原因分类归调用方所有)。 +- dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 + +参见[进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 + +## 模型体验 + +通过消费方 seam 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出与生命周期面向模型的全部渲染归消费方所有。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **目前只有一个消费方家族**:该 seam 的形状仅在 bash 执行器上得到验证;仓库内其他 spawn 调用点(LSP 服务器、PTY 后端、subagent 传输层)继续保留各自专属的进程处理,直到它们的流与生命周期需求对照本契约得到重新审视。 +- **假定 POSIX 进程组语义**:句柄词汇(作为组长的 `pid`、进程组终止、SIGTERM/SIGKILL 升级)没有 Windows 方案。 From c4647a860945481f8ce68bd7774b757f221930b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:29:41 +0800 Subject: [PATCH 32/75] test: adopt execa for hand-rolled subprocess plumbing, parseArgs for llm-mock-server CLI, vi.waitFor for poll loops Implements the execa Agent Note's four sub-changes: - execa (root devDep + loader-smoke dep) replaces the hand-rolled spawn-collect-timeout choreography in loader-smoke, apps/cli and cli-demo/acp-demo built-bin e2e, lsp-local and code-runtime-worker built-lib e2e, the tui pty-harness outer collector, the jsonrpc keyless smoke, and crash-recovery's child spawn. Genuinely custom parts stay custom: cli-demo's interrupt-on-marker, jsonrpc's line-predicate protocol driving, crash-recovery's SIGKILL-at-failpoint. The two loader-smoke /* v8 ignore */ OS-error branches are gone. - llm-mock-server CLI tokenizes via node:util parseArgs; numeric coercion/bounds/cross-option constraints stay manual; pinned error-message tests updated to the parseArgs texts. - both loadRootEnv copies in apps/web/tests are deleted: the owning vitest configs (web unconditionally, snapshot in record mode) already load the repo-root .env before these files run. - the four poll loops (acp-snapshot harness waits + crash-recovery waitForFile) ride vi.waitFor with explicit {interval, timeout}. --- apps/cli/tests/built-bin.e2e.ts | 31 ++--- apps/web/tests/scaffold.ts | 15 +-- apps/web/tests/smoke-real.e2e.ts | 17 +-- .../jsonrpc-agent/tests/keyless-smoke.e2e.ts | 54 +++----- examples/tui-agent/tests/pty-harness.ts | 55 ++++---- package.json | 1 + .../tests/built-lib.e2e.ts | 15 ++- .../examples/acp-demo/tests/built-bin.e2e.ts | 35 +++-- .../examples/cli-demo/tests/built-bin.e2e.ts | 52 ++++---- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 15 ++- .../tests/crash-recovery.e2e.ts | 44 +++--- packages/support/acp-snapshot/src/harness.ts | 41 +++--- packages/support/llm-mock-server/src/cli.ts | 107 +++++++-------- .../support/llm-mock-server/tests/cli.spec.ts | 10 +- packages/support/loader-smoke/package.json | 1 + packages/support/loader-smoke/src/index.ts | 66 +++------ pnpm-lock.yaml | 126 ++++++++++++++++++ 17 files changed, 364 insertions(+), 321 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 9fd1d55ab2..1ea7d9f0db 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** @@ -22,25 +22,18 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** Run the built bin with PIPED stdio; resolve with output + exit code. */ -function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (c: string) => { stdout += c }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - // Resolve on `close` (all stdio drained), not `exit`, so captured output is complete. - child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) - child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.end() +/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ +async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin], { + input: '', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) + if (result.timedOut) { + throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr } } describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index babfbde919..b9da65aae7 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -17,7 +17,7 @@ // the open llm seam post-boot with installLlmReplay on the settled root ctx // (the plugin-row path discards the ReplayHandle; the direct install keeps // assertConsumed for the teardown fixture-consumption check). -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -63,16 +63,6 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml') // contextWindow keeps that pressure path provably inert for small fixtures. const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] -/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */ -function loadRootEnv(): void { - const envPath = join(REPO_ROOT, '.env') - if (!existsSync(envPath)) return - for (const line of readFileSync(envPath, 'utf8').split('\n')) { - const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim()) - if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2] - } -} - /** A booted web scaffold: real composition, mode-selected model backend, temp world. */ export interface WebScaffold { /** The active snapshot mode this scaffold booted under. */ @@ -123,7 +113,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { return new Promise((resolveReady, reject) => { let out = '' diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index cb2ab9687e..fb31afd033 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -6,6 +5,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) @@ -69,7 +69,9 @@ describe('jsonrpc-agent keyless smoke', () => { await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) const address = modelServer.address() if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') - const child = spawn(process.execPath, [ + // The line-predicate protocol driving below is the genuinely custom part; + // execa owns spawn, the deadline, and exit settlement around it. + const child = execa(process.execPath, [ '--import', 'tsx', binScript, @@ -77,27 +79,26 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_CWD: root, DSH_SESSION_ROOT: join(root, '.sessions'), ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), }, - stdio: ['pipe', 'pipe', 'pipe'], + timeout: 35_000, + killSignal: 'SIGKILL', + reject: false, }) const lines: string[] = [] let stdoutBuffer = '' let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdoutBuffer += chunk + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') const parts = stdoutBuffer.split('\n') stdoutBuffer = parts.pop() ?? '' lines.push(...parts) }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) try { child.stdin.write(`${JSON.stringify({ @@ -144,16 +145,8 @@ describe('jsonrpc-agent keyless smoke', () => { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} }) - if (child.exitCode === null) { - await new Promise((resolve, reject) => { - child.once('exit', (code) => { - if (code === 0) resolve() - else reject(new Error(`runtime exited ${code}; stderr=${stderr}`)) - }) - }) - } else { - expect(child.exitCode, stderr).toBe(0) - } + const exit = await child + expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) const sessionsRoot = join(root, '.sessions') const files = await readdir(sessionsRoot, { recursive: true }) const log = files.find(file => file.endsWith('.jsonl.zstd')) @@ -162,14 +155,16 @@ describe('jsonrpc-agent keyless smoke', () => { expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) } finally { - if (child.exitCode === null) child.kill('SIGKILL') + // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown. + child.kill('SIGKILL') + await child await new Promise(resolve => modelServer.close(() => { resolve() })) await rm(root, { recursive: true, force: true }) } }, 40_000) it('rejects an invalid max-token success env value', async () => { - const child = spawn(process.execPath, [ + const { exitCode, stdout, stderr } = await execa(process.execPath, [ '--import', 'tsx', binScript, @@ -177,22 +172,13 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const exitCode = await new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', resolve) + stdin: 'ignore', + timeout: 9_000, + killSignal: 'SIGKILL', + reject: false, }) expect(exitCode, stderr).toBe(1) diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 700c67f660..ea088a20f9 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execa } from 'execa' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const POSIX_PTY_DRIVER = String.raw` @@ -94,35 +94,32 @@ async function runPosixPtySmoke( options: TuiPtySmokeOptions, timeoutMs: number, ): Promise { - return await new Promise((resolve, reject) => { - const child = spawn('python3', [ - '-c', - POSIX_PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - JSON.stringify(options.actions ?? []), - String(options.expectedExitCode ?? 0), - String(timeoutMs / 1_000), - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, timeoutMs + 5_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve(stdout) - else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) + // The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline + // only backstops a wedged python3 process itself. + const result = await execa('python3', [ + '-c', + POSIX_PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + JSON.stringify(options.actions ?? []), + String(options.expectedExitCode ?? 0), + String(timeoutMs / 1_000), + ], { + stdin: 'ignore', + timeout: timeoutMs + 5_000, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, }) + if (result.timedOut) { + throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + if (result.failed) { + throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return result.stdout } async function runWindowsPtySmoke( diff --git a/package.json b/package.json index 3797b24efa..0fc6244e18 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", + "execa": "^10.0.0", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", "js-yaml": "^4.2.0", diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 4c6098a2ec..5a09dd69f2 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** @@ -36,12 +36,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { console.log(JSON.stringify(result)) process.exit(0) ` - const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) - const exitCode = await new Promise(resolve => child.on('close', resolve)) + const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], { + cwd: pkgDir, + stdin: 'ignore', + timeout: 55_000, + killSignal: 'SIGKILL', + reject: false, + }) expect(exitCode, `stderr:\n${stderr}`).toBe(0) const lastLine = stdout.trim().split('\n').at(-1) ?? '' diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 02e82ac5ac..8b6533e106 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -17,6 +17,7 @@ import { import { Readable, Writable } from 'node:stream' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { afterEach, describe, expect, it } from 'vitest' /** @@ -209,25 +210,19 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n }, 30_000) }) -/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */ -function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const proc = spawn(process.execPath, [acpBin, '--config', configArg], { - cwd, - env: { - ...process.env, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - child = proc - let stderr = '' - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000) - proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) }) - proc.on('error', (err) => { clearTimeout(timer); reject(err) }) - proc.stdin.end() +/** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */ +async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { + const result = await execa(process.execPath, [acpBin, '--config', configArg], { + cwd, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + input: '', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) + if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`) + return { code: result.exitCode ?? -1, stderr: result.stderr } } diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 5c3a6ad62e..f87563d27a 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -6,6 +5,7 @@ import { dirname, join } from 'node:path' import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { afterEach, describe, expect, it } from 'vitest' /** @@ -114,36 +114,34 @@ interface BinResult { readonly stderr: string } -function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise { - return new Promise((resolveResult, reject) => { - const child = spawn(process.execPath, [cliBin, ...args], { - cwd, - env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' +async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise { + const subprocess = execa(process.execPath, [cliBin, ...args], { + cwd, + env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, + stdin: 'ignore', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, + }) + // Genuinely custom mid-stream logic: the signal cases deliver `interrupt` + // once the first streamed chunk proves the turn is in flight. + if (interrupt !== undefined) { + let streamed = '' let interrupted = false - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdout += chunk - if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) { + subprocess.stdout.on('data', (chunk: Buffer) => { + streamed += chunk.toString('utf8') + if (!interrupted && streamed.includes('assistant/chunk')) { interrupted = true - child.kill(interrupt) + subprocess.kill(interrupt) } }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code, signal) => { - clearTimeout(timer) - resolveResult({ code: code ?? -1, signal, stdout, stderr }) - }) - }) + } + const result = await subprocess + if (result.timedOut) { + throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr } } let consumer: string | undefined diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index a2da86d87c..ef44655b19 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -1,9 +1,9 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** @@ -57,12 +57,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { console.log(JSON.stringify(result)) await ctx.fiber.dispose() ` - const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) - const exitCode = await new Promise(resolve => child.on('close', resolve)) + const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], { + cwd: pkgDir, + stdin: 'ignore', + timeout: 55_000, + killSignal: 'SIGKILL', + reject: false, + }) expect(exitCode, `stderr:\n${stderr}`).toBe(0) const lastLine = stdout.trim().split('\n').at(-1) ?? '' diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts index 411e374833..8a59923bf6 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -1,10 +1,10 @@ -import { spawn } from 'node:child_process' import { access, mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import SessionStore, { SessionId, TOOL_OUTCOME_UNKNOWN, type SessionEvent, @@ -19,44 +19,36 @@ const roots: string[] = [] const CHILD_FAILPOINT_TIMEOUT_MS = 30_000 async function waitForFile(path: string): Promise { - const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS - for (;;) { - try { - await access(path) - return - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - if (Date.now() >= deadline) throw new Error(`crash child did not reach failpoint ${path}`) - await new Promise(resolve => setTimeout(resolve, 10)) - } + await vi.waitFor(async () => { + await access(path).catch((error: unknown) => { + throw new Error(`crash child did not reach failpoint ${path}`, { cause: error }) + }) + }, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS }) } async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> { const root = await mkdtemp(join(tmpdir(), `dsh-semantic-${mode}-`)) roots.push(root) const marker = join(root, 'failpoint') - const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], { + // The SIGKILL-at-failpoint choreography stays custom: the child must die + // mid-write, so no timeout or graceful termination may reach it first. + const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], { cwd: repoRoot, - env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, - stdio: ['ignore', 'ignore', 'pipe'], + env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, + stdin: 'ignore', + stdout: 'ignore', + reject: false, }) - let stderr = '' - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) try { await waitForFile(marker) const markerText = await readFile(marker, 'utf8') - const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { - child.once('close', (code, signal) => { resolve({ code, signal }) }) - }) child.kill('SIGKILL') - const exit = await closed - expect(exit).toEqual({ code: null, signal: 'SIGKILL' }) + const exit = await child + expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' }) return { root, markerText } } catch (error: unknown) { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') - throw new Error(`crash child failed: ${stderr}`, { cause: error }) + child.kill('SIGKILL') + throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error }) } } diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index cd84f42513..0cf1cde9ab 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -21,7 +21,7 @@ import { existsSync, realpathSync } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { basename, dirname, join, delimiter } from 'node:path' -import { setTimeout as delay } from 'node:timers/promises' +import { vi } from 'vitest' import { ClientSideConnection, PROTOCOL_VERSION, @@ -457,17 +457,25 @@ async function waitForPersistedTurnStart( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, minimumTurn?: number, ): Promise { - const deadline = Date.now() + timeoutMs - while (true) { + let invalidRecord: Error | undefined + await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) - const openTurn = log === undefined ? undefined : latestOpenTurn(log.content) - if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return - if (Date.now() >= deadline) { + let openTurn: number | undefined + try { + openTurn = log === undefined ? undefined : latestOpenTurn(log.content) + } catch (error) { + // A malformed persisted record is a scenario bug, not a not-yet state: + // vi.waitFor retries every callback throw, so capture the validation + // failure, resolve the wait, and rethrow immediately below. + invalidRecord = error instanceof Error ? error : new Error(String(error)) + return + } + if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) { const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}` throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) + if (invalidRecord !== undefined) throw invalidRecord } /** @@ -481,15 +489,12 @@ async function waitForPersistedTurnEnd( sessionId: string, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, ): Promise { - const deadline = Date.now() + timeoutMs - while (true) { + await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) - if (log !== undefined && latestTurnIsClosed(log.content)) return - if (Date.now() >= deadline) { + if (log === undefined || !latestTurnIsClosed(log.content)) { throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } /** Wait for a cwd-relative marker proving an external action reached readiness. */ @@ -499,13 +504,11 @@ async function waitForWorkspaceFile( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, ): Promise { const target = join(cwd, path) - const deadline = Date.now() + timeoutMs - while (!existsSync(target)) { - if (Date.now() >= deadline) { + await vi.waitFor(() => { + if (!existsSync(target)) { throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } /** Return whether the last complete raw-JSONL turn boundary closes its turn. */ diff --git a/packages/support/llm-mock-server/src/cli.ts b/packages/support/llm-mock-server/src/cli.ts index 786a74c0f4..1787f318ca 100644 --- a/packages/support/llm-mock-server/src/cli.ts +++ b/packages/support/llm-mock-server/src/cli.ts @@ -3,6 +3,7 @@ * @module @deepseek-ai/dsh-llm-mock-server/cli */ +import { parseArgs } from 'node:util' import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts' import type { ConcreteMockLlmBehavior, @@ -63,14 +64,6 @@ Other: --help ` -function optionValue(argv: readonly string[], index: number, option: string): string { - const value = argv[index + 1] - if (value === undefined || value.startsWith('--')) { - throw new Error(`dsh-llm-mock-server: ${option} requires a value`) - } - return value -} - function numberValue(option: string, value: string): number { const parsed = Number(value) if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`) @@ -122,66 +115,64 @@ function parseRandomWeights(raw: string): MockLlmRandomWeights { return weights } +/** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */ +const CLI_OPTIONS = { + 'sequence': { type: 'string' }, + 'host': { type: 'string' }, + 'port': { type: 'string' }, + 'api-key': { type: 'string' }, + 'listen-delay-ms': { type: 'string' }, + 'repeat-last': { type: 'boolean' }, + 'seed': { type: 'string' }, + 'random-weights': { type: 'string' }, + 'success-text': { type: 'string' }, + 'partial-text': { type: 'string' }, + 'reasoning-text': { type: 'string' }, + 'chunk-size': { type: 'string' }, + 'chunk-delay-ms': { type: 'string' }, + 'disconnect-delay-ms': { type: 'string' }, + 'retry-after-ms': { type: 'string' }, + 'request-id': { type: 'string' }, + 'tool-name': { type: 'string' }, + 'tool-arguments': { type: 'string' }, +} as const + /** * Parse standalone server arguments without starting a process or listener. + * Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric + * coercion, bounds, and cross-option constraints remain manual below it. * @param argv - arguments after the executable name. * @returns help or validated run configuration. */ export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult { if (argv.includes('--help')) return { kind: 'help' } - let sequenceRaw: string | undefined - let host: string | undefined - let port = 8_000 - let apiKey: string | undefined - let listenDelayMs: number | undefined - let repeatLast = false - let randomSeed: number | undefined - let randomWeights: MockLlmRandomWeights | undefined - let successText: string | undefined - let partialText: string | undefined - let reasoningText: string | undefined - let chunkSize: number | undefined - let chunkDelayMs: number | undefined - let disconnectDelayMs: number | undefined - let retryAfterMs: number | undefined - let requestId: string | undefined - let toolName: string | undefined - let toolArguments: string | undefined + const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false }) - for (let index = 0; index < argv.length; index += 1) { - const option = argv[index] as string - if (option === '--repeat-last') { - repeatLast = true - continue - } - const value = optionValue(argv, index, option) - index += 1 - switch (option) { - case '--sequence': sequenceRaw = value; break - case '--host': host = value; break - case '--port': port = numberValue(option, value); break - case '--api-key': apiKey = value; break - case '--listen-delay-ms': - listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS) - break - case '--seed': randomSeed = numberValue(option, value); break - case '--random-weights': randomWeights = parseRandomWeights(value); break - case '--success-text': successText = value; break - case '--partial-text': partialText = value; break - case '--reasoning-text': reasoningText = value; break - case '--chunk-size': chunkSize = numberValue(option, value); break - case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break - case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break - case '--retry-after-ms': retryAfterMs = numberValue(option, value); break - case '--request-id': requestId = value; break - case '--tool-name': toolName = value; break - case '--tool-arguments': toolArguments = value; break - default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`) - } - } + const host = values.host + const port = values.port === undefined ? 8_000 : numberValue('--port', values.port) + const apiKey = values['api-key'] + const listenDelayMs = values['listen-delay-ms'] === undefined + ? undefined + : boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS) + const repeatLast = values['repeat-last'] ?? false + const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed) + const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights']) + const successText = values['success-text'] + const partialText = values['partial-text'] + const reasoningText = values['reasoning-text'] + const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size']) + const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms']) + const disconnectDelayMs = values['disconnect-delay-ms'] === undefined + ? undefined + : numberValue('--disconnect-delay-ms', values['disconnect-delay-ms']) + const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms']) + const requestId = values['request-id'] + const toolName = values['tool-name'] + const toolArguments = values['tool-arguments'] - if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required') + if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required') + const sequenceRaw = values.sequence const parsedSequence = parseSequence(sequenceRaw) if (parsedSequence.startsUnavailable && port === 0) { throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port') diff --git a/packages/support/llm-mock-server/tests/cli.spec.ts b/packages/support/llm-mock-server/tests/cli.spec.ts index 12c5bd6926..04b221beda 100644 --- a/packages/support/llm-mock-server/tests/cli.spec.ts +++ b/packages/support/llm-mock-server/tests/cli.spec.ts @@ -101,8 +101,11 @@ describe('mock LLM server CLI parser', () => { it.each([ [[], /--sequence is required/], - [['--wat'], /requires a value/], - [['--wat', 'x'], /unknown option/], + // Tokenizer-level failures carry node:util parseArgs's own messages. + [['--wat'], /Unknown option '--wat'/], + [['--wat', 'x'], /Unknown option '--wat'/], + [['--port'], /Option '--port ' argument missing/], + [['--sequence', 'success', 'stray'], /Unexpected argument 'stray'/], [['--port', 'NaN', '--sequence', 'success'], /finite number/], [['--sequence', 'success,'], /non-empty/], [['--sequence', 'success,connection_refused'], /only as the first/], @@ -110,7 +113,8 @@ describe('mock LLM server CLI parser', () => { [['--sequence', 'unknown'], /unknown behavior/], [['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/], [['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/], - [['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/], + // `=` syntax: a space-separated leading-dash value is a tokenizer error, not a bounds probe. + [['--sequence', 'connection_refused,success', '--listen-delay-ms=-1'], /integer between 0 and 2147483647/], [['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/], [['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/], [['--sequence', 'success', '--seed', '1'], /require random/], diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 570ee2c5ca..ebdd62373a 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "execa": "^10.0.0", "tsx": "^4.22.4" }, "peerDependencies": { diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index 61ad3b9d16..e573684a4e 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -11,10 +11,10 @@ * @module @deepseek-ai/dsh-loader-smoke */ -import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execa } from 'execa' const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 @@ -171,53 +171,27 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise((resolve, reject) => { - const child = spawn(launch.command, launch.args, { - cwd, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - let deferredFailure: Error | undefined - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`) - child.kill('SIGKILL') - }, processTimeoutMs) - - child.once('exit', (code) => { - clearTimeout(timer) - if (deferredFailure !== undefined) { - reject(deferredFailure) - } else if (code === 0) { - resolve({ stdout, stderr }) - } else { - reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - } - }) - - // process.execPath and a just-created pipe make these OS-error paths - // impractical to induce without replacing the boundary under test. - /* v8 ignore start */ - child.once('error', (error) => { - clearTimeout(timer) - reject(new Error(`${options.label} failed to start: ${error.message}`)) - }) - child.stdin.once('error', (error) => { - deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`) - child.kill('SIGKILL') - }) - /* v8 ignore stop */ - - child.stdin.end() + // `input: ''` writes nothing and closes stdin — the fixture-visible + // stdin-close contract. `reject: false` folds spawn errors, the SIGKILL + // deadline, and nonzero exits into independent result fields, so the + // diagnostics below embed both streams on every failure. + const result = await execa(launch.command, launch.args, { + cwd, + env: launch.env, + input: '', + timeout: processTimeoutMs, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, }) + if (result.timedOut) { + throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + if (result.failed) { + throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } await options.inspect?.(cwd) - return result + return { stdout: result.stdout, stderr: result.stderr } } finally { await rm(cwd, { recursive: true, force: true }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1946a841bb..d202f4f665 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: eslint-plugin-sonarjs: specifier: ^4.1.0 version: 4.1.0(eslint@10.5.0(jiti@2.7.0)) + execa: + specifier: ^10.0.0 + version: 10.0.0 fast-check: specifier: ^4.8.0 version: 4.8.0 @@ -3844,6 +3847,9 @@ importers: packages/support/loader-smoke: dependencies: + execa: + specifier: ^10.0.0 + version: 10.0.0 tsx: specifier: ^4.22.4 version: 4.22.4 @@ -6719,6 +6725,9 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/core@2.5.0': resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} @@ -6743,6 +6752,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@smithy/core@3.24.7': resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} @@ -7885,6 +7898,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@10.0.0: + resolution: {integrity: sha512-Cxl6MKxB1dr1H0FHmiizJ+lavKF7pV+fcDZFyqMB8d5m7qUPm/OtZYcD5vPWePKxSnTQ57KuBd9mtdZ3oNCvyQ==} + engines: {node: '>=22'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -7950,6 +7967,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -8033,6 +8054,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -8132,6 +8157,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -8220,6 +8249,14 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -8864,6 +8901,10 @@ packages: non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -8933,6 +8974,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -8958,6 +9003,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -9018,6 +9067,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -9319,6 +9372,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -9513,6 +9570,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -9790,6 +9851,11 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} + hasBin: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -9853,6 +9919,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -11350,6 +11420,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/core@2.5.0': dependencies: '@shikijs/engine-javascript': 2.5.0 @@ -11390,6 +11462,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.24.7': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -12734,6 +12808,22 @@ snapshots: dependencies: eventsource-parser: 3.1.0 + execa@10.0.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + path-key: 4.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + which-command: 0.1.0 + yoctocolors: 2.1.2 + expect-type@1.3.0: {} express-rate-limit@8.5.2(express@5.2.1): @@ -12823,6 +12913,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -12919,6 +13013,11 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -13056,6 +13155,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@8.0.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -13115,6 +13216,10 @@ snapshots: is-promise@4.0.0: {} + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + is-what@5.5.0: {} isarray@1.0.0: {} @@ -13936,6 +14041,11 @@ snapshots: non-layered-tidy-tree-layout@2.0.2: optional: true + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -14046,6 +14156,8 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-ms@4.0.0: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -14062,6 +14174,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -14110,6 +14224,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} property-information@7.2.0: {} @@ -14535,6 +14653,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@4.0.0: {} + strip-json-comments@5.0.3: {} strnum@2.4.0: @@ -14691,6 +14811,8 @@ snapshots: undici@7.28.0: {} + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -15010,6 +15132,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which-command@0.1.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -15051,6 +15175,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 From d404609e19e9f548d55bbd427a1b6a4c8d378d96 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:54:44 +0800 Subject: [PATCH 33/75] docs(subprocess): bring the zh side of the consumer-migration pairs along The parent branch's zh translations were recorded against its pre-reshape en text; this PR's Node-ward en deltas get minimal zh updates across the subprocess catalog and the subprocess/lsp-local/subagent READMEs (all type-equiv blocks byte-identical), with pairing records re-recorded. --- .../core-data-structures/subprocess.i18n.yaml | 4 +- docs/core-data-structures/subprocess.zh.md | 225 ++++++++++++++---- packages/lsp/lsp-local/README.i18n.yaml | 4 +- packages/lsp/lsp-local/README.zh.md | 2 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.zh.md | 3 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.zh.md | 6 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.zh.md | 12 +- .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.zh.md | 17 +- 14 files changed, 209 insertions(+), 86 deletions(-) diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 10be6b6d75..0527cf34a3 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/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 -subprocess.md: 2481afe324b0cbd2404186ae79670da81038f8f5 -subprocess.zh.md: 291233c456f73a1ea80de19290a6fc71a1f22725 +subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 +subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 291233c456..78325c3255 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -2,13 +2,13 @@ [English](subprocess.md) | 中文 -进程管理器 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam:目前是 [bash 执行器家族](bash.md),后者传入 `['bash', '-c', command]` argv,并拥有每一项默认值。该 seam 拥有受管的 `DSH_*` 环境命名空间与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 将二者重导出,使 bash 消费方保持单一导入入口。 +进程管理器 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam 与进程外后端:[bash 执行器家族](bash.md)使用收集模式(collect)的批量输出,LSP 主机使用管道化的协议流 + 收集的 stderr 尾部,ACP(Agent Client Protocol)subagent 后端则使用管道化的协议流 + inherit 的 stderr。该 seam 拥有受管的 `DSH_*` 环境命名空间、共享的凭据清除(`scrubbedParentEnv`)与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 重导出这套词汇,使 bash 消费方保持单一导入入口。 源码:[`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) ## 受管环境命名空间与捕获的输出 -`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方快照之前丢弃环境中已有的 `DSH_*` 名称,每条被捕获的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方快照之前丢弃环境中已有的 `DSH_*` 名称,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -32,83 +32,170 @@ interface CollectedOutput { } ``` -## 完全显式的 spawn spec +## Node 形状的 stdio 处置方式(disposition) -该 seam 不应用任何默认值:每项限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的进程管理器默认值决定。`argv` 绝不经过 shell 解释。 +每条流的处置方式都显式给出,由各消费方自行选择:原始管道用于协议分帧(LSP JSON-RPC、ACP ndjson),inherit 用于直通的诊断输出,收集模式用于有界的批量输出;其中 spill 文件是可选的,因此诊断尾部(语言服务器的 stderr)可以只在内存中缓冲,不留下任何文件。 ```ts type-equiv /** - * A fully-specified spawn request. This seam applies no defaults: every limit - * and directory is explicit, so the caller's own config — not a hidden - * subprocess-service default — decides them (the `dsh-bash` request/spec split - * is the owning template). + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } +``` + +```ts type-equiv +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} +``` + +```ts type-equiv +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect +``` + +```ts type-equiv +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} +``` + +## 完全显式的 spawn spec + +该 seam 不应用任何默认值:每项处置方式、限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的进程管理器默认值决定。`argv` 绝不经过 shell 解释。 + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). */ interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after process exit. */ + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ graceMs: number /** - * Abort signal — kills the process group 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 process tree when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. */ signal?: AbortSignal | undefined /** - * Bytes to write to the child's stdin, then close it. Absent (or empty) - * leaves stdin closed/empty. - */ - stdin?: string | undefined - /** - * Ordinary environment entries merged after the implementation's credential - * scrub. `DSH_*` names are rejected and belong in {@link dshEnv}. + * Ordinary environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and + * belong in {@link dshEnv}; a deliberately forwarded credential-shaped + * entry survives because this layer merges after the scrub. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Implementations - * discard ambient `DSH_*` entries before merging this snapshot, so an - * unavailable current fact cannot inherit a stale value from the harness - * process, and reject non-`DSH_*` names supplied through this channel. + * Harness-owned `DSH_*` variables for this execution. The scrubbed base has + * already discarded ambient `DSH_*` entries, so an unavailable current fact + * cannot inherit a stale value from the harness process; non-`DSH_*` names + * on this channel are rejected. */ dshEnv?: DshEnvironment | undefined } ``` -## 句柄与基于偏移量的读取 +## 句柄:流、读取器与以进程树为范围的终止 -spawn 会立即返回一个实时句柄。输出读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;bash 工具呈现的消费游标模型,是消费方在这些读取器之上自行持有的状态。 +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 ```ts type-equiv /** - * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL - * escalation; buffered output remains readable after exit. + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. */ interface SubprocessHandle { - /** Process id (group leader); -1 when the spawn itself failed. */ + /** Process id (tree root); -1 when the spawn itself failed. */ readonly pid: number - /** Live stdout reader (also readable after exit). */ - readonly stdout: SubprocessOutputReader - /** Live stderr reader (also readable after exit). */ - readonly stderr: SubprocessOutputReader - /** Resolves when the process closes; rejects only for spawn-level failures. */ + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void + /** + * Send one signal to the process tree, Node-style — no escalation, no + * timers. A no-op after the outcome has settled (the pid may be reused). + * @param signal - the signal to deliver (default `SIGTERM`; Windows + * force-terminates the tree for any value). + */ + kill(signal?: NodeJS.Signals): void + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately). Idempotent; also triggered by the + * spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise + /** + * Tear the child down to quiescence, resolving only after exit: close stdin + * (when this handle owns a piped one) and allow cooperative flush for + * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced + * tree termination with a final bounded `graceMs` wait. + * @param graces - the ladder's two windows, from the consumer's Config. + * @throws when the child still has not exited `graceMs` after the forced tier. + */ + dispose(graces: SubprocessDisposeGraces): Promise } ``` ```ts type-equiv /** - * Cursor-free incremental access to one live output stream. Offsets are + * Cursor-free incremental access to one collected output stream. Offsets are * whole-stream byte coordinates owned by the caller, so independent readers - * cannot consume one another's output. + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). */ interface SubprocessOutputReader { /** @@ -136,26 +223,62 @@ interface SubprocessOutputRead { } ``` -## 结果不携带原因分类 - -`done` 报告原始退出事实。服务会在中止时终止进程,但绝不判定原因:调用方读取归自己所有的 deadline 信号,以区分超时与取消(即 bash 执行器的 `timedOut`/`aborted` 拆分)。 +```ts type-equiv +/** Offset-based readers for the streams spawned in collect mode. */ +interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} +``` ```ts type-equiv /** - * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the service kills on abort but does not decide - * why — the caller reads the signal it owns to classify causes. + * The two grace periods of the cooperative dispose ladder + * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, + * validated Config fields, so teardown timing is deployment-tunable and this + * seam hardcodes nothing. + */ +interface SubprocessDisposeGraces { + /** + * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce + * ON ITS OWN — flush durable state, tear down its own descendants — before + * escalation to platform termination. Usually WIDER than + * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven + * teardown may itself wait on a signal-trapping grandchild plus a final + * flush. + */ + eofGraceMs: number + /** + * Termination confirmation window (ms): POSIX applies it after `SIGTERM` + * and again after `SIGKILL`; Windows applies it after the forced tree + * termination. + */ + graceMs: number +} +``` + +## 结果只承载退出事实 + +`done` 报告 Node close 事件的词汇,不携带原因分类:服务会在中止时终止进程,但绝不判定原因(调用方读取归自己所有的 deadline 信号,例如 bash 执行器的 `timedOut`/`aborted` 拆分)。收集到的输出在结算后仍可经 `handle.collected` 读取,因此批量与流式调用方共用一条访问路径。 + +```ts type-equiv +/** + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. */ interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput } ``` ## 服务行为 -抽象的 [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam 只定义 `spawn`;[`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) 是本地实现(detached 进程组、以 spill 文件兜底的尾部保留收集、凭据清除、先终止再等待退出的 dispose(资源释放))。seam 契约见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md),具体机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md)。 +抽象的 [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam 只定义 `spawn`;[`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) 是本地实现(detached 进程树、按处置方式接线的流、凭据清除、先终止再等待退出的 dispose(资源释放))。seam 契约见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md),具体机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md)。 diff --git a/packages/lsp/lsp-local/README.i18n.yaml b/packages/lsp/lsp-local/README.i18n.yaml index dcef438cf7..9532bc1cb3 100644 --- a/packages/lsp/lsp-local/README.i18n.yaml +++ b/packages/lsp/lsp-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 -README.md: 877c131ca4e34fdce59a46f820b889a1b9a73555 -README.zh.md: 58cf5a0558c680abd12b599ac7ef7696ce044877 +README.md: 462cb12ce96dbbb645c9a19126911d32d4ddd722 +README.zh.md: f5537a416c49106b128188efe4adb2d65304320a diff --git a/packages/lsp/lsp-local/README.zh.md b/packages/lsp/lsp-local/README.zh.md index 58cf5a0558..f5537a416c 100644 --- a/packages/lsp/lsp-local/README.zh.md +++ b/packages/lsp/lsp-local/README.zh.md @@ -12,7 +12,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) - 每个 `(server id, canonical workspace realpath)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。 - 每次查询都使用兼容性优先的**临时打开** 序列:通过 Node API 规范化并读取源文件、`textDocument/didOpen`(版本 1、完整文本)、所请求操作,然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。 - 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。 -- 协议 shutdown 失败后,通过 POSIX 进程组信号或同步 Windows `taskkill /T /F` 终止服务器后代树。Windows 只抑制 taskkill 报告的树已不存在结果;命令、权限与其他树终止失败仍保持可见。 +- 协议 shutdown 失败后,经由进程管理器 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。 - 通过子进程 host namespace 中的 Node 文件系统 API 读取源文件,绝不使用 `ctx.fs`,也不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。 ## 配置 diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 7be70e0fe5..17e68d1607 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/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 -README.md: 5e3bddc67d213d74766a75da65cc44a21c8bb149 -README.zh.md: 4391809ee83c822fcada25f0bdc021af44be9354 +README.md: 8414836efd756f60258566ae3e4e00de2d4110d7 +README.zh.md: d32228495cd6c57398c88cea92ce168ecf278188 diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 4391809ee8..d32228495c 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -10,10 +10,9 @@ subagent seam 允许 agent(智能体)把工作委派给子 agent。与 [bash | `subagent-inprocess/` | 共享进程内运行驱动器(不提供提供方;每次运行使用一个清理 effect) | 无 | | `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents`) | -| `subagent-subprocess/` | 共享进程外机制:环境变量清理、dispose(资源释放)阶梯、隔离配置目录(纯库;不注册任何内容) | 无 | | `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则构建于 `subagent-subprocess` 库之上(凭据环境变量清理、dispose 阶梯、隔离配置目录)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 72377b5f77..10d4b9a7cb 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 -README.md: d1ba03cf5256ad4889c4893bfe11af42bd627f9d -README.zh.md: 5763ee9a22c1d0bfe12c7da2b7d996911b55cc49 +README.md: 317517f64f24d8a3ed01ebae08dcfd13668b9029 +README.zh.md: e10f435b13e7cdabb92ebfa5b4a5d0763f2af18f diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 5763ee9a22..e10f435b13 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程环境由 [`buildChildEnv`](../subagent-subprocess/README.md) 构建:先移除名称形似凭据的环境变量,再应用显式 `config.env` 值。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 64edcdc78b..5224774c27 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 -README.md: f91609dc6b6e27fc26e5ffb4b7fd68fc9f4de556 -README.zh.md: d4e2cd69d21772834e0e55db2b14e19ea7ce1832 +README.md: 657855aff67230ee22b8137ae3aabc76aff8f860 +README.zh.md: 5281a0d6eddb38974d1225220bab08880224f14b diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index d4e2cd69d2..5281a0d6ed 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -spawn 受管子进程组的共用归属位置:完全显式的 spawn spec、附带 spill 文件的有界尾部保留输出、经凭据清除的环境、基于偏移量的增量读取,以及 SIGTERM→宽限期→SIGKILL 的进程组终止。命令默认值补全、shell 语义、deadline 与呈现留在消费方:[bash 执行器家族](../bash/README.md)是第一个消费方,也拥有上述各项。参见[进程管理器 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +spawn 受管子进程树的共用归属位置:完全显式的 spawn spec,其 stdio 处置方式(disposition)为 Node 形状、按流划分(原始管道、inherit、附带 spill 文件的有界尾部保留收集);harness 中所有 spawn 调用方共用的那一份凭据清除;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及协作式 dispose(资源释放)阶梯。命令默认值补全、shell 语义、deadline、协议分帧与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见[进程管理器 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 | 包(package) | ctx 键 | 角色 | |---|---|---| -| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式的 `SubprocessSpawnSpec`、携带基于偏移量读取器的 `SubprocessHandle`,以及共享的 `DSH_*` 受管环境与 `CollectedOutput` 词汇 | -| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程组、附带有界私有 spill 文件的尾部保留截断、凭据清除与 `DSH_*` 合并次序、kill 升级,以及先终止再等待退出的 dispose(资源释放) | +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、kill/terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | +| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose | 服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 4dab94d5b8..2e8c1fdae4 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 -README.md: a18772055d33369f6feec1b1a0751bb299303e04 -README.zh.md: bc829d48e846055853753522287967cc9fd42ca6 +README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 +README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index bc829d48e8..da230ba37d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程组 spawn,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。该实现没有任何配置:每项限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在调用方 seam 的配置里(目前是 [`dsh-bash-local`](../../bash/bash-local/README.md))。 +[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程树 spawn,依照 spec 中按流划分的 stdio 处置方式(disposition)完成接线(原始管道、inherit、附带可选 spill 文件的有界尾部保留收集),并以进程树为范围、按 SIGTERM→SIGKILL 升级发送信号。该实现没有任何配置:每项处置方式、限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在各调用方 seam 的配置里([`dsh-bash-local`](../../bash/bash-local/README.md)、[`dsh-lsp-local`](../../lsp/lsp-local/README.md)、[`dsh-subagent-acp`](../../subagent/subagent-acp/README.md))。 ## 行为(以及设计来源) -- **带升级的 detached 进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。组长进程退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止这次 spawn 结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 -- **尾部保留截断 + 有界 spill 文件**:输出超过某条流的上限后,内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到一个私有临时文件,并在可用时报告该路径。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 +- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 - **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **基于偏移量的读取**:`SubprocessHandle` 的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存。 -- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能终止每个仍在运行的进程组并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 +- **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 +- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 ## 模型体验 @@ -22,7 +22,7 @@ ## 已知限制与暂缓事项 -- **仅支持 POSIX**:detached 进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 +- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index adc9ed8cc4..818069e978 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 -README.md: 126f0fd6863739563b8d3cb5b4b958cee47dfb3d -README.zh.md: fdd5035867316349a91d1700cbf3f521a2bac117 +README.md: 73e0a4abe49e8d3060f246218694faee127668e9 +README.zh.md: c38a1dd7c15e7d1c0f3139f8942911a4cd9f23fe diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index fdd5035867..c38a1dd7c1 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -6,13 +6,14 @@ ## 契约 -- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时 resolve,仅在 spawn 层面失败时 reject。 -- spec 完全显式(argv、cwd、按流划分的字节上限、spill 上限、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 在这里绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 -- 输出读取器接受全流字节偏移量且从不消费:独立的读取器不会抢走彼此的增量。偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在完整流 spill 文件存在时指向它。 -- `kill()` 与 spec 的 abort 信号对整个 detached 进程组执行 SIGTERM→宽限期→SIGKILL 升级;服务响应中止但绝不判定原因(deadline 与原因分类归调用方所有)。 -- dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 +- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 +- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 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 文件存在时指向它。收集到的输出在结算后仍可读取。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入该函数。 +- 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 -参见[进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +参见[进程管理器数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 ## 模型体验 @@ -24,5 +25,5 @@ ## 已知限制与暂缓事项 -- **目前只有一个消费方家族**:该 seam 的形状仅在 bash 执行器上得到验证;仓库内其他 spawn 调用点(LSP 服务器、PTY 后端、subagent 传输层)继续保留各自专属的进程处理,直到它们的流与生命周期需求对照本契约得到重新审视。 -- **假定 POSIX 进程组语义**:句柄词汇(作为组长的 `pid`、进程组终止、SIGTERM/SIGKILL 升级)没有 Windows 方案。 +- **node-pty 与由 SDK 管理的 spawn 只共享凭据清除**:PTY 后端的终端 fork 与 MCP SDK 自己的 stdio 传输层无法把 spawn 路由到这道 seam(fork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源。 +- **dispose 阶梯假定子进程配合 stdin EOF**:依赖其他信号(SIGHUP 惯例、控制 socket)才能完全停稳的子进程,需要自己的第一阶,通用阶梯才适用。 From cb3a1302eed107fcb9b783afaa624b8d1a148d76 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:55:05 +0800 Subject: [PATCH 34/75] ci: adopt pnpm/action-setup for symmetric pnpm store caching Convert the hand-rolled corepack + store-path + actions/cache pnpm setups to pnpm/action-setup@v4 + setup-node cache: pnpm (the landlock-run.yml pattern) in e2e.yml, docs-pages.yml, pi-ai-provider-e2e.yml, build-exe-for-python-sdk.yml, and ci.yml's node-compat and benchmark jobs. The enterprise restore-only PR jobs and the Windows job keep their documented asymmetries; serial-linux keeps its hand-rolled store cache because it produces the exact key those restore-only jobs consume, and only swaps corepack for the action. Implements and moves the CI-caching Agent Note from PR #667 to implemented/, recording the serial-linux producer exception and the exact-key (no restore-keys prefix) behavior of setup-node's cache. --- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +- ...m-action-setup-for-symmetric-ci-caching.md | 32 ++++++++ ...ction-setup-for-symmetric-ci-caching.zh.md | 32 ++++++++ ...m-action-setup-for-symmetric-ci-caching.md | 31 -------- ...ction-setup-for-symmetric-ci-caching.zh.md | 31 -------- .../workflows/build-exe-for-python-sdk.yml | 20 ++--- .github/workflows/ci.yml | 76 ++++++------------- .github/workflows/docs-pages.yml | 17 +---- .github/workflows/e2e.yml | 17 +---- .github/workflows/pi-ai-provider-e2e.yml | 17 +---- 10 files changed, 102 insertions(+), 175 deletions(-) rename .agents/notes/{proposed => implemented}/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml (59%) create mode 100644 .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md create mode 100644 .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md delete mode 100644 .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md delete mode 100644 .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md diff --git a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml similarity index 59% rename from .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml rename to .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 0a31f7a857..23f0580c07 100644 --- a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 63e3f45ab2340ee2b732da286117e25be45bed08 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 2348e07d58f7f0ed39a1759cc30133c8e15dbc4a +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 0cc0a09dfe1019d80b08599a245ce4bb044d81c5 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 477f8eed61579eb9c4f18701a39307daf098407d diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md new file mode 100644 index 0000000000..0cc0a09dfe --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -0,0 +1,32 @@ +# Agent Note: Use pnpm/action-setup for symmetric CI pnpm caching + +Status: implemented + +English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) + +## Problem + +Five workflows repeated a hand-rolled three-step pnpm setup — `corepack enable`, `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines total). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and also insulates against corepack's removal from newer Node distributions. + +## Decision + +The symmetric-cache setups use `pnpm/action-setup@v4` followed by `actions/setup-node` with `cache: pnpm`, the `landlock-run.yml` pattern: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input, mirroring the required Windows job's deliberate skip; the consolidated benchmark caches on both platforms as before. + +Explicitly NOT converted: + +- the three enterprise-runner PR jobs in `ci.yml` — they deliberately use `actions/cache/restore` only, keeping cache compression/upload off the paid latency-critical path, an asymmetry `setup-node`'s cache cannot express; +- the Windows job, which deliberately skips the store cache; +- the store-cache step of `ci.yml`'s serial-linux job — the job swaps `corepack enable` for `pnpm/action-setup@v4`, but its `pnpm store path` + `actions/cache@v4` steps stay hand-rolled because the master-push serial-linux run is the save side that populates the exact key and path the enterprise restore-only jobs consume; converting the producer to `setup-node`'s own key format would silently starve their restores. + +## Alternatives considered + +- **Keep the hand-rolled steps.** They work, but they are five drifting copies of setup boilerplate, and the corepack dependency is a known future break. +- **Convert everything including the enterprise jobs.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. +- **Convert serial-linux's store cache too.** Rejected during implementation: the proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. + +## Consequences + +- The corepack dependency is gone from every converted workflow; pnpm arrives via the pnpm team's official action, already trusted in-repo (`landlock-run.yml`). +- The cache-key format changed once; one cold run per converted lane repopulates it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. +- `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. +- About 75 net lines of workflow YAML removed; the enterprise-runner PR jobs and the Windows job are byte-identical, and serial-linux keeps producing the key they restore. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md new file mode 100644 index 0000000000..477f8eed61 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 用 pnpm/action-setup 实现对称的 CI pnpm 缓存 + +Status: implemented + +[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文 + +## 问题 + +五个工作流曾重复着同一套手写(hand-rolled)的三步 pnpm 设置——`corepack enable`、`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(合计约 40–60 行 YAML)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,同时还能隔绝 corepack 被从较新 Node 发行版中移除的影响。 + +## 决策 + +各对称缓存设置现采用 `pnpm/action-setup@v4` 后接带 `cache: pnpm` 的 `actions/setup-node`,即 `landlock-run.yml` 的模式:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux,与必需 Windows 作业刻意跳过缓存的做法保持一致;consolidated benchmark 与之前一样在两个平台上都启用缓存。 + +以下明确未做转换: + +- `ci.yml` 中运行在企业 runner 上的三个 PR(Pull Request)作业——它们刻意只用 `actions/cache/restore`,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外,这种不对称是 `setup-node` 的缓存无法表达的; +- Windows 作业,它刻意跳过 store 缓存; +- `ci.yml` 中 serial-linux 作业的 store 缓存步骤——该作业把 `corepack enable` 换成了 `pnpm/action-setup@v4`,但其 `pnpm store path` + `actions/cache@v4` 步骤仍保持手写,因为 master 推送触发的 serial-linux 运行正是写入侧,负责填充企业只恢复不上传(restore-only)作业所消费的那个精确缓存键与路径;把生产者改成 `setup-node` 自有的键格式,会悄然断供它们的恢复。 + +## 曾考虑的替代方案 + +- **保留手写步骤。** 它们能用,但那是五份会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 +- **连企业作业在内全部转换。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 +- **连 serial-linux 的 store 缓存也转换。** 实现期间否决:提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 + +## 后果 + +- 每个已转换的工作流都不再依赖 corepack;pnpm 经由 pnpm 团队的官方 action 提供,该 action 已在仓库内获得信任(`landlock-run.yml`)。 +- 缓存键格式变更了一次;每条已转换泳道各跑一次冷运行以重建缓存,此后命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 +- `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。 +- 净删除约 75 行工作流 YAML;企业 runner 上的 PR 作业与 Windows 作业逐字节未动,serial-linux 继续生产它们所恢复的缓存键。 diff --git a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md deleted file mode 100644 index 63e3f45ab2..0000000000 --- a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Use pnpm/action-setup for symmetric CI pnpm caching - -Status: proposed - -English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) - -## Problem - -Five workflows repeat a hand-rolled three-step pnpm setup — `corepack enable`, `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines total). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — is already proven in-repo in `landlock-run.yml`, and also insulates against corepack's removal from newer Node distributions. - -## Proposal - -Convert the symmetric-cache workflows to `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`. Explicitly do NOT convert: - -- the three enterprise-runner PR jobs in `ci.yml` — they deliberately use `actions/cache/restore` only, keeping cache compression/upload off the paid latency-critical path, an asymmetry `setup-node`'s cache cannot express; -- the Windows job, which deliberately skips the store cache. - -## Alternatives considered - -- **Keep the hand-rolled steps.** They work, but they are five drifting copies of setup boilerplate, and the corepack dependency is a known future break. -- **Convert everything including the enterprise jobs.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. - -## Acceptance criteria - -- The five symmetric workflows set up pnpm via the actions; one cold run per lane repopulates the new cache-key format, after which cache hit rates match the old steps. -- The enterprise-runner PR jobs and the Windows job are untouched. - -## Risks - -- Cache-key format changes once (one cold run per lane). -- A third-party action in more workflows; it is already trusted in-repo (`landlock-run.yml`) and is the pnpm team's official action. diff --git a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md deleted file mode 100644 index 2348e07d58..0000000000 --- a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: 用 pnpm/action-setup 实现对称的 CI pnpm 缓存 - -Status: proposed - -[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文 - -## 问题 - -五个工作流重复着同一套手写(hand-rolled)的三步 pnpm 设置——`corepack enable`、`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(合计约 40–60 行 YAML)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——已在仓库内的 `landlock-run.yml` 中得到验证,同时还能隔绝 corepack 被从较新 Node 发行版中移除的影响。 - -## 提案 - -将各对称缓存工作流改为 `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`。以下明确不做转换: - -- `ci.yml` 中运行在企业 runner 上的三个 PR(Pull Request)作业——它们刻意只用 `actions/cache/restore`,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外,这种不对称是 `setup-node` 的缓存无法表达的; -- Windows 作业,它刻意跳过 store 缓存。 - -## 曾考虑的替代方案 - -- **保留手写步骤。** 它们能用,但那是五份会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 -- **连企业作业在内全部转换。** 否决:只恢复不上传(restore-only)的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 - -## 验收标准 - -- 五个对称工作流经由上述 action 完成 pnpm 设置;每条泳道各跑一次冷运行以重建新的缓存键格式,此后缓存命中率与旧步骤持平。 -- 企业 runner 上的 PR 作业与 Windows 作业保持原样不动。 - -## 风险 - -- 缓存键格式变更一次(每条泳道各一次冷运行)。 -- 更多工作流引入一个第三方 action;它已在仓库内获得信任(`landlock-run.yml`),且是 pnpm 团队的官方 action。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 48c96e4d20..b119a548b8 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -124,9 +124,14 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + + # setup-node's built-in pnpm store cache keys on platform AND arch, so + # the Linux architectures sharing runner.os stay on separate caches. - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm - uses: actions/setup-python@v6 with: @@ -135,21 +140,6 @@ jobs: - name: Install Python build tooling run: python -m pip install uv==0.11.23 - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - # Linux architectures share runner.os, so the cache key includes arch. - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm- - # Cache pkg's target Node binary; lockfile changes roll the # exact key while the restore prefix can seed its replacement. - uses: actions/cache@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eceefa114..272bfd4453 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,22 +236,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ matrix.node }}-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -324,17 +314,22 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack and resolve pnpm store path + - name: Resolve pnpm store path id: pnpm-store - run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" # Master refreshes the caches that pull requests restore without saving. + # The store cache stays a hand-rolled actions/cache step rather than + # setup-node's `cache: pnpm`: the enterprise pull-request jobs above + # restore exactly this key and path, and setup-node's built-in cache + # uses its own key format — converting this producer would silently + # starve their documented restore-only optimization. - uses: actions/cache@v4 with: path: ${{ steps.pnpm-store.outputs.path }} @@ -498,9 +493,14 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + + # The Windows lanes deliberately skip the store cache like the required + # windows job; an empty cache input disables setup-node's caching. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + cache: ${{ matrix.platform == 'linux' && 'pnpm' || '' }} - name: Report runner capacity run: >- @@ -508,22 +508,6 @@ jobs: console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length, memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))" - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - if: matrix.platform == 'linux' - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - if: matrix.platform == 'linux' - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -598,9 +582,14 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + + # Unlike the larger-runner suite, both platforms cache the store here: + # the consolidated topology measures cache mechanics as workload. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + cache: pnpm - name: Report runner capacity run: >- @@ -608,27 +597,6 @@ jobs: console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length, memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))" - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path (Linux) - if: matrix.platform == 'linux' - id: pnpm-store-linux - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - name: Resolve pnpm store path (Windows) - if: matrix.platform == 'windows' - id: pnpm-store-windows - shell: pwsh - run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store-linux.outputs.path || steps.pnpm-store-windows.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache@v4 if: matrix.platform == 'linux' with: diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index e1ad20997d..281e931c50 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -33,23 +33,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3d1bba6c17..c445034a8c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -61,23 +61,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: 24 - - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-24-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml index d198abf5b5..1306754d4c 100644 --- a/.github/workflows/pi-ai-provider-e2e.yml +++ b/.github/workflows/pi-ai-provider-e2e.yml @@ -27,23 +27,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: 24 - - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-24-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile From a8a1ada183e8c382ef19ceb874e8ba25aeadcd9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:10:38 +0800 Subject: [PATCH 35/75] docs: move execa Agent Note to implemented; update inbound links and README contracts - proposed/testing -> implemented/testing with the lifecycle rewrite (Proposal->Decision in present tense, Acceptance criteria + Risks folded into Consequences); zh counterpart mirrored and both pairs re-recorded. - the rejected NIH-audit roll-up pair now links the implemented/ path. - loader-smoke README: captured output is bounded by execa's default 100 MB maxBuffer, no longer unbounded. - acp-snapshot README: harness.ts now also imports vitest (vi.waitFor), so the vitest-run-only constraint names both modules. - jsonrpc keyless smoke: raise the invalid-env case's subprocess deadline to 25s (the 9s pick starved a cold tsx boot on slow NFS). --- ...eca-for-test-subprocess-plumbing.i18n.yaml | 4 +- ...7-26-execa-for-test-subprocess-plumbing.md | 37 +++++++++++++++++ ...6-execa-for-test-subprocess-plumbing.zh.md | 37 +++++++++++++++++ ...7-26-execa-for-test-subprocess-plumbing.md | 41 ------------------- ...6-execa-for-test-subprocess-plumbing.zh.md | 41 ------------------- ...ency-swaps-rejected-by-nih-audit.i18n.yaml | 4 +- ...-dependency-swaps-rejected-by-nih-audit.md | 2 +- ...pendency-swaps-rejected-by-nih-audit.zh.md | 2 +- .../jsonrpc-agent/tests/keyless-smoke.e2e.ts | 4 +- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- .../support/loader-smoke/README.i18n.yaml | 4 +- packages/support/loader-smoke/README.md | 2 +- packages/support/loader-smoke/README.zh.md | 2 +- 15 files changed, 90 insertions(+), 98 deletions(-) rename .agents/notes/{proposed => implemented}/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml (61%) create mode 100644 .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md create mode 100644 .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md delete mode 100644 .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md delete mode 100644 .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml similarity index 61% rename from .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml rename to .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml index 90cad79b89..606229b494 100644 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.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 -2026-07-26-execa-for-test-subprocess-plumbing.md: 99a86258fe4d59db6a0e144dbcee94c095f70f8f -2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 525e09f07ce3e5dc61f1cadab5c11ea0790cccee +2026-07-26-execa-for-test-subprocess-plumbing.md: a25010b1cab7012cf9c659cfd8272d17e33618c5 +2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 733c9f7e7f666052f030ed3b0f916e4832aaa120 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md new file mode 100644 index 0000000000..a25010b1ca --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md @@ -0,0 +1,37 @@ +# Agent Note: Adopt execa for hand-rolled test subprocess plumbing + +Status: implemented + +English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) + +## Problem + +Roughly ten e2e/smoke files re-derived the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. + +Two related test-infra hand-rolls compounded the case: + +- `packages/support/llm-mock-server/src/cli.ts` hand-tokenized 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`). +- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carried two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies dead. +- The snapshot harness hand-rolled three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing. + +## Decision + +- `execa` is a root devDependency and a runtime dependency of `@deepseek-ai/dsh-loader-smoke` (the one `src/` consumer). The listed spawn-collect-timeout sites run through `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut, failed }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. `runLoaderSmoke` passes `input: ''` for its stdin-close contract, and sites whose assertions pin exact stream bytes pass `stripFinalNewline: false`. +- The genuinely custom parts stay custom on top of an execa-owned subprocess: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. `smoke-real.e2e.ts` keeps raw `spawn` for its three long-lived interactive servers — ready-line watching across both streams plus a staged SIGTERM→await→SIGKILL teardown are the whole site, so execa would delete nothing there; its share of this note is the dead `.env` parser. +- `llm-mock-server`'s CLI tokenizes via `parseArgs` (strict, no positionals); numeric coercion, bounds, and cross-option constraints stay manual, and the pinned error-message tests carry `parseArgs`'s own tokenizer texts. +- Both `loadRootEnv` copies are deleted outright: the owning vitest configs (`vitest.web.config.ts` unconditionally, `vitest.snapshot.config.ts` in record mode) load the repo-root `.env` before those files run. +- The four poll loops ride `vi.waitFor` with explicit `{ interval, timeout }` and descriptive errors thrown from the callback; `waitForPersistedTurnStart` captures its malformed-record validation error out of the retry loop so it fails the run immediately instead of being retried until the deadline. + +## Alternatives considered + +- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. +- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries. +- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. + +## Consequences + +- The hand-rolled collect/timeout blocks are gone, including the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke`: spawn and stream failures settle through execa's result fields, so the `src/` file carries no coverage exemptions and the per-file gate covers every remaining branch. +- Captured output is bounded by execa's default 100 MB `maxBuffer` (overflow terminates the subprocess) where it was previously unbounded; the `loader-smoke` README's limitation entry reflects this. +- Windows termination behavior (taskkill, exit-code mapping) is owned by execa instead of per-site hand-rolls; each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform. +- execa is a new root devDependency (previously absent from the lockfile); it is one of the most-depended-on packages on npm and actively maintained, and the exe/runtime closure is unaffected (tests only). +- The mock-server CLI's tokenizer-level error texts are no longer this repo's to choose: unknown options, missing values, and stray positionals report `parseArgs`'s wording, pinned as such in `tests/cli.spec.ts`. diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md new file mode 100644 index 0000000000..733c9f7e7f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 采用 execa 替换手写的测试子进程管道代码 + +Status: implemented + +[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 + +## 问题 + +大约十个 e2e/冒烟测试文件各自手工重写过同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。 + +另有两处相关的测试基础设施手写代码进一步强化了替换的理由: + +- `packages/support/llm-mock-server/src/cli.ts` 曾手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。 +- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 曾携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝实为死代码。 +- 快照 harness 曾手写三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。 + +## 决定 + +- `execa` 是根 devDependency,同时是 `@deepseek-ai/dsh-loader-smoke`(唯一的 `src/` 消费者)的运行时依赖。上述 spawn、收集、超时的代码位置统一经由 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 运行:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut, failed }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。`runLoaderSmoke` 传 `input: ''` 以兑现其 stdin 关闭契约;断言固定精确流字节的位置传 `stripFinalNewline: false`。 +- 真正定制的部分继续保持定制,只是架在 execa 拥有的子进程之上:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。`smoke-real.e2e.ts` 的三个长驻交互式服务器保留原生 `spawn`——跨双流监听就绪行加上分级的 SIGTERM→等待→SIGKILL 拆除就是该处的全部内容,execa 在那里删不掉任何东西;它在本 note 中的份额是那份死的 `.env` 解析器。 +- `llm-mock-server` 的 CLI 经由 `parseArgs` 切分(strict、不允许位置参数);数值转换、边界检查与跨选项约束仍手工实现,被固定的错误消息测试改为携带 `parseArgs` 自己的切分器文本。 +- 两份 `loadRootEnv` 拷贝被整体删除:拥有它们的 vitest 配置(`vitest.web.config.ts` 无条件、`vitest.snapshot.config.ts` 在 record 模式下)在这些文件运行之前就加载了仓库根部的 `.env`。 +- 那四个轮询循环改乘 `vi.waitFor`,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误;`waitForPersistedTurnStart` 把「持久化记录格式非法」的校验错误捕获到重试循环之外,使其立即让运行失败,而不是被重试到截止时间。 + +## 曾考虑的替代方案 + +- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 +- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。 +- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 + +## 后果 + +- 手写的收集/超时代码块全部移除,包括 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支:spawn 与流故障如今经由 execa 的结果字段结算,这个 `src/` 文件不再携带任何覆盖率豁免,逐文件门禁覆盖其余全部分支。 +- 捕获的输出如今受 execa 默认 100 MB `maxBuffer` 约束(溢出即终止子进程),此前是无界的;`loader-smoke` README 的局限条目反映了这一点。 +- Windows 终止行为(taskkill、退出码映射)由 execa 拥有,不再逐处手写;每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。 +- execa 是新增的根 devDependency(此前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,exe/运行时闭包不受影响(仅测试使用)。 +- mock-server CLI 切分器层面的错误文本不再由本仓库决定:未知选项、缺失取值与多余位置参数报告 `parseArgs` 的措辞,并在 `tests/cli.spec.ts` 中如此固定。 diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md deleted file mode 100644 index 99a86258fe..0000000000 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Adopt execa for hand-rolled test subprocess plumbing - -Status: proposed - -English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) - -## Problem - -Roughly ten e2e/smoke files re-derive the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. Net deletable: ~100–150 lines of test infrastructure. - -Two related test-infra hand-rolls compound the case: - -- `packages/support/llm-mock-server/src/cli.ts` hand-tokenizes 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`). -- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carry two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies arguably dead. -- The snapshot harness hand-rolls three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing. - -## Proposal - -- Add `execa` as a root devDependency and rewrite the spawn-collect-timeout sites onto `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. Keep the genuinely custom parts custom: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. -- Swap `llm-mock-server`'s CLI tokenizer for `parseArgs` (numeric coercion, bounds, and cross-option constraints stay manual; pinned error-message texts update with the tests). -- Delete both `loadRootEnv` copies in favor of `process.loadEnvFile` in a try/catch, or remove them outright if the vitest-config loading already covers them. -- Replace the four poll loops with `vi.waitFor`/`expect.poll`, passing explicit `{ interval, timeout }` and throwing descriptive errors from the callback. - -## Alternatives considered - -- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. -- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries. -- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. - -## Acceptance criteria - -- The listed sites spawn through execa (or the chosen equivalent); the hand-rolled collect/timeout blocks and the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke` are gone. -- `llm-mock-server` CLI parses via `parseArgs`; its cli spec passes with updated message expectations. -- No hand-rolled `.env` parser remains under `apps/web/tests`. -- The affected e2e and snapshot suites pass on both POSIX and Windows CI lanes. - -## Risks - -- `loader-smoke` is a `src/` file under the per-file-100% coverage gate; the swap actually simplifies its coverage story (removes un-inducible branches) but the new call shape needs coverage. -- Each rewritten e2e must be re-run on both platforms; subtle differences in kill escalation or stdin-close semantics (`input: ''` for loader-smoke's stdin-close contract) are the risk to verify per site. -- execa is a new root devDependency (currently absent from the lockfile entirely); it is one of the most-depended-on packages on npm and actively maintained, so health is not a concern, but the exe/runtime closure is unaffected either way (tests only). diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md deleted file mode 100644 index 525e09f07c..0000000000 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: 采用 execa 替换手写的测试子进程管道代码 - -Status: proposed - -[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 - -## 问题 - -大约十个 e2e/冒烟测试文件各自手工重写同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。净可删除量:约 100–150 行测试基础设施代码。 - -另有两处相关的测试基础设施手写代码进一步强化了替换的理由: - -- `packages/support/llm-mock-server/src/cli.ts` 手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。 -- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝几乎可以视为死代码。 -- 快照 harness 手写了三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。 - -## 提案 - -- 将 `execa` 添加为根 devDependency,把上述 spawn、收集、超时的代码位置改写到 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 上:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。真正定制的部分继续保持定制:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。 -- 把 `llm-mock-server` 的 CLI 切分器换成 `parseArgs`(数值转换、边界检查与跨选项约束仍手工实现;被固定的错误消息文本随测试一并更新)。 -- 删除两份 `loadRootEnv` 拷贝,改用包在 try/catch 中的 `process.loadEnvFile`;如果 vitest 配置的加载已经覆盖了它们,则直接整体移除。 -- 用 `vi.waitFor`/`expect.poll` 替换那四个轮询循环,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误。 - -## 曾考虑的替代方案 - -- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 -- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。 -- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 - -## 验收标准 - -- 所列位置全部通过 execa(或最终选定的等价包)spawn 子进程;手写的收集/超时代码块,连同 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支,全部移除。 -- `llm-mock-server` 的 CLI 经由 `parseArgs` 解析;其 cli 测试文件在更新消息期望后通过。 -- `apps/web/tests` 下不再存在手写的 `.env` 解析器。 -- 受影响的 e2e 与快照测试套件在 POSIX 与 Windows 两条 CI 车道上均通过。 - -## 风险 - -- `loader-smoke` 是逐文件 100% 覆盖率门禁下的 `src/` 文件;这次替换实际上简化了它的覆盖率问题(移除了无法人为诱发的分支),但新的调用形态需要补齐覆盖。 -- 每个改写后的 e2e 都必须在两个平台上重新运行;终止信号升级或 stdin 关闭语义上的细微差异(loader-smoke 的 stdin 关闭契约对应 `input: ''`)是需要逐处核验的风险。 -- execa 是新增的根 devDependency(当前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,健康度不是顾虑;至于 exe/运行时闭包,无论选哪个包都不受影响(仅测试使用)。 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 8749dbd0bc..e217158aa6 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.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 -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: c988ca0c75e9c50686551f3be1971d736b971e2a -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: e85161cb2ee616d388aa2a9dd065c315c60cd44a +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 31c925cd7bfe21e2020ae8bd3ba8f9e2b0398641 +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 097ba6c879a9eab7ae25f9a3020c403380842014 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index c988ca0c75..31c925cd7b 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -46,7 +46,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`shell-quote` for POSIX single-quoting**: two 1-line quoting helpers with exhaustive tests versus a maintenance-mode package with a CVE history and different escaping output — a safety boundary is the wrong place to save one line. - **`strip-ansi` for pty sanitization**: the pty sanitizer is a streaming state machine with split-sequence carry across chunks and OSC `133;D` prompt-marker extraction (the shell-readiness signal); stateless strippers replace ~20 inner lines while all state machinery stays. `stripVTControlCharacters` also demonstrably leaks unterminated-OSC payloads the session-title normalizer must strip (anti-spoofing). - **`pidtree`/`ps-tree` for the pty process inspector**: bare PID trees; the code needs start-time identity against PID reuse plus `/proc` stdin-wait detection no package does. -- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa proposal](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) +- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) - **`tree-kill` for acp-snapshot teardown and lsp process kill**: the lines are drain-ordering/error-propagation, not tree traversal; lsp/bash already use detached process groups + taskkill. - **node-pty everywhere for the TUI test driver**: [Windows-TUI note](../../implemented/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it is already the Windows leg. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index e85161cb2e..097ba6c879 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -46,7 +46,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `shell-quote` 承担 POSIX 单引号包裹**:两个各 1 行、测试详尽的引号辅助函数,对上一个处于维护模式、有 CVE 历史、转义输出还不一样的包——安全边界不是省一行代码的地方。 - **以 `strip-ansi` 承担 pty 净化**:pty 净化器是一台流式状态机,带跨分片的断裂序列续接和 OSC `133;D` 提示符标记提取(shell 就绪信号);无状态的剥离器只能替掉约 20 行内层代码,全部状态机构件原样保留。`stripVTControlCharacters` 还被实证会泄漏未终止的 OSC 载荷,会话标题归一化器必须剥除它们(反欺骗)。 - **以 `pidtree`/`ps-tree` 承担 pty 进程巡检器**:它们只给裸 PID 树;这段代码需要对抗 PID 复用的启动时间身份校验,加上 `/proc` stdin 等待检测,没有包做这些。 -- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa 提案](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) +- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) - **以 `tree-kill` 承担 acp-snapshot 拆除与 lsp 进程终止**:那些代码行做的是排空顺序与错误传播,不是进程树遍历;lsp/bash 已经使用分离的进程组加 taskkill。 - **在 TUI 测试驱动器上到处使用 node-pty**:[Windows TUI 决策](../../implemented/feature/2026-07-20-windows-tui-support.md)已明确否决在每个宿主上都用 node-pty;它已经是 Windows 那一条腿。 diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index fb31afd033..0d4e4d8f2e 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -176,7 +176,7 @@ describe('jsonrpc-agent keyless smoke', () => { DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', }, stdin: 'ignore', - timeout: 9_000, + timeout: 25_000, killSignal: 'SIGKILL', reject: false, }) @@ -184,5 +184,5 @@ describe('jsonrpc-agent keyless smoke', () => { expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc') - }, 10_000) + }, 30_000) }) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index fd0fe03cb8..d706584b02 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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 -README.md: f3817a386a286e1dca40334fed7cb169643cb7e4 -README.zh.md: 2f87e9ef7b29f65f81f8b464f59725c13a057003 +README.md: 8babb67c30aed87ace4cfff81b2494a03a5b0335 +README.zh.md: 3f43627740054e200e928ffe527f827c710599b0 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index f3817a386a..8babb67c30 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -55,7 +55,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. +Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. ## Model Experience diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 2f87e9ef7b..3f43627740 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -55,7 +55,7 @@ defineAcpSnapshotSuite({ 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 -约束:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入(启动器、harness 和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 +约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 ## 模型体验 diff --git a/packages/support/loader-smoke/README.i18n.yaml b/packages/support/loader-smoke/README.i18n.yaml index a4e0016620..4794ac72cb 100644 --- a/packages/support/loader-smoke/README.i18n.yaml +++ b/packages/support/loader-smoke/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 -README.md: 8e53550608037a3c9a272db825933b7224ab24db -README.zh.md: 5310429ab59cf3cd04ac024746f5ed557e003637 +README.md: 73610ce50ebac4c6fc7bb9135f7b41b347c60685 +README.zh.md: 17f8481220136e8edf9fccd23fabfca5ccf41dfc diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 8e53550608..73610ce50e 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -19,5 +19,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`. -- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it. +- **Captured stdout and stderr are bounded only by execa's default 100 MB `maxBuffer`** — a runaway child is terminated at that ceiling rather than at a smoke-chosen budget. - **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup. diff --git a/packages/support/loader-smoke/README.zh.md b/packages/support/loader-smoke/README.zh.md index 5310429ab5..17f8481220 100644 --- a/packages/support/loader-smoke/README.zh.md +++ b/packages/support/loader-smoke/README.zh.md @@ -19,5 +19,5 @@ ## 已知限制与待完成工作 - **构建 mode 需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。 -- **捕获的 stdout 和 stderr 无界**:失控子进程可以消耗内存,直到 deadline 将其终止。 +- **捕获的 stdout 和 stderr 仅受 execa 默认 100 MB `maxBuffer` 约束**:失控子进程会在该上限处被终止,而不是在冒烟测试自选的预算处。 - **超时只终止直接子进程**:故障 fixture 生成的进程树可以比冒烟测试存活更久,需要外部清理。 From 3cabde323f5e299454bf8179dbbfbf51aa5ef039 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:45:13 +0800 Subject: [PATCH 36/75] fix(acp-snapshot): keep the malformed-record capture branch-free for per-file coverage Store the captured validation error as unknown in a wrapper object and rethrow it directly: the instanceof-Error normalization added an un-inducible false branch that failed harness.ts's 100% branch gate. --- packages/support/acp-snapshot/src/harness.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 0cf1cde9ab..ff98e620d3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -457,7 +457,7 @@ async function waitForPersistedTurnStart( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, minimumTurn?: number, ): Promise { - let invalidRecord: Error | undefined + let invalidRecord: { error: unknown } | undefined await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) let openTurn: number | undefined @@ -467,7 +467,7 @@ async function waitForPersistedTurnStart( // A malformed persisted record is a scenario bug, not a not-yet state: // vi.waitFor retries every callback throw, so capture the validation // failure, resolve the wait, and rethrow immediately below. - invalidRecord = error instanceof Error ? error : new Error(String(error)) + invalidRecord = { error } return } if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) { @@ -475,7 +475,7 @@ async function waitForPersistedTurnStart( throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`) } }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) - if (invalidRecord !== undefined) throw invalidRecord + if (invalidRecord !== undefined) throw invalidRecord.error } /** From d3a4f4af1b270e4d1e6dcdc1d139eed6bbe976e7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:18:03 +0800 Subject: [PATCH 37/75] fix(tests): lsp specs ride the seam scrub; acp e2e mounts the subprocess service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI now exports DSH_GATE_CONCURRENCY into every job, and the lsp suites spread the raw parent env into the spawn spec's ordinary channel — which the seam rightly rejects for DSH_* names. The specs now build their child env the way production does (scrubbedParentEnv() + explicit extras). The acp with-key e2e never mounted dsh-subprocess-local, leaving the provider's inject unsatisfied, so no 'acp' provider ever registered. --- packages/lsp/lsp-local/tests/connection.spec.ts | 5 +++-- packages/lsp/lsp-local/tests/instance.spec.ts | 5 +++-- packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index cdb331077c..0fdf223521 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-local' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -29,7 +30,7 @@ function connect( command: process.execPath, args: [fixtureServer], cwd: process.cwd(), - env: { ...process.env as Record, ...env }, + env: { ...scrubbedParentEnv(), ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, pipeDrainGraceMs: 3_000, @@ -139,7 +140,7 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec command: process.execPath, args: ['-e', script], cwd: process.cwd(), - env: { ...process.env as Record }, + env: scrubbedParentEnv(), maxMessageBytes: 16_000_000, maxStderrBytes, pipeDrainGraceMs: 3_000, diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 48dbd0252a..7d0ca74d0c 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -9,6 +9,7 @@ import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -39,7 +40,7 @@ function makeInstance( command: process.execPath, args: [fixtureServer], cwd: ws, - env: { ...process.env as Record, ...env }, + env: { ...scrubbedParentEnv(), ...env }, configuration: { setting: 42 }, initializationOptions: { init: true }, maxMessageBytes: 16_000_000, @@ -69,7 +70,7 @@ function scriptInstance(script: string, overrides: Partial = {}): command: process.execPath, args: ['-e', script], cwd: ws, - env: { ...process.env as Record }, + env: scrubbedParentEnv(), configuration: null, initializationOptions: null, maxMessageBytes: 16_000_000, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index b3909b708d..d361d26f4f 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import * as acp from '../src/index.ts' @@ -52,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: childLaunch.command, @@ -81,6 +83,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: childLaunch.command, From 23e82fdfb04fa4cbc8ac2c9fbe4dd302c10ae3d6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:34:41 +0800 Subject: [PATCH 38/75] docs(process): add the subprocess area to the PR label taxonomy The new capability family is a recurring repository domain; the area list grows to 46 (en+zh, pair re-recorded) and the GitHub label exists. --- .../process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml | 4 ++-- .../process/2026-07-25-semantic-pr-label-taxonomy.md | 4 ++-- .../process/2026-07-25-semantic-pr-label-taxonomy.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml index 8bc3f87435..e8fdb3f515 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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 -2026-07-25-semantic-pr-label-taxonomy.md: 61b7a829b8c44836cf9c6d0d8a7df463df309d89 -2026-07-25-semantic-pr-label-taxonomy.zh.md: cc0c5e7a8953bc97de51f90349a0e2542be5b77e +2026-07-25-semantic-pr-label-taxonomy.md: 3217b405e968d4d2c1eba1f1a5a08008b18ba514 +2026-07-25-semantic-pr-label-taxonomy.zh.md: 4cc603daa52bc9e6b0a85e33086a559a21dcc621 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md index 61b7a829b8..3217b405e9 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -30,13 +30,13 @@ Areas record semantic repository domains rather than temporary initiatives, owne ### Current areas -The 45 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. +The 46 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. | Group | Areas | |---|---| | Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | | Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Capabilities | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | | Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md index cc0c5e7a89..4cc603daa5 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -30,13 +30,13 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 ### 当前领域 -当前的 45 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 +当前的 46 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 | 分组 | 领域 | |---|---| | agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | | 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 能力 | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | | 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | From f5204796639b7b3b8bf22c67148690f0ee7bd716 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:55:32 +0800 Subject: [PATCH 39/75] docs: reject the landstrip evaluation for the win32 sandbox rung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User verdict: landstrip is not battle-tested — a days-old, single-maintainer project (~48 GitHub stars at rejection), which a security-invariant dependency cannot be. The note moves proposed/feature -> rejected/feature with the verdict on the Status line; the sandbox note's deferred-phases cross-link now records the rejection instead of instructing an evaluation, and the NIH roll-up's pointer follows. Supersedes this branch's earlier cross-link commit. --- .../notes/implemented/feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .agents/notes/implemented/feature/2026-07-06-sandbox.md | 2 +- .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md | 2 +- ...7-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml | 4 ++-- .../2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md | 2 +- ...26-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md | 2 +- ...026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml | 4 ++-- .../2026-07-26-dependency-swaps-rejected-by-nih-audit.md | 2 +- .../2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) rename .agents/notes/{proposed => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml (58%) rename .agents/notes/{proposed => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md (93%) rename .agents/notes/{proposed => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md (93%) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index fdb8e71d3b..ee6efc69f4 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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 -2026-07-06-sandbox.md: a9af53adfdabc6919113d8c6cb00c0b5f9e58c1f -2026-07-06-sandbox.zh.md: 6db1a914f1560e17296b7ea28f8c7367ecc58a81 +2026-07-06-sandbox.md: c6883873192f15ba2982436e156d8795396c0148 +2026-07-06-sandbox.zh.md: d84df9b06b15dd296801073d381603f34cfd2878 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index a9af53adfd..c688387319 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Before implementing this rung, complete the [landstrip evaluation gate](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md). +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 6db1a914f1..d84df9b06b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。在实现该梯级之前,先完成 [landstrip 评估门禁](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——对安全不变式而言,它还远未经过实战检验。 ## 曾考虑的替代方案 diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml similarity index 58% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml index 56e0178e8d..2dc0338121 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.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 -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 047449f4915c973e86cdb9f05f6dc51535133534 -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 379d57e1e0006bf8f567d0b750ca0bb641ca6b49 +2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 236139f9198f178d44cdf0867cbad2377a127359 +2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 3932f73a2bf147ce5088b5c42e85982c70cdb945 diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md index 047449f491..236139f919 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md @@ -1,6 +1,6 @@ # Agent Note: Evaluate landstrip before building a Windows sandbox launcher -Status: proposed +Status: rejected — landstrip is not battle-tested (a days-old single-maintainer project, ~48 GitHub stars at rejection); a security-invariant dependency must have proven adoption, so the win32 rung keeps the in-house-launcher plan English | [中文](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md) diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md index 379d57e1e0..3932f73a2b 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md @@ -1,6 +1,6 @@ # Agent Note: 在构建 Windows 沙箱启动器之前先评估 landstrip -Status: proposed +Status: rejected — landstrip 未经实战检验(问世仅数天、单一维护者、驳回时 GitHub 星标约 48 个);安全不变式级的依赖必须有成熟的采用度,因此 win32 梯级维持自研启动器的原计划 [English](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 8749dbd0bc..21dcac3dd6 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.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 -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: c988ca0c75e9c50686551f3be1971d736b971e2a -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: e85161cb2ee616d388aa2a9dd065c315c60cd44a +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 92ecfeef2deb7f6cca6e99b4e2de7571bc974548 +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 76f9c012e8986fc8b527cab263f875983bd72609 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index c988ca0c75..92ecfeef2d 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -67,7 +67,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`syncpack`/`manypkg` for `check-workspace-constraints.ts`**: they cover ~20 lines of range alignment; the load-bearing 200+ lines (computed `files` lists, cordis peer=dev pairing, hierarchy shape) are repo policy no generic engine expresses. - **`remark-validate-links` for `verify-md-links.ts`**: the gate rides the repo's shared mdast toolchain; adopting remark-cli adds a second markdown stack to delete one small file. - **`prebuildify`/`node-gyp-build` for the landlock launcher packaging**: inapplicable — those load `.node` addons via dlopen; the launcher ships a standalone exec'd static binary, and per-platform `optionalDependencies` *is* the ecosystem convention for binaries. -- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung is a different question — see the [landstrip evaluation proposal](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md).) +- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung was weighed separately and also [rejected](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — landstrip is not battle-tested.) - **`hatch-nodejs-version` for Python release versioning**: roughly LOC-neutral (a custom metadata hook replaces the regex), inverts the recorded decision that the dev sentinel never determines a release version, and puts a single-maintainer build plugin in the release supply chain. - **YAML consolidation (`js-yaml` vs `yaml`)**: the repo carries both parsers, with the `!!js` tag defined three times on js-yaml (vendored include, app-boot, apps/cli) and twice on `yaml` (sdk-telemetry's `ScalarTag`, sdk-helper's comment-preserving Document editing). The direction is forced — js-yaml cannot replace `yaml` (sdk-helper needs the Document API) — but migrating the js-yaml sites cannot retire the library either (the vendored include pins it) and would put two parsers in charge of one dialect that must agree exactly, against the [personal-config note](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)'s deliberate load-only-copy parity. Deletable: ~20–25 lines of duplicate tag definitions and two `@types/js-yaml` entries. The consolidation moment is a future include sync, not now. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index e85161cb2e..76f9c012e8 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -67,7 +67,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `syncpack`/`manypkg` 替换 `check-workspace-constraints.ts`**:它们只覆盖约 20 行的版本范围对齐;承重的 200+ 行(计算生成的 `files` 列表、cordis peer=dev 配对、层级形状)是仓库政策,没有通用引擎能表达。 - **以 `remark-validate-links` 替换 `verify-md-links.ts`**:该门禁搭载仓库共享的 mdast 工具链;采用 remark-cli 等于为删掉一个小文件而增加第二套 markdown 技术栈。 - **以 `prebuildify`/`node-gyp-build` 承担 landlock 启动器打包**:不适用——那些工具通过 dlopen 加载 `.node` addon;这个启动器交付的是独立 exec 的静态二进制,而按平台划分的 `optionalDependencies` 恰恰*就是*二进制分发的生态惯例。 -- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级是另一个问题——见 [landstrip 评估提案](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。) +- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级经单独权衡后同样被[驳回](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——landstrip 未经实战检验。) - **以 `hatch-nodejs-version` 承担 Python 发布版本号**:代码行数大致持平(一个自定义 metadata 钩子换掉那个正则),却反转了「dev 哨兵值绝不决定发布版本」这条记录在案的决策,还把一个单一维护者的构建插件放进发布供应链。 - **YAML 归一(`js-yaml` 与 `yaml`)**:仓库同时携带两个解析器,`!!js` 标签在 js-yaml 上定义了三次(vendor 收录的 include、app-boot、apps/cli),在 `yaml` 上定义了两次(sdk-telemetry 的 `ScalarTag`、sdk-helper 的保留注释式 Document 编辑)。方向是被迫的——js-yaml 无法取代 `yaml`(sdk-helper 需要 Document API)——但迁移 js-yaml 各调用点也退休不了这个库(vendor 收录的 include 锁定了它),还会让两个解析器共管一种必须完全一致的方言,违背[个人配置决策](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)刻意的「仅加载副本」对等性。可删除的:约 20–25 行重复标签定义和两条 `@types/js-yaml` 条目。归一的时机是未来某次 include 同步,不是现在。 From fced51d4ebb35bd105cb10e0d9196d7cc5325b04 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:04:37 +0800 Subject: [PATCH 40/75] fix(subagent): route explicit DSH_* config entries onto the managed env channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acp run passed config.env wholesale into the spawn spec's ordinary channel, which rejects the reserved DSH_* namespace — a deployment fact like DSH_PERMISSION_MODE (the acp-agent example's own knob, used by the with-key e2e) crashed the spawn. The run now splits DSH_* entries onto dshEnv, where the scrubbed base expects current facts to arrive. New layering test drives the split through the real seam via a MOCK_ECHO_ENV knob on the mock server; README env prose updated (en+zh, re-recorded). --- .../subagent/subagent-acp/README.i18n.yaml | 4 ++-- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 20 ++++++++++++++++--- .../subagent-acp/tests/mock-acp-server.ts | 10 +++++++++- .../subagent-acp/tests/subagent-acp.spec.ts | 15 ++++++++++++++ 6 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 10d4b9a7cb..61f41f5665 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 -README.md: 317517f64f24d8a3ed01ebae08dcfd13668b9029 -README.zh.md: e10f435b13e7cdabb92ebfa5b4a5d0763f2af18f +README.md: 0c21e179bd5ca405057b682786c41c8447a78317 +README.zh.md: b349443cfcda80caa9b06cfa1cf80f6e780a98be diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 317517f64f..0c21e179bd 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -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 are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. 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 are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives; `DSH_*` entries such as `DSH_PERMISSION_MODE` ride the seam's managed channel, since the scrubbed base reserves that namespace for current deployment facts), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. 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 e10f435b13..b349443cfc 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来;`DSH_PERMISSION_MODE` 这类 `DSH_*` 条目走该 seam 的受管通道,因为清除后的基底把这一命名空间保留给当前部署事实),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 85f1b9fdde..d2fc52d876 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -25,7 +25,8 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' +import type { DshEnvironmentKey, SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' @@ -49,6 +50,9 @@ export interface AcpRunSpec { * `DEEPSEEK_API_KEY`). Merged on top of the subprocess seam's scrubbed * parent env. A value here is forwarded even if its name matches the * credential-scrub pattern (an explicit opt-in for the child's own creds). + * Explicit `DSH_*` entries are deployment-owned facts for the child harness + * (e.g. `DSH_PERMISSION_MODE`) and ride the seam's managed channel, which + * the scrubbed base reserves for current values. */ env: Record /** @@ -166,13 +170,23 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes // to the result. The seam's scrub drops ambient credentials while spec.env - // (the child's own key) merges after it. + // (the child's own key) merges after it. Explicit DSH_* entries are the + // deployment's facts for the child and take the managed channel — the + // ordinary channel rejects that reserved namespace. + const env: Record = {} + const dshEnv: Record = {} + const isDshKey = (key: string): key is DshEnvironmentKey => key.startsWith(DSH_ENV_PREFIX) + for (const [key, value] of Object.entries(spec.env)) { + if (isDshKey(key)) dshEnv[key] = value + else env[key] = value + } const child = spec.spawn({ argv: [spec.command, ...spec.args], cwd: spec.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: spec.disposeGraceMs, - env: spec.env, + env, + dshEnv, }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (child.stdin === undefined || child.stdout === undefined) { diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 6b3f8157e8..de5900906a 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -4,6 +4,9 @@ * fully scripted by environment variables — no model, no network: * * - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`. + * - `MOCK_ECHO_ENV` — if set to a variable NAME, stream that variable's value + * (or ``) instead of MOCK_TEXT — asserts what + * environment actually reached the child process. * - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt` * (`end_turn` default, or `max_tokens`/`refusal`/…). * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for @@ -67,7 +70,12 @@ import { type StopReason, } from '@agentclientprotocol/sdk' -const TEXT = process.env.MOCK_TEXT ?? 'mock child answer' +// When MOCK_ECHO_ENV names a variable, stream that variable's value in place +// of MOCK_TEXT — lets a test assert exactly what env reached this process. +const echoEnvName = process.env.MOCK_ECHO_ENV +const TEXT = echoEnvName !== undefined + ? process.env[echoEnvName] ?? `<${echoEnvName} unset>` + : process.env.MOCK_TEXT ?? 'mock child answer' const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1' const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason const HANG = process.env.MOCK_HANG === '1' diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 919e736cb8..7c10463000 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -119,6 +119,21 @@ describe('child env layering (through the subprocess seam)', () => { delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN } }) + + it('routes explicit DSH_* config entries onto the managed channel', async () => { + // A deployment sets child-harness facts like DSH_PERMISSION_MODE in + // config.env; the run must split them onto the seam's managed channel + // (the ordinary channel rejects the reserved namespace) and the child + // must still see the value. + const ctx = await setup({ MOCK_ECHO_ENV: 'DSH_ACP_TEST_FACT', DSH_ACP_TEST_FACT: 'managed' }) + const parent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent + const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) + const result = await run.result + await run.dispose() + const text = result.output.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('') + expect(text).toBe('managed') + await ctx.fiber.dispose() + }) }) describe('cwd resolution', () => { From 43d81b67cef2a957afd2cb0646894757f3f28315 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:21:32 +0800 Subject: [PATCH 41/75] fix(subprocess): consumers with one mixed env map split DSH_* onto the managed channel Codex review of the acp-only fix found lsp-local carries the identical defect: its server config's unrestricted env merges into the connection's ordinary spawn channel, so a configured DSH_* fact crashed the spawn with the reserved-namespace rejection. The partition now lives on the seam as splitEnvChannels() beside the scrub it complements; the ACP run and the LSP connection both use it, and each proves child delivery end-to-end (MOCK_ECHO_ENV / LSP_FAKE_ECHO_ENV fixture knobs). Seam + consumer README rows updated (en+zh, re-recorded). bash-local is already two-channel; mcp/pty/sdk bypass the seam and only share the scrub. --- packages/lsp/lsp-local/README.i18n.yaml | 4 ++-- packages/lsp/lsp-local/README.md | 2 +- packages/lsp/lsp-local/README.zh.md | 2 +- packages/lsp/lsp-local/src/connection.ts | 5 ++++- .../lsp/lsp-local/tests/connection.spec.ts | 9 ++++++++ .../lsp/lsp-local/tests/fixture-server.ts | 8 ++++++- packages/subagent/subagent-acp/src/run.ts | 19 +++++----------- .../subprocess/subprocess/README.i18n.yaml | 4 ++-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 22 +++++++++++++++++++ .../subprocess/tests/service.spec.ts | 8 ++++++- 12 files changed, 62 insertions(+), 25 deletions(-) diff --git a/packages/lsp/lsp-local/README.i18n.yaml b/packages/lsp/lsp-local/README.i18n.yaml index 9532bc1cb3..41bbe5f7c6 100644 --- a/packages/lsp/lsp-local/README.i18n.yaml +++ b/packages/lsp/lsp-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 -README.md: 462cb12ce96dbbb645c9a19126911d32d4ddd722 -README.zh.md: f5537a416c49106b128188efe4adb2d65304320a +README.md: 3c70977b6ea783d5aa1766709d2a507e08b8ceca +README.zh.md: d24100c5f850172845a24acadcbd2d0207b64859 diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 462cb12ce9..3c70977b6e 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -23,7 +23,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v |---|---|---| | `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | | `args` | `[]` | Arguments passed to the executable. | -| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). | +| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); `DSH_*` entries ride the subprocess seam's managed channel. | | `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). | | `initializationOptions` | `null` | Static `initialize` options forwarded to the server. | | `configuration` | `null` | Static answer to every `workspace/configuration` item. | diff --git a/packages/lsp/lsp-local/README.zh.md b/packages/lsp/lsp-local/README.zh.md index f5537a416c..d24100c5f8 100644 --- a/packages/lsp/lsp-local/README.zh.md +++ b/packages/lsp/lsp-local/README.zh.md @@ -23,7 +23,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) |---|---|---| | `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 | | `args` | `[]` | 传给可执行文件的参数。 | -| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发)。 | +| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);`DSH_*` 条目走 subprocess seam 的受管通道。 | | `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 | | `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 | | `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 | diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index cbad79ea0d..74f68288e6 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -11,6 +11,7 @@ */ import type { Writable } from 'node:stream' +import { splitEnvChannels } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' @@ -98,7 +99,9 @@ export class LspConnection { stderr: { maxBytes: spec.maxStderrBytes }, }, graceMs: spec.pipeDrainGraceMs, - env: spec.env, + // spec.env mixes the scrubbed base with explicit config entries; a + // configured DSH_* fact takes the managed channel the seam reserves. + ...splitEnvChannels(spec.env), }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (this.handle.stdin === undefined || this.handle.stdout === undefined) { diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 0fdf223521..c717e3c886 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -51,6 +51,15 @@ describe('LspConnection', () => { expect(conn.pid).toBeGreaterThan(0) }) + it('routes explicit DSH_* env entries onto the managed channel', async () => { + // A configured DSH_* fact must reach the child: the ordinary channel + // rejects the reserved namespace, so the connection's spawn must split it + // onto dshEnv. The fixture echoes the named variable back as hover text. + const conn = connect({ LSP_FAKE_ECHO_ENV: 'DSH_LSP_TEST_FACT', DSH_LSP_TEST_FACT: 'managed' }) + await conn.request('initialize', { capabilities: {} }) + expect(await conn.request('textDocument/hover', {})).toEqual({ contents: 'managed' }) + }) + it('rejects a request when the server replies with an error', async () => { const conn = connect({ LSP_FAKE_ERROR: '1' }) await conn.request('initialize', { capabilities: {} }) diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 1a30ed5628..9399b5a1a7 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -58,7 +58,13 @@ function resultFor(method: string): unknown { case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null) case 'textDocument/references': return envJson('LSP_FAKE_REFS', null) case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null) - case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null) + case 'textDocument/hover': { + // LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover + // contents — a test can assert exactly what env reached this process. + const echoName = process.env.LSP_FAKE_ECHO_ENV + if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` } + return envJson('LSP_FAKE_HOVER', null) + } default: return null } } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index d2fc52d876..07358f230c 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -25,8 +25,8 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' -import type { DshEnvironmentKey, SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { splitEnvChannels } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' @@ -170,23 +170,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes // to the result. The seam's scrub drops ambient credentials while spec.env - // (the child's own key) merges after it. Explicit DSH_* entries are the - // deployment's facts for the child and take the managed channel — the - // ordinary channel rejects that reserved namespace. - const env: Record = {} - const dshEnv: Record = {} - const isDshKey = (key: string): key is DshEnvironmentKey => key.startsWith(DSH_ENV_PREFIX) - for (const [key, value] of Object.entries(spec.env)) { - if (isDshKey(key)) dshEnv[key] = value - else env[key] = value - } + // (the child's own key) merges after it; explicit DSH_* entries ride the + // managed channel via the seam's split. const child = spec.spawn({ argv: [spec.command, ...spec.args], cwd: spec.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: spec.disposeGraceMs, - env, - dshEnv, + ...splitEnvChannels(spec.env), }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (child.stdin === undefined || child.stdout === undefined) { diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 818069e978..31f0fc4e84 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 -README.md: 73e0a4abe49e8d3060f246218694faee127668e9 -README.zh.md: c38a1dd7c15e7d1c0f3139f8942911a4cd9f23fe +README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 +README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 73e0a4abe4..2cb7a5ebce 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` 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 is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the function. +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index c38a1dd7c1..a3211834e0 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 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 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入该函数。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 参见[进程管理器数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index bbcca90133..e4cfaae901 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -12,6 +12,7 @@ import { Context, Service } from 'cordis' import { DSH_ENV_PREFIX } from './types.ts' +import type { DshEnvironment, DshEnvironmentKey } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' @@ -61,6 +62,27 @@ export function scrubbedParentEnv(): Record { return env } +/** + * Partition one mixed explicit-env map onto the spec's two channels: `DSH_*` + * names are deployment-owned facts for the child and take the managed + * {@link SubprocessSpawnSpec.dshEnv} channel (the ordinary channel rejects the + * reserved namespace), everything else stays ordinary `env`. For consumers + * whose configs expose a single env map (lsp-local servers, the ACP backend) + * rather than two channel-shaped fields. + * @param env - explicit entries from a consumer's config, both namespaces mixed. + * @returns the two spec channels, each safe for its validator. + */ +export function splitEnvChannels(env: Readonly>): { env: Record; dshEnv: DshEnvironment } { + const ordinary: Record = {} + const managed: Record = {} + const isDshKey = (key: string): key is DshEnvironmentKey => key.startsWith(DSH_ENV_PREFIX) + for (const [key, value] of Object.entries(env)) { + if (isDshKey(key)) managed[key] = value + else ordinary[key] = value + } + return { env: ordinary, dshEnv: managed } +} + declare module 'cordis' { interface Context { subprocess: SubprocessService diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index d033e24a31..9fb68f403f 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv, splitEnvChannels, SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** @@ -72,4 +72,10 @@ describe('SubprocessService seam', () => { delete process.env.SCRUB_PROBE_PLAIN } }) + + it('splitEnvChannels partitions a mixed map onto the two spec channels', () => { + const { env, dshEnv } = splitEnvChannels({ DSH_FACT: 'managed', PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' }) + expect(env).toEqual({ PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' }) + expect(dshEnv).toEqual({ DSH_FACT: 'managed' }) + }) }) From 984c8e1d3e5ad24f9ce13b943d2b4d8d5057ceb9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:25:20 +0800 Subject: [PATCH 42/75] docs: regenerate the cordis services catalog for splitEnvChannels --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1b23a0a1e3..a410f2710b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1582,7 +1582,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:90`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:112`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` From cb1864795e97cf5a2d90446b13abf3e973e4a2e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:06:46 +0800 Subject: [PATCH 43/75] docs: prose-standard pass over the PR's added comments and one label Current-state fixes: two test comments still named the pre-rename spawnProcess; two narrated history ('the old whole-chunk drop', 'no longer kills backend work') instead of the pinned contract; and the packages/README row said process-group where the seam's own vocabulary is process-tree (en+zh, pair re-recorded). --- packages/README.i18n.yaml | 4 ++-- packages/README.md | 2 +- packages/README.zh.md | 2 +- packages/bash/bash-local/tests/executor.spec.ts | 2 +- packages/subprocess/subprocess-local/tests/spawn.spec.ts | 8 ++++---- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 6f22ec2128..be7e7c5b67 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 -README.md: e56ff806d2fabeb9a921b09897e006ed58c2019a -README.zh.md: 6787385d7ff4d361878e17c2945cc1dbef9c99ad +README.md: 911f18547120eb3dbbc9e42bbcd41e3b6d518cfe +README.zh.md: d6e1f0bf9b38b40944f8e3cebea3f6d90dcaceb5 diff --git a/packages/README.md b/packages/README.md index e56ff806d2..911f185471 100644 --- a/packages/README.md +++ b/packages/README.md @@ -13,7 +13,7 @@ Packages live at `packages///`; groups are containers, while names r | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | -| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-group implementation | Product — stable surface | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | | [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 6787385d7f..d6e1f0bf9b 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -13,7 +13,7 @@ | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | -| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程组实现 | 产品:稳定表面 | +| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | | [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 | | [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 | diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index d174b264ab..1395b48958 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -309,7 +309,7 @@ describe('process lifecycle ownership (the subprocess service, not the executor) const pid = Number((await readUntil(proc, '\n')).trim()) expect(Number.isInteger(pid) && pid > 0).toBe(true) - // Reloading/disposing the executor no longer kills backend work — the + // Executor reload/disposal leaves background work running — the // handle stays live and readable, mirroring the task runtime's // registrations-outlive-producer-fibers contract. await executorFiber.dispose() diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 38d4a970bd..4b3e16fd5a 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -144,7 +144,7 @@ describe('spawnSubprocess', () => { }) it('kills the process group with SIGTERM when the signal fires', async () => { - // spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout + // spawnSubprocess owns no timer: it kills on abort. The bash executor drives the timeout // by firing this signal via a deadline (see executor.spec.ts); here we // assert the kill itself lands as SIGTERM. const controller = new AbortController() @@ -350,8 +350,8 @@ describe('OutputCollector', () => { }) it('retains a byte-exact tail across uneven chunk boundaries', () => { - // The old whole-chunk drop could under-retain; a diagnostic tail must be - // exactly the LAST maxBytes regardless of chunking. + // A diagnostic tail must be exactly the LAST maxBytes regardless of + // chunking; dropping only whole chunks would under-retain. const collector = new OutputCollector(10, undefined, 'exact-tail', spillDir) collector.push(Buffer.from('aaaa')) collector.push(Buffer.from('bbbbbb')) @@ -818,7 +818,7 @@ describe('abort edge cases', () => { }) it('reports the terminating signal of an externally self-killed command', async () => { - // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the + // spawnSubprocess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. const result = await finish(spawnSubprocess(spec('kill -TERM $$'))) expect(result.signal).toBe('SIGTERM') From 776d666246d5ded20405802767419eb6f3dab07c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:52:41 +0800 Subject: [PATCH 44/75] refactor(subprocess): the dispose ladder moves to its one consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SubprocessHandle loses dispose(graces) and SubprocessDisposeGraces: the stdin-EOF→SIGTERM→SIGKILL sequence is teardown POLICY encoding one consumer's cooperation shape, not process vocabulary — the seam keeps kill/terminate/waitForExit, and waitForExit(signal?) is the quiescence probe a consumer ladder needs. dsh-subagent-acp owns disposeAcpChild() over those public verbs (tier tests move into its suite; a never-exits stub pins the fail-loud path); dsh-subprocess-local sheds the ladder, its deadline import, and the dsh-timeout dependency. Every future backend now owes four verbs and no teardown policy. New bilingual ladder-ownership Agent Note records the decision; catalogs regenerated. --- ...26-subprocess-consumer-migration.i18n.yaml | 4 +- ...026-07-26-subprocess-consumer-migration.md | 6 +- ...-07-26-subprocess-consumer-migration.zh.md | 6 +- ...07-27-dispose-ladder-to-consumer.i18n.yaml | 6 + .../2026-07-27-dispose-ladder-to-consumer.md | 23 ++++ ...026-07-27-dispose-ladder-to-consumer.zh.md | 23 ++++ docs/cordis-catalog/services.md | 4 +- .../core-data-structures/subprocess.i18n.yaml | 4 +- docs/core-data-structures/subprocess.md | 36 +----- docs/core-data-structures/subprocess.zh.md | 36 +----- .../cordis/tool-cordis/src/api-catalog.ts | 6 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 45 +++++++- .../subagent-acp/tests/subagent-acp.spec.ts | 68 ++++++++++- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/package.json | 2 - .../subprocess/subprocess-local/src/index.ts | 4 +- .../subprocess/subprocess-local/src/spawn.ts | 46 +------- .../subprocess-local/tests/spawn.spec.ts | 107 ++++-------------- .../subprocess/subprocess-local/tsconfig.json | 3 - .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 4 +- packages/subprocess/subprocess/README.zh.md | 4 +- packages/subprocess/subprocess/src/index.ts | 15 ++- packages/subprocess/subprocess/src/types.ts | 33 ------ .../subprocess/tests/service.spec.ts | 4 +- pnpm-lock.yaml | 3 - scripts/type-equiv.manifest.json | 5 - 32 files changed, 223 insertions(+), 294 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md create mode 100644 .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 4326462cbf..43c859efe2 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.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 -2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 41bdf04bc03517cf9fe10a61a565e442ecee5520 +2026-07-26-subprocess-consumer-migration.md: 41b2dd12c18cda91937569319d57ff65a804bd55 +2026-07-26-subprocess-consumer-migration.zh.md: d371573548863e404686e317ab763cda5a0d2995 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md index 805f27ba2e..41b2dd12c1 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -14,10 +14,10 @@ The seam's vocabulary is now Node-shaped, and every spawner that can ride the se - **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch). - **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome. -- **Tree-scoped termination, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. +- **Tree-scoped termination, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. (The stdin-EOF-first dispose ladder initially absorbed from `subagent-subprocess` later moved back out to its one consumer — see the [ladder-ownership Agent Note](2026-07-27-dispose-ladder-to-consumer.md).) - **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well. -Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is `handle.dispose` with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). +Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` (the plugins inject `'subprocess'`); the acp/lsp test fixtures gained the row. @@ -33,6 +33,6 @@ Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` ## Consequences -Bought: one implementation of tree signalling, escalation, the dispose ladder, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. +Bought: one implementation of tree signalling, escalation, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. Cost: the seam is wider — three stdio modes and four termination verbs instead of one of each — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 41bdf04bc0..d371573548 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -14,10 +14,10 @@ Status: implemented - **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 -- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 +- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界)。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。(最初从 `subagent-subprocess` 吸收的以 stdin EOF 打头的 dispose 阶梯,后来又移回其唯一消费方——见[阶梯归属 Agent Note](2026-07-27-dispose-ladder-to-consumer.md)。) - **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 -各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 +各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 挂载 lsp-local 或 subagent-acp 的组合如今都加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 @@ -33,6 +33,6 @@ Status: implemented ## 后果 -换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 +换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 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 new file mode 100644 index 0000000000..bd5964f1a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 +2026-07-27-dispose-ladder-to-consumer.zh.md: b6849ad393737f2fef06e2007991583b12a04d7a 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 new file mode 100644 index 0000000000..97b551ff50 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -0,0 +1,23 @@ +# Agent Note: The dispose ladder belongs to its consumer, not the subprocess seam + +Status: implemented + +English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) + +## Problem + +`SubprocessHandle.dispose(graces)` and `SubprocessDisposeGraces` put a full teardown *policy* — stdin-EOF wait, then SIGTERM, then SIGKILL, each tier bounded by a caller-supplied window — on a seam whose other verbs are single mechanisms. Only one consumer ever called it (the ACP subagent backend); bash rides `terminate()` and service teardown, and the LSP host runs its own protocol-first shutdown. Every future backend nonetheless had to implement the ladder to satisfy the interface, and the implementation carried a `dsh-timeout` dependency solely for the ladder's tier bounds. + +## Decision + +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface. + +## Alternatives considered + +**Keep the ladder on the handle as a convenience.** Rejected: a seam method every implementation must provide is not a convenience, it is contract surface — and this one encodes one consumer's cooperation shape (stdin-EOF-first) as if it were process vocabulary. The seam's own README already had to caveat that children quiescing on other signals need "their own tier-1", which is the admission that the ladder is policy. + +**Move the ladder to a shared helper package.** Rejected: one consumer. A second out-of-process backend with the same stdin-EOF cooperation shape can lift `disposeAcpChild` to shared code when it exists; extracting now would recreate `dsh-subagent-subprocess`, the single-purpose library this stack just deleted. + +## Consequences + +Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) 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 new file mode 100644 index 0000000000..b6849ad393 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -0,0 +1,23 @@ +# Agent Note: dispose 阶梯归其消费方所有,而非 subprocess seam + +Status: implemented + +[English](2026-07-27-dispose-ladder-to-consumer.md) | 中文 + +## 问题 + +`SubprocessHandle.dispose(graces)` 与 `SubprocessDisposeGraces` 把一整套拆卸*策略*——等待 stdin EOF、再 SIGTERM、再 SIGKILL,每一层由调用方提供的时间窗约束——放在了一个其余动词均为单一机制的 seam 上。它始终只有一个调用方(ACP subagent 后端);bash 走 `terminate()` 与服务拆卸,LSP 主机运行自己的协议优先关闭流程。然而每个未来后端都必须实现该阶梯才能满足接口,实现包也仅为阶梯的层级时限背上了 `dsh-timeout` 依赖。 + +## 决策 + +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 + +## 曾考虑的替代方案 + +**把阶梯作为便利方法留在句柄上。**否决:一个每个实现都必须提供的 seam 方法不是便利,而是契约表面——而这一个把某一消费方的配合形状(stdin EOF 打头)当作进程词汇来编码。seam 自己的 README 早已不得不加注「依赖其他信号停稳的子进程需要自己的第一阶」,这本身就是承认该阶梯是策略。 + +**把阶梯移到共享辅助包。**否决:只有一个消费方。当第二个具有相同 stdin EOF 配合形状的进程外后端出现时,可以再把 `disposeAcpChild` 提升为共享代码;现在抽取只会重造 `dsh-subagent-subprocess`——这组堆叠变更刚刚删掉的那个单一用途库。 + +## 后果 + +买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0b43c245a..abfa64a1af 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1567,7 +1567,7 @@ Implementations must honor these semantics: - spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level 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.kill signals without escalation, SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and SubprocessHandle.dispose runs the cooperative EOF-first ladder — all tree-scoped on every platform. +- SubprocessHandle.kill signals without escalation, and SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — both 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. ```ts cordis-catalog @@ -1582,7 +1582,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:112`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:111`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 0527cf34a3..1e942083be 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/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 -subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 -subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 +subprocess.md: 810f74b6512c846e138670aa6c3ad1c8be577366 +subprocess.zh.md: 8e6aca436a2a7a14ec4ab1da9b9d4f848918a9dc diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index cdd4507c7d..810f74b651 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and tree-scoped termination -A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, and `waitForExit()` observes the whole tree — enough for a consumer to build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). ```ts type-equiv /** @@ -177,15 +177,6 @@ interface SubprocessHandle { * @returns `true` when the tree exited, `false` when the signal aborted first. */ waitForExit(signal?: AbortSignal): Promise - /** - * Tear the child down to quiescence, resolving only after exit: close stdin - * (when this handle owns a piped one) and allow cooperative flush for - * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced - * tree termination with a final bounded `graceMs` wait. - * @param graces - the ladder's two windows, from the consumer's Config. - * @throws when the child still has not exited `graceMs` after the forced tier. - */ - dispose(graces: SubprocessDisposeGraces): Promise } ``` @@ -233,31 +224,6 @@ interface SubprocessCollectedOutputs { } ``` -```ts type-equiv -/** - * The two grace periods of the cooperative dispose ladder - * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, - * validated Config fields, so teardown timing is deployment-tunable and this - * seam hardcodes nothing. - */ -interface SubprocessDisposeGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own descendants — before - * escalation to platform termination. Usually WIDER than - * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven - * teardown may itself wait on a signal-trapping grandchild plus a final - * flush. - */ - eofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` - * and again after `SIGKILL`; Windows applies it after the forced tree - * termination. - */ - graceMs: number -} -``` ## Outcomes carry exit facts only diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 78325c3255..8e6aca436a 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与以进程树为范围的终止 -spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树——这足以让消费方构建自己的拆卸阶梯(ACP 后端以 stdin EOF 打头的 `disposeAcpChild` 即是模板)。 ```ts type-equiv /** @@ -177,15 +177,6 @@ interface SubprocessHandle { * @returns `true` when the tree exited, `false` when the signal aborted first. */ waitForExit(signal?: AbortSignal): Promise - /** - * Tear the child down to quiescence, resolving only after exit: close stdin - * (when this handle owns a piped one) and allow cooperative flush for - * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced - * tree termination with a final bounded `graceMs` wait. - * @param graces - the ladder's two windows, from the consumer's Config. - * @throws when the child still has not exited `graceMs` after the forced tier. - */ - dispose(graces: SubprocessDisposeGraces): Promise } ``` @@ -233,31 +224,6 @@ interface SubprocessCollectedOutputs { } ``` -```ts type-equiv -/** - * The two grace periods of the cooperative dispose ladder - * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, - * validated Config fields, so teardown timing is deployment-tunable and this - * seam hardcodes nothing. - */ -interface SubprocessDisposeGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own descendants — before - * escalation to platform termination. Usually WIDER than - * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven - * teardown may itself wait on a signal-trapping grandchild plus a final - * flush. - */ - eofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` - * and again after `SIGKILL`; Windows applies it after the forced tree - * termination. - */ - graceMs: number -} -``` ## 结果只承载退出事实 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index cf879df592..57d71fef7a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2221,13 +2221,9 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubprocessCollectedOutputs', declaration: 'export interface SubprocessCollectedOutputs {\n readonly stdout?: SubprocessOutputReader;\n readonly stderr?: SubprocessOutputReader;\n}', }, - { - name: 'SubprocessDisposeGraces', - declaration: 'export interface SubprocessDisposeGraces {\n eofGraceMs: number;\n graceMs: number;\n}', - }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n}', }, { name: 'SubprocessOutcome', diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 61f41f5665..a6b976c516 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 -README.md: 0c21e179bd5ca405057b682786c41c8447a78317 -README.zh.md: b349443cfcda80caa9b06cfa1cf80f6e780a98be +README.md: e573e22a02a301e18e341ea13c967718f6a7f625 +README.zh.md: 0522706fe0f0e3263c0c229812871ea17ebe8143 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 0c21e179bd..e573e22a02 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, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. After forced termination, every platform waits at most `disposeGraceMs` for exit and rejects on a signal error or missing 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 the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index b349443cfc..0522706fe0 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 取消,关闭 stdin,并等待 `disposeEofGraceMs`。随后 POSIX 先升级到 SIGTERM,等待 `disposeGraceMs` 后再使用 SIGKILL;Windows 会直接强制终止,因为 Node 会把两个信号都映射到 `TerminateProcess`。强制终止后,各平台最多再等待 `disposeGraceMs` 以确认退出;若信号出错或未退出,则拒绝。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 07358f230c..34438f4f2a 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -91,6 +91,46 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** 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) + try { + return await child.waitForExit(controller.signal) + } finally { + clearTimeout(timer) + } +} + +/** + * Cooperative teardown ladder for an out-of-process agent, over the seam's + * public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's + * window to flush persistence and reap its own descendants), then the + * terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded + * confirmation wait. + * @param child - the spawned ACP child's handle. + * @param eofGraceMs - tier-1 window after stdin EOF. + * @param graceMs - confirmation window after the escalation's SIGKILL. + * @throws when the tree still has not exited `graceMs` after forced termination. + */ +export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise { + // 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() sends SIGTERM now and SIGKILL after the spawn spec's grace + // (this plugin passes disposeGraceMs there), so the bound covers both the + // escalation window and an equal confirmation window after the SIGKILL. + child.terminate() + if (!(await treeExitsWithin(child, graceMs * 2))) { + throw new Error('ACP child process tree did not exit within its dispose windows') + } +} + /** * Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. * @param reason - the terminal reason from the child's `session/prompt` response. @@ -197,10 +237,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined - const disposeProcess = (): Promise => (processDisposal ??= child.dispose({ - eofGraceMs: spec.disposeEofGraceMs, - graceMs: spec.disposeGraceMs, - })) + const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs)) // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 7c10463000..0ab5916de9 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' -import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' +import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' @@ -136,6 +136,72 @@ describe('child env layering (through the subprocess seam)', () => { }) }) +describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => { + const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({ + argv: ['bash', '-c', command], + cwd: process.cwd(), + stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 200, + }) + + it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { + const child = bash('read -r line; exit 0') + await disposeAcpChild(child, 5_000, 200) + const outcome = await child.done + expect(outcome.exitCode).toBe(0) + expect(outcome.signal).toBeNull() + }) + + it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => { + const child = bash('sleep 60') + await disposeAcpChild(child, 100, 5_000) + const outcome = await child.done + expect(outcome.signal).toBe('SIGTERM') + }) + + it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => { + const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore') + // Wait for the trap to arm so SIGTERM cannot race the default handler. + while (!child.collected.stdout!.readFrom(0).text.includes('armed')) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + await disposeAcpChild(child, 50, 2_000) + const outcome = await child.done + expect(outcome.signal).toBe('SIGKILL') + }) + + it('throws when the tree survives even the escalation window', async () => { + // A handle whose tree never exits (waitForExit only ever aborts): the + // ladder must fail loud instead of resolving over survivors. Built as a + // stub because the ladder composes only public verbs. + const never: Parameters[0] = { + pid: 1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + kill: () => {}, + terminate: () => {}, + waitForExit: (signal?: AbortSignal) => new Promise((resolve) => { + signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + } + await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) + }) + + it('observes a spawn-level rejection and returns without a process to reap', async () => { + const child = spawnSubprocess({ + argv: ['bash', '-c', 'true'], + cwd: '/nonexistent-dir-dsh-acp-ladder-test', + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 200, + }) + await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined() + await expect(child.done).rejects.toThrow() + }) +}) + describe('cwd resolution', () => { it('falls back to the parent session cwd for the child process AND its ACP session', async () => { // realpath: on macOS `tmpdir()` sits behind a symlink (/var → /private/var), diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2e8c1fdae4..f34c84e520 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 -README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 -README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 +README.md: 16d83032ebf271a3e3e091962cad6dc9b689f072 +README.zh.md: e9a1284a81269741909631a6fbac8af3549800a3 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 08cc2ce7d9..16d83032eb 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README ## Behavior (and where it came from) -- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **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 + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. 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-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index da230ba37d..e9a1284a81 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为(以及设计来源) -- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 - **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 871b4cfac6..72ff50c422 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -29,13 +29,11 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index d71db5622a..eea1f1e739 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -16,8 +16,8 @@ import type { SpawnInternals } from './spawn.ts' /** * Local subprocess service: detached process trees, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill - * files), credential-scrubbed environment, tree-scoped signalling with - * SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. + * files), credential-scrubbed environment, and tree-scoped signalling with + * SIGTERM→grace→SIGKILL escalation. */ export class LocalSubprocessService extends SubprocessService { /** Live handles retained only so disposal can terminate and join them. */ diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8716f50b0c..bc716391e9 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,10 +1,9 @@ /** * Process plumbing for the local subprocess service: detached process-tree * spawn with per-stream stdio dispositions, tail-keep collection with spill - * files, tree-scoped signalling (POSIX groups; Windows taskkill), the - * SIGTERM→SIGKILL escalation, and the cooperative EOF-first dispose ladder. - * This layer reacts to an abort signal; callers own deadlines and classify - * causes. + * files, tree-scoped signalling (POSIX groups; Windows taskkill), and the + * SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers + * own deadlines, teardown ladders, and cause classification. * @module dsh-subprocess-local/spawn */ @@ -15,13 +14,11 @@ import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' -import { deadline } from '@deepseek-ai/dsh-timeout' import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { CollectedOutput, DshEnvironment, SubprocessCollect, - SubprocessDisposeGraces, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, @@ -63,9 +60,6 @@ export interface SpawnInternals { platform?: NodeJS.Platform } -/** Timeout code marking a dispose-ladder tier bound (vs an external abort). */ -const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER' - /** * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an * awaited teardown must keep the event loop alive until the tree really @@ -469,39 +463,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter return true } - /** - * Wait, bounded, for whole-tree exit — the dispose ladder's quiescence test. - * Tree liveness, not direct-child settlement: a TERM-trapping helper that - * outlives the leader must hold the ladder on its tier until it exits. - */ - const treeExitsWithin = async (ms: number): Promise => { - using bound = deadline(undefined, ms, DISPOSE_TIER_TIMEOUT) - return await waitForExit(bound.signal) - } - - let disposal: Promise | undefined - const dispose = (graces: SubprocessDisposeGraces): Promise => (disposal ??= (async () => { - // A spawn failure has no process to tear down; observe the rejection so - // disposal in a finally block cannot surface it as unhandled. - if (pid <= 0) { - await done.catch(() => {}) - return - } - // 1. Close a piped stdin and allow cooperative teardown and flush. - if (stdinMode === 'pipe') child.stdin?.end() - if (await treeExitsWithin(graces.eofGraceMs)) return - // 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates. - if (platform !== 'win32') { - kill('SIGTERM') - if (await treeExitsWithin(graces.graceMs)) return - } - // 3. Force-kill the tree and await a bounded exit edge. - kill('SIGKILL') - if (!(await treeExitsWithin(graces.graceMs))) { - throw new Error(`child process tree did not exit within ${graces.graceMs}ms after forced termination`) - } - })()) - return { pid, /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ @@ -517,6 +478,5 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter kill, terminate, waitForExit, - dispose, } } diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4b3e16fd5a..7936217b04 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -506,40 +506,6 @@ describe('stdio dispositions', () => { }) }) -describe('dispose ladder', () => { - it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { - const running = spawnSubprocess({ - ...spec('read -r line; exit 0'), - stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, - }) - await running.dispose({ eofGraceMs: 5_000, graceMs: 200 }) - const outcome = await running.done - expect(outcome.exitCode).toBe(0) - expect(outcome.signal).toBeNull() - }) - - it('tier 2: an EOF-deaf child dies by SIGTERM', async () => { - const running = spawnSubprocess({ - ...spec('sleep 60'), - stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, - }) - await running.dispose({ eofGraceMs: 100, graceMs: 5_000 }) - const outcome = await running.done - expect(outcome.signal).toBe('SIGTERM') - }) - - it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => { - const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60')) - await waitForStdout(running, 'armed\n') - const first = running.dispose({ eofGraceMs: 50, graceMs: 200 }) - const second = running.dispose({ eofGraceMs: 50, graceMs: 200 }) - expect(second).toBe(first) - await first - const outcome = await running.done - expect(outcome.signal).toBe('SIGKILL') - }) -}) - describe('windows tree semantics (injected platform)', () => { it('kill and terminate route through taskkill by root pid', async () => { const killed: number[] = [] @@ -590,7 +556,7 @@ describe('waitForExit', () => { }) }) -describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => { +describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from // the collected pipes, then exits: the helper holds the GROUP alive while @@ -609,18 +575,21 @@ describe('tree-survivor escalation (terminate/dispose reach helpers the leader l await waitGone(helper) }) - it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => { - const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`) + it('a bounded waitForExit reports false while a survivor lives, true after escalation', async () => { + const pidFile = join(spillDir, `survivor-wait-${Date.now()}.pid`) const running = spawnSubprocess(spec( `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`, { graceMs: 200 }, )) const helper = await waitForPidFile(pidFile) await running.done - expect(() => process.kill(helper, 0)).not.toThrow() - - await running.dispose({ eofGraceMs: 100, graceMs: 300 }) - // The ladder only returns once the WHOLE tree is gone. + // A consumer-owned teardown tier bounds its wait and reads the verdict. + const bound = new AbortController() + const timer = setTimeout(() => { bound.abort() }, 100) + await expect(running.waitForExit(bound.signal)).resolves.toBe(false) + clearTimeout(timer) + running.terminate() + await expect(running.waitForExit()).resolves.toBe(true) expect(() => process.kill(helper, 0)).toThrow() }) @@ -653,11 +622,10 @@ describe('coverage seams', () => { expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() }) - it('dispose on a spawn-failed handle observes the rejection and returns', async () => { + it('a spawn-failed handle rejects done while waitForExit reports gone', async () => { const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) - const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 }) await expect(running.done).rejects.toThrow() - await expect(disposal).resolves.toBeUndefined() + await expect(running.waitForExit()).resolves.toBe(true) }) it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => { @@ -692,25 +660,10 @@ describe('coverage seams', () => { await expect(running.waitForExit()).resolves.toBe(true) }) - it('dispose() on an already-exited tree returns without delivering a signal', async () => { - const running = spawnSubprocess(spec('true')) - await running.done - await running.waitForExit() - const spy = vi.spyOn(process, 'kill') - try { - await running.dispose({ eofGraceMs: 50, graceMs: 50 }) - const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) - expect(delivered).toEqual([]) - } finally { - spy.mockRestore() - } - }) - - it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => { + it('a batch-stdin handle exposes no stdin surface', async () => { const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' })) expect(running.stdin).toBeUndefined() await running.done - await running.dispose({ eofGraceMs: 50, graceMs: 50 }) expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n') }) }) @@ -739,33 +692,15 @@ describe('coverage seams 2', () => { await expect(running.waitForExit()).resolves.toBe(true) }) - it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => { - const kills: number[] = [] - const running = spawnSubprocess({ - ...spec('sleep 60'), - stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, - }, { - spillDir, - platform: 'win32', - taskkill: (pid) => { - kills.push(pid) - try { - process.kill(pid, 'SIGKILL') - } catch { - // Already gone. - } - }, - }) - await running.dispose({ eofGraceMs: 50, graceMs: 5_000 }) - // Exactly one forced tree termination: no POSIX SIGTERM tier ran. - expect(kills).toEqual([running.pid]) - }) - - it('dispose throws when even SIGKILL produces no exit within the grace', async () => { - // An inert taskkill simulates a tree that never reports exit. + it('an inert win32 taskkill leaves the tree alive for a bounded wait to report', async () => { + // An inert taskkill simulates a tree that never reports exit: terminate() + // delivers nothing, so a bounded consumer wait must come back false. const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} }) - await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 })) - .rejects.toThrow(/did not exit within 40ms after forced termination/) + running.terminate() + const bound = new AbortController() + const timer = setTimeout(() => { bound.abort() }, 60) + await expect(running.waitForExit(bound.signal)).resolves.toBe(false) + clearTimeout(timer) // Real cleanup: the injected platform spawned without detachment, so the // child is a plain (group-less) POSIX process — kill it directly. process.kill(running.pid, 'SIGKILL') diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 5272a4f78d..5a8dea211b 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../subprocess" }, - { - "path": "../../util/timeout" - }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 31f0fc4e84..a48a16cb26 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 -README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 -README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 +README.md: 271e9b912077bf5ec18d96666c216562cb87cc6e +README.zh.md: 692108ffe8848f823b3ab3fa487a2bf0fbc8fa44 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 2cb7a5ebce..271e9b9120 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -9,7 +9,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` 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 is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). +- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. @@ -26,4 +26,4 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced. -- **The dispose ladder assumes stdin-EOF cooperation** — a child that quiesces on a different signal (SIGHUP conventions, control sockets) needs its own tier-1 before the generic ladder fits. +- **Teardown ladders are consumer-owned** — the seam ships signalling verbs and the tree-liveness wait, not a canned quiesce sequence; each out-of-process consumer encodes its child's cooperation shape itself (the ACP backend's stdin-EOF-first ladder is the in-repo template). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index a3211834e0..692108ffe8 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -9,7 +9,7 @@ - `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 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 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 @@ -26,4 +26,4 @@ ## 已知限制与暂缓事项 - **node-pty 与由 SDK 管理的 spawn 只共享凭据清除**:PTY 后端的终端 fork 与 MCP SDK 自己的 stdio 传输层无法把 spawn 路由到这道 seam(fork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源。 -- **dispose 阶梯假定子进程配合 stdin EOF**:依赖其他信号(SIGHUP 惯例、控制 socket)才能完全停稳的子进程,需要自己的第一阶,通用阶梯才适用。 +- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合形状(ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index e4cfaae901..8001e1655f 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -2,10 +2,9 @@ * The subprocess seam (`ctx.subprocess`): spawn fully-specified commands into * managed process trees with Node-shaped stdio dispositions — raw pipes for * protocol streams, inherit for diagnostics, bounded spill-backed collection - * for batch output — plus tree-scoped signalling and a cooperative dispose - * ladder. Command defaulting, shell semantics, deadlines, framing, and - * presentation belong to consumers; the bash executor seam is the owning - * template. The local implementation lives in + * for batch output — plus tree-scoped signalling. Command defaulting, shell + * semantics, deadlines, teardown ladders, framing, and presentation belong to + * consumers; the bash executor seam is the owning template. The local implementation lives in * `@deepseek-ai/dsh-subprocess-local`. * @module @deepseek-ai/dsh-subprocess */ @@ -22,7 +21,6 @@ export type { DshEnvironmentKey, SubprocessCollect, SubprocessCollectedOutputs, - SubprocessDisposeGraces, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, @@ -102,10 +100,11 @@ declare module 'cordis' { * 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. - * - {@link SubprocessHandle.kill} signals without escalation, + * - {@link SubprocessHandle.kill} signals without escalation, and * {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates - * SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the - * cooperative EOF-first ladder — all tree-scoped on every platform. + * SIGTERM→grace→SIGKILL — both tree-scoped on every platform. + * {@link 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/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 4c667919c7..b2e0740d01 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -160,30 +160,6 @@ export interface SubprocessCollectedOutputs { readonly stderr?: SubprocessOutputReader } -/** - * The two grace periods of the cooperative dispose ladder - * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, - * validated Config fields, so teardown timing is deployment-tunable and this - * seam hardcodes nothing. - */ -export interface SubprocessDisposeGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own descendants — before - * escalation to platform termination. Usually WIDER than - * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven - * teardown may itself wait on a signal-trapping grandchild plus a final - * flush. - */ - eofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` - * and again after `SIGKILL`; Windows applies it after the forced tree - * termination. - */ - graceMs: number -} - /** * A live child process rooted in its own process tree. Collected output * remains readable after exit; piped streams belong to the caller. @@ -226,13 +202,4 @@ export interface SubprocessHandle { * @returns `true` when the tree exited, `false` when the signal aborted first. */ waitForExit(signal?: AbortSignal): Promise - /** - * Tear the child down to quiescence, resolving only after exit: close stdin - * (when this handle owns a piped one) and allow cooperative flush for - * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced - * tree termination with a final bounded `graceMs` wait. - * @param graces - the ladder's two windows, from the consumer's Config. - * @throws when the child still has not exited `graceMs` after the forced tier. - */ - dispose(graces: SubprocessDisposeGraces): Promise } diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 9fb68f403f..612d349786 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { scrubbedParentEnv, splitEnvChannels, SubprocessService } from '@deepseek-ai/dsh-subprocess' -import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** * Minimal concrete service: a hand-built handle. The seam is spawn-only — @@ -24,7 +24,6 @@ class StubSubprocessService extends SubprocessService { kill: () => {}, terminate: () => {}, waitForExit: () => Promise.resolve(true), - dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(), } } } @@ -44,7 +43,6 @@ describe('SubprocessService seam', () => { handle.kill() handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) - await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined() const outcome = await handle.done expect(outcome.exitCode).toBe(0) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af57e80494..d9f00ed502 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3851,9 +3851,6 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 811ebd40c9..53d32758e0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1303,11 +1303,6 @@ "doc": "docs/core-data-structures/subprocess.md", "symbol": "SubprocessCollectedOutputs", "source": "packages/subprocess/subprocess/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subprocess.md", - "symbol": "SubprocessDisposeGraces", - "source": "packages/subprocess/subprocess/src/types.ts" } ] } From 571772683596952e48b538027597f123c5bb2e5d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:14:51 +0800 Subject: [PATCH 45/75] subprocess: one explicit env channel on the spawn spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop SubprocessSpawnSpec.dshEnv and splitEnvChannels(); childEnv() is now scrubbed-base + explicit entries with no namespace validation. The invariant dropped is the reserved-namespace check on explicit entries (DSH_* rejected from env, non-DSH_* rejected from dshEnv). Explicit-entry trust already covers it: an explicit credential-shaped entry has always merged after the scrub as a deliberate caller opt-in, and an explicit DSH_* entry is the same deliberate act — the staleness invariant lives entirely in scrubbedParentEnv dropping AMBIENT credential-shaped and DSH_* names, which stays. The validation's only observed effect was rejecting legitimate explicit entries: both recent CI breakages (DSH_GATE_CONCURRENCY exported into every job crashing lsp specs, DSH_PERMISSION_MODE in acp config.env crashing the child spawn) were this check firing on values a caller meant to pass, each fixed by routing around the bureaucracy the seam itself imposed. The bash seam keeps its own request/spec dshEnv field: that is bash-owned trusted-plugin vocabulary (the ctx.bashEnv collected overlay) whose merge-last position guarantees a caller env entry cannot displace a managed fact; bash-local now flattens ENV_OVERRIDES -> spec.env -> spec.dshEnv into the seam's one env map. subagent-acp and lsp-local pass their single config env map straight through. DshEnvironment/DshEnvironmentKey/DSH_ENV_PREFIX stay on the subprocess seam as the namespace vocabulary (bash re-exports them; scrubbedParentEnv filters on the prefix). Tests: the two channel-rejection specs and the splitEnvChannels partition spec are deleted; one spawn spec now proves an explicit DSH_* env entry reaches the child while an ambient one is scrubbed; the acp/lsp forwarding specs keep their MOCK_ECHO_ENV / LSP_FAKE_ECHO_ENV assertions with the split comments rewritten to merge-after-scrub. Docs (en+zh, re-recorded) and the owning Agent Notes updated; cordis api/services catalogs regenerated. --- ...stdin-env-trusted-plugin-surface.i18n.yaml | 4 +-- ...0-bash-stdin-env-trusted-plugin-surface.md | 2 +- ...ash-stdin-env-trusted-plugin-surface.zh.md | 2 +- .../2026-07-26-subprocess-seam.i18n.yaml | 4 +-- .../2026-07-26-subprocess-seam.md | 4 +-- .../2026-07-26-subprocess-seam.zh.md | 4 +-- ...ession-identity-and-log-location.i18n.yaml | 4 +-- ...agent-session-identity-and-log-location.md | 4 +-- ...nt-session-identity-and-log-location.zh.md | 4 +-- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/bash.i18n.yaml | 4 +-- docs/core-data-structures/bash.md | 21 +++++++------ docs/core-data-structures/bash.zh.md | 21 +++++++------ .../core-data-structures/subprocess.i18n.yaml | 4 +-- docs/core-data-structures/subprocess.md | 19 +++++------- docs/core-data-structures/subprocess.zh.md | 19 +++++------- packages/bash/bash-local/src/index.ts | 10 +++--- packages/bash/bash/README.i18n.yaml | 4 +-- packages/bash/bash/README.md | 2 +- packages/bash/bash/README.zh.md | 2 +- packages/bash/bash/src/types.ts | 21 +++++++------ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/lsp/lsp-local/README.i18n.yaml | 4 +-- packages/lsp/lsp-local/README.md | 2 +- packages/lsp/lsp-local/README.zh.md | 2 +- packages/lsp/lsp-local/src/connection.ts | 8 ++--- .../lsp/lsp-local/tests/connection.spec.ts | 8 ++--- .../subagent/subagent-acp/README.i18n.yaml | 4 +-- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 12 +++---- .../subagent-acp/tests/subagent-acp.spec.ts | 7 ++--- .../subprocess-local/README.i18n.yaml | 4 +-- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/spawn.ts | 31 +++++-------------- .../subprocess-local/tests/spawn.spec.ts | 18 +++-------- .../subprocess/subprocess/README.i18n.yaml | 4 +-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 31 +++---------------- packages/subprocess/subprocess/src/types.ts | 17 ++++------ .../subprocess/tests/service.spec.ts | 8 +---- 43 files changed, 134 insertions(+), 201 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index 51d0eb9a78..690aa1f9b4 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.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 -2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 284cd45a66294dbc9e8207a1e00e9642d32d4e58 -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9486f8c35c5060150b072fb673acca5d4167ec1a +2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 556d5dd86dfcc92c4628e68c19390f0033560d25 +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9d67797f86903e70e7bdcd6f80f19d17c41ac18e diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index 284cd45a66..556d5dd86d 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -18,7 +18,7 @@ Three deliberate choices: 1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them. -2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. +2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision manages `DSH_*`: ambient entries are removed, and trusted `dshEnv` merges last, so an ordinary `env` entry can never displace a managed value. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index 9486f8c35c..9d67797f86 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -18,7 +18,7 @@ Status: implemented 1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 -2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策保留 `DSH_*`:环境条目会被移除,普通 `env` 无法设置它们,受信的 `dshEnv` 最后合并。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 +2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策托管 `DSH_*`:环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 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 7fd27e6770..6c6b1d6b44 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 -2026-07-26-subprocess-seam.md: 5cf0e596603b4cd3240e5d9a114d95f413c6410b -2026-07-26-subprocess-seam.zh.md: 31c37ae07f9456078673ccd005bd44c74ffa672e +2026-07-26-subprocess-seam.md: ad2f8522be51ba16b0df155aeb334a88f493890f +2026-07-26-subprocess-seam.zh.md: d9a0fb56b57b545dd1f94fde0cfb436d58fe00d4 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 5cf0e59660..ad2f8522be 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -13,7 +13,7 @@ English | [中文](2026-07-26-subprocess-seam.zh.md) A new `subprocess/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: - **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream stdio dispositions, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the shared scrub plus `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. (The [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) later widened the stdio and termination vocabulary Node-ward.) -- **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` merge, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel. +- **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the explicit-env merge after it, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel. - **`dsh-bash-local` (consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. - **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. @@ -29,7 +29,7 @@ Background-process lifetime moved from the executor to the subprocess service: t **Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry. -**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the subprocess service.** Rejected: a generic subprocess service must not impose terminal presentation policy on non-terminal consumers; the scrub and `DSH_*` channel rules are security/identity invariants and stay, but terminal friendliness is the bash tool's choice, expressed through the ordinary env channel where an explicit caller entry still wins. +**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the subprocess service.** Rejected: a generic subprocess service must not impose terminal presentation policy on non-terminal consumers; the ambient scrub (credential-shaped and `DSH_*` names) is a security/identity invariant and stays, but terminal friendliness is the bash tool's choice, expressed through the spec's explicit env where a caller's own entry still wins. ## Consequences 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 31c37ae07f..d9a0fb56b5 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 @@ -13,7 +13,7 @@ Status: implemented 新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: - **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及共享的凭据清除与 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。([消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 其后将 stdio 与终止词汇拓宽为 Node 形状。) -- **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 +- **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、清除之后合并显式 env 的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 - **`dsh-bash-local`(消费方)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 - **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 @@ -29,7 +29,7 @@ Status: implemented **改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 -**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;凭据清除与 `DSH_*` 通道规则是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经普通 env 通道表达,而调用方的显式条目在该通道中依旧优先。 +**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 `DSH_*` 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml index 2e7b5b1d58..f756be1482 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.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 -2026-07-10-agent-session-identity-and-log-location.md: a55bf276bff998a94f84ec1af078022e4881903c -2026-07-10-agent-session-identity-and-log-location.zh.md: 84b8b22187ccd1078ff13e39f4a345efbecfaeac +2026-07-10-agent-session-identity-and-log-location.md: 7b51ae41ac00c12a496940c891092580003646fa +2026-07-10-agent-session-identity-and-log-location.zh.md: 2574e1327f424069cdff68ef9b8de20c490077f0 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index a55bf276bf..7b51ae41ac 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -40,7 +40,7 @@ The registry rebuilds a trusted overlay for every foreground and background bash Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. -The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for filtering and channel validation. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`. Ordinary `env` remains the general in-process plugin surface used by hooks, but cannot contain managed keys; symmetrically, `dshEnv` cannot contain ordinary keys. The local executor rejects either wrong channel before spawn, removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. +The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for ambient filtering. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`: ordinary `env` remains the general in-process plugin surface used by hooks, while `dshEnv` is typed to managed keys. The local executor removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot, so an `env` entry can never displace a managed value. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required. @@ -82,6 +82,6 @@ A keyless full-loop integration drives the real agent loop, JSONL persistence, t ## Consequences -Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The complete `DSH_*` namespace inside these children is managed by the harness: ambient values are removed, current trusted values are re-added, and ordinary callers cannot use `env` to bypass ownership checks. +Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them. The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md index 84b8b22187..2574e1327f 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -40,7 +40,7 @@ interface SessionPersistence { 会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 -bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量进行过滤和通道校验。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层。普通 `env` 仍是钩子所用的通用进程内插件接口,但不能包含受管理的键;对称地,`dshEnv` 不能包含普通键。本地执行器会在 spawn 前拒绝任一错误通道,移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 +bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量过滤环境中已有的值。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层:普通 `env` 仍是钩子所用的通用进程内插件接口,`dshEnv` 则以类型约束为受管理键。本地执行器移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照,因此 `env` 条目永远无法顶掉受管理的值。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。 @@ -82,6 +82,6 @@ bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管 ## 影响 -每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中的完整 `DSH_*` 命名空间由 harness 管理:系统移除环境中已有的受管理值、重新加入当前受信任的值,并禁止普通调用方通过 `env` 绕过所有权检查。 +每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。 该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0b43c245a..a030b45da2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1582,7 +1582,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:112`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:89`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index fba715d163..9d261fe939 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.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 -bash.md: 4ea0ace7e4af8cda3ff5bf1b4fc672f6425e5396 -bash.zh.md: 6642c85134748bceaa4a7783742a63da8b6acabd +bash.md: 3747244662301a256e12037ea67c21017b5ac2c5 +bash.zh.md: 9927aa8d51ee410d70bed7a2d00e40061b499e15 diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 4ea0ace7e4..3747244662 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -46,17 +46,18 @@ interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -85,12 +86,12 @@ interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 6642c85134..9927aa8d51 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -46,17 +46,18 @@ interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -85,12 +86,12 @@ interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 0527cf34a3..17a21e9ac3 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/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 -subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 -subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 +subprocess.md: db4ab49ea953bfcf9ae59ede3187795eb71f0268 +subprocess.zh.md: 3f0a6c798056865ead1352f87328fb9dad6d5c0f diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index cdd4507c7d..db4ab49ea9 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -8,7 +8,7 @@ Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subproces ## Managed environment namespace and captured output -`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate entry, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -114,19 +114,14 @@ interface SubprocessSpawnSpec { */ signal?: AbortSignal | undefined /** - * Ordinary environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and - * belong in {@link dshEnv}; a deliberately forwarded credential-shaped - * entry survives because this layer merges after the scrub. + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. */ env?: Record | undefined - /** - * Harness-owned `DSH_*` variables for this execution. The scrubbed base has - * already discarded ambient `DSH_*` entries, so an unavailable current fact - * cannot inherit a stale value from the harness process; non-`DSH_*` names - * on this channel are rejected. - */ - dshEnv?: DshEnvironment | undefined } ``` diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 78325c3255..3f0a6c7980 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -8,7 +8,7 @@ ## 受管环境命名空间与捕获的输出 -`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方快照之前丢弃环境中已有的 `DSH_*` 名称,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的条目形式到达,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -114,19 +114,14 @@ interface SubprocessSpawnSpec { */ signal?: AbortSignal | undefined /** - * Ordinary environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and - * belong in {@link dshEnv}; a deliberately forwarded credential-shaped - * entry survives because this layer merges after the scrub. + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. */ env?: Record | undefined - /** - * Harness-owned `DSH_*` variables for this execution. The scrubbed base has - * already discarded ambient `DSH_*` entries, so an unavailable current fact - * cannot inherit a stale value from the harness process; non-`DSH_*` names - * on this channel are rejected. - */ - dshEnv?: DshEnvironment | undefined } ``` diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index f93c17e2ed..0f5a1b4e4d 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -19,8 +19,8 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' * Model-friendly environment overrides: disable colors, pagers, and * interactive terminal features that would garble tool output (the same set * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — - * merged into the ordinary env channel, so a trusted caller's own entry still - * wins; the subprocess service applies its credential scrub independently. + * merged first into the spawn's explicit env, so a trusted caller's own entry + * still wins; the subprocess service applies its credential scrub independently. */ export const ENV_OVERRIDES = { NO_COLOR: '1', @@ -152,8 +152,10 @@ export class LocalBashExecutor extends BashExecutor { }, graceMs: this.config.graceMs, signal, - env: { ...ENV_OVERRIDES, ...spec.env }, - dshEnv: spec.dshEnv, + // One explicit env map for the seam, layered so the trusted dshEnv + // snapshot beats both the caller's env and the terminal overrides; the + // subprocess service merges the whole map after its ambient scrub. + env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, } } diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index f32efb60a0..520828acde 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/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 -README.md: b4ee66a1fa2696254a1f2f411b7db5d3190f8370 -README.zh.md: 151d4bd7ab257234584b9008c96e6356d7e39351 +README.md: d7bf746969f52000fe298b65b995b7c631d8001c +README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index b4ee66a1fa..d7bf746969 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -33,7 +33,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). -`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). ## Model Experience diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 151d4bd7ab..14476b7703 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -33,7 +33,7 @@ 每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 -`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,拒绝普通 `env` 中的这些名称,再合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 ## 模型体验 diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index ea153605af..6fdd8b36c7 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -60,17 +60,18 @@ export interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -97,12 +98,12 @@ export interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index cf879df592..5853c92c0b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2247,7 +2247,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessSpawnSpec', - declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n}', }, { name: 'SubprocessStdinMode', diff --git a/packages/lsp/lsp-local/README.i18n.yaml b/packages/lsp/lsp-local/README.i18n.yaml index 41bbe5f7c6..a459427a78 100644 --- a/packages/lsp/lsp-local/README.i18n.yaml +++ b/packages/lsp/lsp-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 -README.md: 3c70977b6ea783d5aa1766709d2a507e08b8ceca -README.zh.md: d24100c5f850172845a24acadcbd2d0207b64859 +README.md: 85254eea2bb74df277be6fd5de1529b5da4ea178 +README.zh.md: 290601d455ba2012d0c1f5c97503ae87304881cb diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 3c70977b6e..85254eea2b 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -23,7 +23,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v |---|---|---| | `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | | `args` | `[]` | Arguments passed to the executable. | -| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); `DSH_*` entries ride the subprocess seam's managed channel. | +| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. | | `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). | | `initializationOptions` | `null` | Static `initialize` options forwarded to the server. | | `configuration` | `null` | Static answer to every `workspace/configuration` item. | diff --git a/packages/lsp/lsp-local/README.zh.md b/packages/lsp/lsp-local/README.zh.md index d24100c5f8..290601d455 100644 --- a/packages/lsp/lsp-local/README.zh.md +++ b/packages/lsp/lsp-local/README.zh.md @@ -23,7 +23,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) |---|---|---| | `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 | | `args` | `[]` | 传给可执行文件的参数。 | -| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);`DSH_*` 条目走 subprocess seam 的受管通道。 | +| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 | | `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 | | `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 | | `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 | diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 74f68288e6..91107bfbeb 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -11,7 +11,6 @@ */ import type { Writable } from 'node:stream' -import { splitEnvChannels } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' @@ -99,9 +98,10 @@ export class LspConnection { stderr: { maxBytes: spec.maxStderrBytes }, }, graceMs: spec.pipeDrainGraceMs, - // spec.env mixes the scrubbed base with explicit config entries; a - // configured DSH_* fact takes the managed channel the seam reserves. - ...splitEnvChannels(spec.env), + // spec.env mixes the scrubbed base with explicit config entries; the + // seam merges the whole map after its own ambient scrub, so a + // configured DSH_* fact reaches the child. + env: spec.env, }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (this.handle.stdin === undefined || this.handle.stdout === undefined) { diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index c717e3c886..076abb43e6 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -51,10 +51,10 @@ describe('LspConnection', () => { expect(conn.pid).toBeGreaterThan(0) }) - it('routes explicit DSH_* env entries onto the managed channel', async () => { - // A configured DSH_* fact must reach the child: the ordinary channel - // rejects the reserved namespace, so the connection's spawn must split it - // onto dshEnv. The fixture echoes the named variable back as hover text. + it('forwards explicit DSH_* env entries to the child', async () => { + // A configured DSH_* fact must reach the child: the seam scrubs only the + // ambient namespace, and the explicit entry merges after that scrub. The + // fixture echoes the named variable back as hover text. const conn = connect({ LSP_FAKE_ECHO_ENV: 'DSH_LSP_TEST_FACT', DSH_LSP_TEST_FACT: 'managed' }) await conn.request('initialize', { capabilities: {} }) expect(await conn.request('textDocument/hover', {})).toEqual({ contents: 'managed' }) diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 61f41f5665..13589b4dd0 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 -README.md: 0c21e179bd5ca405057b682786c41c8447a78317 -README.zh.md: b349443cfcda80caa9b06cfa1cf80f6e780a98be +README.md: 044a4c5a71d32224262acef9e2c849f375198c2c +README.zh.md: 918df12ea0ea50e6f59b144e74601ff6beacbe86 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 0c21e179bd..044a4c5a71 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -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 are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives; `DSH_*` entries such as `DSH_PERMISSION_MODE` ride the seam's managed channel, since the scrubbed base reserves that namespace for current deployment facts), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. 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 runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. 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 b349443cfc..918df12ea0 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来;`DSH_PERMISSION_MODE` 这类 `DSH_*` 条目走该 seam 的受管通道,因为清除后的基底把这一命名空间保留给当前部署事实),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 07358f230c..66584b6fb7 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -25,7 +25,6 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { splitEnvChannels } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ @@ -51,8 +50,8 @@ export interface AcpRunSpec { * parent env. A value here is forwarded even if its name matches the * credential-scrub pattern (an explicit opt-in for the child's own creds). * Explicit `DSH_*` entries are deployment-owned facts for the child harness - * (e.g. `DSH_PERMISSION_MODE`) and ride the seam's managed channel, which - * the scrubbed base reserves for current values. + * (e.g. `DSH_PERMISSION_MODE`); they simply merge after the scrub that + * dropped their stale ambient namesakes. */ env: Record /** @@ -169,15 +168,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const id = SessionId(randomUUID()) // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes - // to the result. The seam's scrub drops ambient credentials while spec.env - // (the child's own key) merges after it; explicit DSH_* entries ride the - // managed channel via the seam's split. + // to the result. The seam's scrub drops ambient credentials and DSH_* names + // while spec.env (the child's own key, its deployment facts) merges after it. const child = spec.spawn({ argv: [spec.command, ...spec.args], cwd: spec.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: spec.disposeGraceMs, - ...splitEnvChannels(spec.env), + env: spec.env, }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (child.stdin === undefined || child.stdout === undefined) { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 7c10463000..a6a7e5f0cb 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -120,11 +120,10 @@ describe('child env layering (through the subprocess seam)', () => { } }) - it('routes explicit DSH_* config entries onto the managed channel', async () => { + it('forwards explicit DSH_* config entries to the child', async () => { // A deployment sets child-harness facts like DSH_PERMISSION_MODE in - // config.env; the run must split them onto the seam's managed channel - // (the ordinary channel rejects the reserved namespace) and the child - // must still see the value. + // config.env; the seam's scrub drops only the AMBIENT namesakes, so the + // explicit entry merges after it and the child must see the value. const ctx = await setup({ MOCK_ECHO_ENV: 'DSH_ACP_TEST_FACT', DSH_ACP_TEST_FACT: 'managed' }) const parent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2e8c1fdae4..4137474562 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 -README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 -README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 +README.md: a62929e8ee1c1c852d99d9569f4118bc751e8150 +README.zh.md: 99febdbe54988f54b74dbc2c80251ec1e9e671ef diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 08cc2ce7d9..a62929e8ee 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -8,7 +8,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README - **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **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 + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. 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-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*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-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index da230ba37d..99febdbe54 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -8,7 +8,7 @@ - **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 -- **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8716f50b0c..1df197e626 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -16,10 +16,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' import { deadline } from '@deepseek-ai/dsh-timeout' -import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { CollectedOutput, - DshEnvironment, SubprocessCollect, SubprocessDisposeGraces, SubprocessHandle, @@ -29,28 +28,14 @@ import type { } from '@deepseek-ai/dsh-subprocess' /** - * Build a child environment from the scrubbed parent base, ordinary caller - * entries, and a managed `DSH_*` snapshot. Ordinary and managed entries - * reject the other channel's namespace before `dshEnv` merges last. - * @param extra - caller entries; `DSH_*` names are rejected. - * @param dshEnv - managed entries; non-`DSH_*` names are rejected. + * Build a child environment: explicit caller entries merge after the scrubbed + * parent base, so a deliberately supplied credential or current `DSH_*` fact + * wins over the scrub that dropped its ambient namesake. + * @param extra - explicit caller entries, merged verbatim after the scrub. * @returns the environment to hand to `spawn` for the child process. */ -export function childEnv( - extra?: Readonly>, - dshEnv?: DshEnvironment, -): NodeJS.ProcessEnv { - for (const key of Object.keys(extra ?? {})) { - if (key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`ordinary child env cannot set reserved variable "${key}"; use dshEnv`) - } - } - for (const key of Object.keys(dshEnv ?? {})) { - if (!key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`managed child env cannot set ordinary variable "${key}"; use env`) - } - } - return { ...scrubbedParentEnv(), ...extra, ...dshEnv } +export function childEnv(extra?: Readonly>): NodeJS.ProcessEnv { + return { ...scrubbedParentEnv(), ...extra } } /** Injectable knobs so tests can exercise spill and platform behavior deterministically. */ @@ -339,7 +324,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const errMode = spec.stdio.stderr const stdinMode = spec.stdio.stdin - const env = childEnv(spec.env, spec.dshEnv) + const env = childEnv(spec.env) const child = spawn(program, args, { cwd: spec.cwd, env, diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4b3e16fd5a..745a9231dc 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -2,7 +2,6 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess' import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' @@ -840,11 +839,13 @@ describe('environment and spill-file hardening', () => { } }) - it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { + it('forwards explicit DSH_* env entries while scrubbing ambient ones', async () => { + // Both facts through one explicit map: the ambient DSH_STALE is dropped by + // the scrub, and the deliberately supplied current values merge after it. process.env.DSH_STALE = 'old-value' try { const result = await finish(spawnSubprocess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { - dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, + env: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, }))) expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') } finally { @@ -852,17 +853,6 @@ describe('environment and spill-file hardening', () => { } }) - it('rejects DSH variables on the ordinary env channel', () => { - expect(() => spawnSubprocess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) - .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) - }) - - it('rejects ordinary variables on the managed env channel', () => { - const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment - expect(() => spawnSubprocess(spec('true', { dshEnv: invalid }))) - .toThrow(/managed child env.*PATH.*use env/) - }) - it('creates spill files with owner-only permissions and random names', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 31f0fc4e84..4dc0bdf5cc 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 -README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 -README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 +README.md: 65c2be2b77fbd7105208b86fa1b98a3c44013f94 +README.zh.md: 98a0315914746a94350ceec53e4daff886054e64 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 2cb7a5ebce..65c2be2b77 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` 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 is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index a3211834e0..98a0315914 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 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 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 参见[进程管理器数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index e4cfaae901..8c7d6797e6 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -12,7 +12,6 @@ import { Context, Service } from 'cordis' import { DSH_ENV_PREFIX } from './types.ts' -import type { DshEnvironment, DshEnvironmentKey } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' @@ -46,11 +45,10 @@ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i * The ambient parent environment minus credential-shaped names and minus all * `DSH_*` names — the canonical base every harness child starts from. `PATH`, * `HOME`, locale, and proxy variables survive, so child CLIs run normally; - * harness identity never leaks implicitly (a child that needs current `DSH_*` - * facts receives them through {@link SubprocessSpawnSpec.dshEnv}, and a - * deliberately forwarded credential goes through an explicit env layer, which - * merges after this scrub). Exported as a plain function so spawners that - * cannot route through the service (node-pty backends, SDK-managed + * harness identity never leaks implicitly (a deliberately forwarded + * credential or current `DSH_*` fact goes through the spec's explicit `env`, + * which merges after this scrub). Exported as a plain function so spawners + * that cannot route through the service (node-pty backends, SDK-managed * transports) share the one scrub definition. * @returns a fresh environment object safe to hand to a child spawn. */ @@ -62,27 +60,6 @@ export function scrubbedParentEnv(): Record { return env } -/** - * Partition one mixed explicit-env map onto the spec's two channels: `DSH_*` - * names are deployment-owned facts for the child and take the managed - * {@link SubprocessSpawnSpec.dshEnv} channel (the ordinary channel rejects the - * reserved namespace), everything else stays ordinary `env`. For consumers - * whose configs expose a single env map (lsp-local servers, the ACP backend) - * rather than two channel-shaped fields. - * @param env - explicit entries from a consumer's config, both namespaces mixed. - * @returns the two spec channels, each safe for its validator. - */ -export function splitEnvChannels(env: Readonly>): { env: Record; dshEnv: DshEnvironment } { - const ordinary: Record = {} - const managed: Record = {} - const isDshKey = (key: string): key is DshEnvironmentKey => key.startsWith(DSH_ENV_PREFIX) - for (const [key, value] of Object.entries(env)) { - if (isDshKey(key)) managed[key] = value - else ordinary[key] = value - } - return { env: ordinary, dshEnv: managed } -} - declare module 'cordis' { interface Context { subprocess: SubprocessService diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 4c667919c7..c936b7bd78 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -93,19 +93,14 @@ export interface SubprocessSpawnSpec { */ signal?: AbortSignal | undefined /** - * Ordinary environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and - * belong in {@link dshEnv}; a deliberately forwarded credential-shaped - * entry survives because this layer merges after the scrub. + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. */ env?: Record | undefined - /** - * Harness-owned `DSH_*` variables for this execution. The scrubbed base has - * already discarded ambient `DSH_*` entries, so an unavailable current fact - * cannot inherit a stale value from the harness process; non-`DSH_*` names - * on this channel are rejected. - */ - dshEnv?: DshEnvironment | undefined } /** diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 9fb68f403f..d033e24a31 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { scrubbedParentEnv, splitEnvChannels, SubprocessService } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** @@ -72,10 +72,4 @@ describe('SubprocessService seam', () => { delete process.env.SCRUB_PROBE_PLAIN } }) - - it('splitEnvChannels partitions a mixed map onto the two spec channels', () => { - const { env, dshEnv } = splitEnvChannels({ DSH_FACT: 'managed', PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' }) - expect(env).toEqual({ PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' }) - expect(dshEnv).toEqual({ DSH_FACT: 'managed' }) - }) }) From f81fcccd9376ca06de0430fb16992c8c832a0845 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:41:04 +0800 Subject: [PATCH 46/75] refactor(subprocess): keep terminate() as the seam's only termination verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete kill(signal?) from SubprocessHandle: consumers stop a process only through terminate()'s tree-scoped SIGTERM→graceMs→SIGKILL escalation (idempotent, also driven by the spec's abort signal, a no-op once the tree is gone). The single-signal verb had exactly one consumer family — lsp-local — and what it bought there was a private re-implementation of the same escalation. The internal kill closure stays in spawn.ts as the dispose ladder's tier primitive; terminate() now routes through it too. lsp-local collapses onto the seam's escalation: - LspConnection replaces its terminate()/kill() pair with one terminate() that delegates to handle.terminate(). Behavior change: the framing-failure path terminates instead of instant SIGKILL, so a misbehaving server now gets SIGTERM plus the killGraceMs window to flush before SIGKILL. - ConnectionSpec.pipeDrainGraceMs becomes killGraceMs: one grace, the spawn spec's graceMs, drives both the escalation window and post-exit pipe draining (the provider already passed killGraceMs for it). - LspInstance.forceTerminate() drops its hand-rolled bounded first wait (LSP_KILL_GRACE) and escalateProcessTree (deleted with its export and unit test): the seam's escalation already commits to SIGKILL after killGraceMs, so only the unbounded quiescence awaits stay load-bearing. Tests: kill()-shaped spawn specs become terminate()-shaped or fold into the terminate() suites (group-wide delivery; the settled no-op case was already pinned by 'terminate() after the tree died'); tree-survivor coverage is intact. A stderr-'inherit' disposition test completes the stdout/stderr symmetry so the scoped subprocess+lsp coverage gate stands alone instead of leaning on subagent-acp's cross-package runs. Docs: SubprocessHandle type-equiv block, seam/impl/group READMEs, and the consumer-migration Agent Note lose the kill(signal?) vocabulary (zh pairs re-recorded); cordis api/services catalogs regenerated. --- ...26-subprocess-consumer-migration.i18n.yaml | 4 +- ...026-07-26-subprocess-consumer-migration.md | 4 +- ...-07-26-subprocess-consumer-migration.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- .../core-data-structures/subprocess.i18n.yaml | 4 +- docs/core-data-structures/subprocess.md | 14 ++---- docs/core-data-structures/subprocess.zh.md | 14 ++---- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/lsp/lsp-local/src/connection.ts | 24 ++++------ packages/lsp/lsp-local/src/index.ts | 2 - packages/lsp/lsp-local/src/instance.ts | 26 +++------- .../lsp/lsp-local/tests/connection.spec.ts | 12 ++--- packages/lsp/lsp-local/tests/instance.spec.ts | 13 +---- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/spawn.ts | 24 +++++----- .../subprocess-local/tests/spawn.spec.ts | 47 +++++++------------ .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 8 ++-- packages/subprocess/subprocess/src/types.ts | 12 ++--- .../subprocess/tests/service.spec.ts | 2 - 27 files changed, 87 insertions(+), 155 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 4326462cbf..7bfb3b6a74 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.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 -2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 41bdf04bc03517cf9fe10a61a565e442ecee5520 +2026-07-26-subprocess-consumer-migration.md: 9353e515e4d700e59bc771b5e38648594e58466e +2026-07-26-subprocess-consumer-migration.zh.md: fa7d8dfdbda6ddbf30e4799d130a71e1a476ab42 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md index 805f27ba2e..9353e515e4 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -14,7 +14,7 @@ The seam's vocabulary is now Node-shaped, and every spawner that can ride the se - **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch). - **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome. -- **Tree-scoped termination, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. +- **Tree-scoped termination behind one verb**: `terminate()` owns the SIGTERM→grace→SIGKILL escalation (serves the spec's abort signal too, and is a no-op once the tree is gone) — the handle exposes no single-signal `kill(signal?)`, so a consumer cannot skip the grace window; `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. - **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well. Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is `handle.dispose` with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). @@ -35,4 +35,4 @@ Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` Bought: one implementation of tree signalling, escalation, the dispose ladder, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. -Cost: the seam is wider — three stdio modes and four termination verbs instead of one of each — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. +Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 41bdf04bc0..fa7d8dfdbd 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -14,7 +14,7 @@ Status: implemented - **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 -- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 +- **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 - **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 @@ -35,4 +35,4 @@ Status: implemented 换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 -代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0b43c245a..4bba20e17c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1567,7 +1567,7 @@ Implementations must honor these semantics: - spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level 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.kill signals without escalation, SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and SubprocessHandle.dispose runs the cooperative EOF-first ladder — all tree-scoped on every platform. +- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — and SubprocessHandle.dispose runs the cooperative EOF-first ladder; both tree-scoped on every platform. - Disposal of the service terminates all still-running managed processes and awaits their exit. ```ts cordis-catalog diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 0527cf34a3..55304600c3 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/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 -subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 -subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 +subprocess.md: b6c316b079177f052302c0b456d300bc54d9ccb6 +subprocess.zh.md: 421ab335b02dfbd20eabd121b96e290eb12d172c diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index cdd4507c7d..b6c316b079 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and tree-scoped termination -A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. ```ts type-equiv /** @@ -157,17 +157,11 @@ interface SubprocessHandle { readonly collected: SubprocessCollectedOutputs /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** - * Send one signal to the process tree, Node-style — no escalation, no - * timers. A no-op after the outcome has settled (the pid may be reused). - * @param signal - the signal to deliver (default `SIGTERM`; Windows - * force-terminates the tree for any value). - */ - kill(signal?: NodeJS.Signals): void /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately). Idempotent; also triggered by the - * spec's abort signal. + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. */ terminate(): void /** diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 78325c3255..421ab335b0 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与以进程树为范围的终止 -spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 ```ts type-equiv /** @@ -157,17 +157,11 @@ interface SubprocessHandle { readonly collected: SubprocessCollectedOutputs /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** - * Send one signal to the process tree, Node-style — no escalation, no - * timers. A no-op after the outcome has settled (the pid may be reused). - * @param signal - the signal to deliver (default `SIGTERM`; Windows - * force-terminates the tree for any value). - */ - kill(signal?: NodeJS.Signals): void /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately). Idempotent; also triggered by the - * spec's abort signal. + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. */ terminate(): void /** diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index cf879df592..d78f77f796 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2227,7 +2227,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', }, { name: 'SubprocessOutcome', diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 74f68288e6..62101a403b 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -30,11 +30,11 @@ export interface ConnectionSpec { /** Largest stderr tail retained for diagnostics. */ readonly maxStderrBytes: number /** - * Bound (ms) for draining pipes a surviving helper still holds after the - * server exits; the instance passes its kill grace so exit observation is - * never slower than the escalation it feeds. + * 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 pipeDrainGraceMs: number + readonly killGraceMs: number /** Static answer to every `workspace/configuration` item. */ readonly configuration: unknown } @@ -98,7 +98,7 @@ export class LspConnection { stdout: 'pipe', stderr: { maxBytes: spec.maxStderrBytes }, }, - graceMs: spec.pipeDrainGraceMs, + graceMs: spec.killGraceMs, // spec.env mixes the scrubbed base with explicit config entries; a // configured DSH_* fact takes the managed channel the seam reserves. ...splitEnvChannels(spec.env), @@ -210,14 +210,9 @@ export class LspConnection { return this.nextId } - /** Request termination of the server's process tree (SIGTERM, no escalation). */ + /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */ terminate(): void { - this.handle.kill('SIGTERM') - } - - /** Force termination of the server's process tree. */ - kill(): void { - this.handle.kill('SIGKILL') + this.handle.terminate() } /** @@ -235,9 +230,10 @@ export class LspConnection { messages = this.decoder.push(chunk) } catch (error) { // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and - // SIGKILL the whole group so helper processes don't outlive the leader. + // terminate the whole group so helper processes don't outlive the leader (SIGTERM first, then + // the kill grace's SIGKILL — a misbehaving server still gets its bounded flush window). this.fail(asError(error)) - this.handle.kill('SIGKILL') + this.handle.terminate() return } for (const message of messages) this.dispatch(message) diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index b9699706aa..d926ae9428 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -285,8 +285,6 @@ class LocalLspProvider implements LspProvider { initializationOptions: this.config.initializationOptions, maxMessageBytes: this.config.maxMessageBytes, maxStderrBytes: this.config.maxStderrBytes, - // Exit observation must never be slower than the escalation it feeds. - pipeDrainGraceMs: this.config.killGraceMs, shutdownTimeoutMs: this.config.shutdownTimeoutMs, killGraceMs: this.config.killGraceMs, } diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 0718bb6b82..c1f78eaa38 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -35,17 +35,6 @@ export interface InstanceSpec extends ConnectionSpec { readonly initializationOptions: unknown /** Graceful `shutdown`/`exit` budget before escalation (ms). */ readonly shutdownTimeoutMs: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ - readonly killGraceMs: number -} - -/** - * Force-kill a process tree only when graceful termination did not make it exit. - * @param treeExited - whether the tree exited within its grace period. - * @param forceKill - forceful process-tree termination primitive. - */ -export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void { - if (!treeExited) forceKill() } /** @@ -311,17 +300,14 @@ export class LspInstance { await abortable(this.connection.closed, signal) } - /** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */ + /** + * 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() - const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') - let treeExited: boolean - try { - treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal) - } finally { - graceDeadline[Symbol.dispose]() - } - escalateProcessTree(treeExited, this.connection.kill.bind(this.connection)) await Promise.all([ this.connection.closed, this.connection.waitForProcessTreeExit(), diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index c717e3c886..c07fa77909 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -14,7 +14,7 @@ let open: LspConnection[] = [] afterEach(async () => { for (const conn of open) { - conn.kill() + conn.terminate() await conn.closed } open = [] @@ -33,7 +33,7 @@ function connect( env: { ...scrubbedParentEnv(), ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - pipeDrainGraceMs: 3_000, + killGraceMs: 3_000, configuration: { setting: 42 }, }, spawnSubprocess, (method, params) => { seen?.push({ method, params }) @@ -66,10 +66,10 @@ describe('LspConnection', () => { await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) }) - it('treats signaling an already-closed child as a teardown race', async () => { + it('treats terminating an already-closed child as a teardown race', async () => { const conn = connectScript('') await conn.closed - expect(() => { conn.kill() }).not.toThrow() + expect(() => { conn.terminate() }).not.toThrow() }) it('answers a server workspace/configuration request from static config', async () => { @@ -152,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec env: scrubbedParentEnv(), maxMessageBytes: 16_000_000, maxStderrBytes, - pipeDrainGraceMs: 3_000, + killGraceMs: 3_000, configuration: null, }, spawnSubprocess, () => Promise.resolve(null), writer) open.push(conn) @@ -168,7 +168,7 @@ describe('LspConnection edge behavior', () => { env: {}, maxMessageBytes: 1000, maxStderrBytes: 1000, - pipeDrainGraceMs: 3_000, + killGraceMs: 3_000, configuration: null, }, spawnSubprocess, () => Promise.resolve(null)) open.push(conn) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 7d0ca74d0c..75b9e541fc 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,7 +6,6 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' -import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' @@ -45,7 +44,6 @@ function makeInstance( initializationOptions: { init: true }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - pipeDrainGraceMs: 200, shutdownTimeoutMs: 200, killGraceMs: 200, ...overrides, @@ -75,7 +73,6 @@ function scriptInstance(script: string, overrides: Partial = {}): initializationOptions: null, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - pipeDrainGraceMs: 150, shutdownTimeoutMs: 150, killGraceMs: 150, ...overrides, @@ -258,14 +255,6 @@ describe('LspInstance query and abort', () => { }) describe('LspInstance disposal', () => { - it('escalates only when the process tree survives its grace period', () => { - const forceKill = vi.fn() - escalateProcessTree(false, forceKill) - expect(forceKill).toHaveBeenCalledOnce() - escalateProcessTree(true, forceKill) - expect(forceKill).toHaveBeenCalledOnce() - }) - it('lets a server finish protocol exit before signal escalation', async () => { const marker = join(root, 'graceful-exit.log') const instance = makeInstance({ diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 5224774c27..f536302eea 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 -README.md: 657855aff67230ee22b8137ae3aabc76aff8f860 -README.zh.md: 5281a0d6eddb38974d1225220bab08880224f14b +README.md: 64e4740c7ac2706e45bb3517891504bf31a6109b +README.zh.md: e30b6c7f51ed0dffba15a6d1dff632e4ff4c6402 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 657855aff6..64e4740c7a 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -6,7 +6,7 @@ The shared home for spawning managed child-process trees: fully-specified spawn | Package | ctx key | Role | |---|---|---| -| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, kill/terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | +| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal | 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 5281a0d6ed..e30b6c7f51 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -6,7 +6,7 @@ spawn 受管子进程树的共用归属位置:完全显式的 spawn spec,其 | 包(package) | ctx 键 | 角色 | |---|---|---| -| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、kill/terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose | 服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2e8c1fdae4..d7ad897392 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 -README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 -README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 +README.md: b16d5e9a7eabb39db549b9fd6452e8fecee73022 +README.zh.md: 81b3f9ec8341a73732e2cf342bb0f9fe5c290357 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 08cc2ce7d9..b16d5e9a7e 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README ## Behavior (and where it came from) -- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **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 + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. 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-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index da230ba37d..81b3f9ec83 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为(以及设计来源) -- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止动词)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束),进程树消亡后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 - **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8716f50b0c..1e069bbb90 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -392,11 +392,12 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } } - const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => { - // Guard on TREE liveness, not outcome settlement: a TERM-trapping helper - // can outlive the settled direct child and must stay signalable, while a - // fully-dead tree (possible pid reuse) must not be re-signalled from a - // caller's finally block. + // The dispose ladder's tier primitive (not on the handle — terminate() is + // the only consumer-facing termination verb). Guards on TREE liveness, not + // outcome settlement: a TERM-trapping helper can outlive the settled direct + // child and must stay signalable, while a fully-dead tree (possible pid + // reuse) must not be re-signalled by a later tier. + const kill = (sig: NodeJS.Signals): void => { if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } @@ -404,15 +405,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const terminate = (): void => { if (graceTimer !== undefined) return // escalation already in flight if (!treeAlive()) return - signalTree(platform, pid, 'SIGTERM', child, taskkill) + kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying // does not mean the tree died — so settle does not clear this timer, and - // it re-probes tree liveness before force-killing. It stays ref'd: the - // pending SIGKILL is a commitment, and a parent exiting before it fires - // would orphan a trapped survivor. Self-bounds at graceMs. - graceTimer = setTimeout(() => { - if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill) - }, spec.graceMs) + // kill() re-probes tree liveness before force-killing. It stays ref'd: + // the pending SIGKILL is a commitment, and a parent exiting before it + // fires would orphan a trapped survivor. Self-bounds at graceMs. + graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } // The caller owns timeout classification; this layer only reacts to abort. @@ -514,7 +513,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter ...stderrCollector !== undefined ? { stderr: stderrCollector } : {}, }, done, - kill, terminate, waitForExit, dispose, diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4b3e16fd5a..96d9fed7ac 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -165,26 +165,15 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe('SIGKILL') }) - it('kill() sends one signal Node-style, without escalation', async () => { - const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60', { graceMs: 100 })) - await waitForStdout(running, 'armed\n') - running.kill() // trapped SIGTERM, no SIGKILL follow-up - await new Promise(resolve => setTimeout(resolve, 400)) - expect(running.collected.stdout).toBeDefined() - running.kill('SIGKILL') // explicit signal choice, still no timers - const result = await running.done - expect(result.signal).toBe('SIGKILL') - }) - - it('kills the whole process group (grandchildren die too)', async () => { - // The subshell writes the sleep's pid then waits on it; killing the + it('terminates the whole process group (grandchildren die too)', async () => { + // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) expect(grandchild).toBeGreaterThan(0) - running.kill() + running.terminate() const result = await running.done expect(result.signal).toBe('SIGTERM') await waitGone(grandchild) @@ -452,22 +441,6 @@ describe('killGroup', () => { expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) - it('handle.kill() after the tree died delivers no termination signal', async () => { - // Cleanup code commonly kills handles in a finally; once the tree is gone - // the pid may be reused, so a late kill must deliver nothing (the - // liveness PROBE — signal 0 — is the only process.kill allowed). - const running = spawnSubprocess(spec('true')) - await running.done - await running.waitForExit() - const spy = vi.spyOn(process, 'kill') - try { - running.kill() - const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) - expect(delivered).toEqual([]) - } finally { - spy.mockRestore() - } - }) }) describe('stdio dispositions', () => { @@ -541,7 +514,7 @@ describe('dispose ladder', () => { }) describe('windows tree semantics (injected platform)', () => { - it('kill and terminate route through taskkill by root pid', async () => { + it('terminate routes through taskkill by root pid', async () => { const killed: number[] = [] const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { spillDir, @@ -672,6 +645,18 @@ describe('coverage seams', () => { expect(running.collected.stderr!.readFrom(0).text).toBe('err\n') }) + it("an 'inherit' stderr with collected stdout wires only the requested collector", async () => { + const running = spawnSubprocess({ + ...spec('echo out; echo to-parent >&2'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'inherit' }, + }) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(running.stderr).toBeUndefined() + expect(running.collected.stderr).toBeUndefined() + expect(running.collected.stdout!.readFrom(0).text).toBe('out\n') + }) + it('terminate() after the tree died delivers no termination signal', async () => { const running = spawnSubprocess(spec('true')) await running.done diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 31f0fc4e84..26a157c442 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 -README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 -README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 +README.md: d760be118e5eaf41d038a5854a8e129cdc41c349 +README.zh.md: a174724e51ce537b3b7121bf6e1fa442d182fe1d diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 2cb7a5ebce..d760be118e 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -9,7 +9,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` 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 is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). +- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - 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 a3211834e0..a174724e51 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -9,7 +9,7 @@ - `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 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 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index e4cfaae901..d69b08c05f 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -102,10 +102,10 @@ declare module 'cordis' { * 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. - * - {@link SubprocessHandle.kill} signals without escalation, - * {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates - * SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the - * cooperative EOF-first ladder — all tree-scoped on every platform. + * - {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates + * SIGTERM→grace→SIGKILL — the only termination verb — and + * {@link SubprocessHandle.dispose} runs the cooperative EOF-first ladder; + * both tree-scoped on every platform. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. */ diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 4c667919c7..1c831c2805 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -206,17 +206,11 @@ export interface SubprocessHandle { readonly collected: SubprocessCollectedOutputs /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** - * Send one signal to the process tree, Node-style — no escalation, no - * timers. A no-op after the outcome has settled (the pid may be reused). - * @param signal - the signal to deliver (default `SIGTERM`; Windows - * force-terminates the tree for any value). - */ - kill(signal?: NodeJS.Signals): void /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately). Idempotent; also triggered by the - * spec's abort signal. + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. */ terminate(): void /** diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 9fb68f403f..ec8718dfa9 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -21,7 +21,6 @@ class StubSubprocessService extends SubprocessService { stderr: undefined, collected, done: Promise.resolve({ exitCode: 0, signal: null }), - kill: () => {}, terminate: () => {}, waitForExit: () => Promise.resolve(true), dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(), @@ -41,7 +40,6 @@ describe('SubprocessService seam', () => { }) expect(handle.pid).toBe(1) expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) - handle.kill() handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined() From 9d2667f900bde40b125a3e735223e22af57e6336 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:43:33 +0800 Subject: [PATCH 47/75] docs: restore the rejected landstrip note's lifecycle folder The origin/master merge's directory-rename detection relocated the trio to implemented/feature/ (master's note-archiving sweep renamed many implemented/ files, and the rejection move predated the merge); move it back to rejected/feature/ where the rejection commit put it. --- ...26-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml | 0 .../2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md | 0 .../2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename .agents/notes/{implemented => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml (100%) rename .agents/notes/{implemented => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md (100%) rename .agents/notes/{implemented => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md (100%) diff --git a/.agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml similarity index 100% rename from .agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml diff --git a/.agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md similarity index 100% rename from .agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md diff --git a/.agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md similarity index 100% rename from .agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md From 7d6450f57c1ebe7b79e747ab5ab986fed82ee578 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:57:30 +0800 Subject: [PATCH 48/75] docs: regenerate the module graph after the master merge The merge composed master's new package edges with this branch's dsh-timeout dependency removal; the checked-in graph had neither. --- docs/module-graph.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 07569ee5f5..0c90a30dba 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -295,7 +295,6 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess - pkg_subprocess_local --> pkg_timeout pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -901,7 +900,7 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | From 187cf6f804bfef9800f3e07e1629207af92eac0c Mon Sep 17 00:00:00 2001 From: NI0317 Date: Mon, 27 Jul 2026 12:38:11 +0800 Subject: [PATCH 49/75] feat(web): delete workspace registrations --- ...-07-25-workspace-ui-product-flow.i18n.yaml | 6 +- .../2026-07-25-workspace-ui-product-flow.md | 8 +- ...2026-07-25-workspace-ui-product-flow.zh.md | 8 +- ...-workspace-registration-deletion.i18n.yaml | 6 + ...6-07-27-workspace-registration-deletion.md | 53 +++++++++ ...7-27-workspace-registration-deletion.zh.md | 53 +++++++++ ...-domain-kv-storage-and-workspace.i18n.yaml | 6 +- ...6-07-24-domain-kv-storage-and-workspace.md | 14 ++- ...7-24-domain-kv-storage-and-workspace.zh.md | 14 ++- apps/web/tests/workspace-management.e2e.ts | 103 ++++++++++++++++-- docs/cordis-catalog/services.md | 10 ++ .../client/connection/src/client/fixture.ts | 15 +++ packages/client/connection/tests/fake-api.ts | 1 + .../client/connection/tests/fixture.spec.ts | 25 +++++ packages/client/runtime/README.i18n.yaml | 6 +- packages/client/runtime/README.md | 4 +- packages/client/runtime/README.zh.md | 4 +- .../runtime/src/client/workspaces/manager.ts | 46 +++++++- .../runtime/src/client/workspaces/service.ts | 10 ++ packages/client/runtime/tests/fake-api.ts | 4 + .../runtime/tests/workspaces-service.spec.ts | 58 ++++++++++ packages/client/ui-workspace/README.i18n.yaml | 6 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- .../src/client/WorkspaceBrowser.module.css | 10 ++ .../src/client/WorkspaceBrowser.tsx | 74 ++++++++++++- .../ui-workspace/src/client/contract/slots.ts | 2 + .../client/ui-workspace/src/client/index.ts | 1 + .../ui-workspace/src/client/rows/Rows.tsx | 14 +-- .../client/ui-workspace/tests/rows.spec.tsx | 8 +- .../tests/workspace-browser.spec.tsx | 69 ++++++++++++ .../cordis/tool-cordis/src/api-catalog.ts | 4 + packages/host/apiproxy/README.i18n.yaml | 6 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 23 +++- .../host/apiproxy/src/api/events.schema.ts | 3 +- packages/host/apiproxy/src/api/events.ts | 5 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/workspace.schema.ts | 10 ++ packages/host/apiproxy/src/api/workspace.ts | 8 ++ packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 27 +++++ .../apiproxy/tests/client-handler.spec.ts | 5 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 13 +++ packages/workspace/README.i18n.yaml | 6 +- packages/workspace/README.md | 4 +- packages/workspace/README.zh.md | 4 +- packages/workspace/workspace/README.i18n.yaml | 6 +- packages/workspace/workspace/README.md | 3 +- packages/workspace/workspace/README.zh.md | 3 +- packages/workspace/workspace/src/index.ts | 39 +++++++ packages/workspace/workspace/src/invariant.ts | 5 +- .../workspace/tests/invariant.spec.ts | 2 +- .../workspace/tests/workspace.spec.ts | 34 ++++++ 57 files changed, 786 insertions(+), 84 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index 3295a845f3..b8266cdd49 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b -2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +2026-07-25-workspace-ui-product-flow.md: b8e1ec1efe19127cad8a12405dddeec38a4ff91e +2026-07-25-workspace-ui-product-flow.zh.md: b80b75a80671e9aa2ab59ff72c44c18a8ec5c16e diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index a02087235a..b8e1ec1efe 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -21,10 +21,11 @@ The Host provides the following GUI wiring on the Workspace entity: | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | | `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path | +| `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | -`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. +`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. @@ -51,7 +52,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope. +Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -75,6 +76,8 @@ A frontend Session Intent appears as a “New session” row and temporarily cou Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. +Deleting a Workspace registration removes its group without deleting or closing any Session. Its accounted Sessions immediately join Ungrouped, including the current Session; a reload reconstructs the same result from the independent Workspace and Session baselines. + ### React and slot boundaries React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer. @@ -106,6 +109,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. - Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 8ccbf5b984..b80b75a806 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -21,10 +21,11 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | | `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 | +| `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。 +`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 @@ -51,7 +52,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编和显示名/目录名双输入不在本期范围。 +Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -75,6 +76,8 @@ Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定 无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 +删除 Workspace 注册记录会移除其分组,但不会删除或关闭任何 Session。已记账的 Session(包括当前 Session)会立即进入 Ungrouped;刷新后,独立的 Workspace 与 Session 基线会重建出相同结果。 + ### React 与 slot 边界 React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。 @@ -106,6 +109,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 - UI 与 Host 两层拒绝同名 Workspace;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml new file mode 100644 index 0000000000..847b040457 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md +2026-07-27-workspace-registration-deletion.md: cae01d529bc6fd97da6fb61839bd5ec8e21557e2 +2026-07-27-workspace-registration-deletion.zh.md: 76377ebc5e93101e1e3efce1d29c3c654df032c2 diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md new file mode 100644 index 0000000000..cae01d529b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md @@ -0,0 +1,53 @@ +# Agent Note: Workspace Registration Deletion + +Status: implemented + +English | [中文](2026-07-27-workspace-registration-deletion.zh.md) + +## Problem + +A Workspace registers an existing code directory so the GUI can name it and order its Sessions. That record has no reliable provenance proving that Harness created or owns the directory, and the Session log is an independent persistence object. Treating the row's Delete action as recursive source deletion or Session deletion would destroy data outside the record's ownership boundary. + +The existing visual-only menu row also left deletion semantics undefined across durable order, the Workspace table, Host streams, concurrent browser tabs, reconnect baselines, and a list request racing the mutation. + +## Decision + +`ctx.workspace.delete(id)` deletes only the Workspace registration: its id leaves durable `workspaceIds`, its `workspaces` table row and entity-cache entry disappear, and its ordered `sessionIds` account disappears with that row. It never calls filesystem removal or `SessionPersistence`; the directory, every user file, every live Session, and every persisted Session log remain. Because sidebar grouping is the complement of all surviving Workspace accounts, those Sessions immediately appear under Ungrouped, including the current Session. + +Unknown ids return `false` at the domain seam. `workspace.delete({ workspaceId })` maps that distinction to `workspace-not-found`; success returns `{ deleted: true }`. `workspace.list` remains the reconnect baseline. + +## Durable commit and publication + +Registry operations serialize create and delete. Deletion first writes the Workspace order without the id, then removes the entity from the cache, then deletes the table row. The table deletion is the notification commit point: the package invariant accepts it only after the cache stopped publishing the entity, and the Host emits `host/workspace-removed` only from that committed deletion. A table-write failure restores the cache and prior durable order; no removal frame is published. + +The Host stream keeps its committed-id set through the preceding global-order write and removes the id only on the table deletion. Create rollback therefore emits no false removal, while every connected tab receives exactly the id needed to delete its projection. + +## Client convergence + +`WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta. + +## Confirmation interaction + +The existing Workspace row menu opens a shared `Modal` before deletion. The text states all three consequences: the Workspace leaves the list, the folder and session logs remain, and its Sessions appear under Ungrouped. While the request is pending, the confirm and Cancel controls are disabled, duplicate confirmation is ignored, and Escape or Close cannot dismiss the operation. Failure keeps the Modal open with the error; Cancel, Escape, and Close before submission never delete. + +The menu, Modal, and buttons retain their existing structure and design tokens. Session deletion remains visual-only and outside this decision. + +## Alternatives considered + +**Cascade-delete Sessions.** Rejected because Workspace registration does not own Session persistence and the product requirement is to preserve histories under Ungrouped. Session deletion needs its own lifecycle, running checks, descendant semantics, and explicit UI. + +**Move the folder to Trash.** Rejected because the record cannot prove directory ownership. A future destructive filesystem action must be separately named, separately confirmed, and enforce explicit safety boundaries. + +**Delete the table row and repair order later.** Rejected because a crash or write failure would leave an initialized registry whose order and table disagree. The registry updates both under one serialized operation and restores the prior order on table failure. + +**Refetch both lists after success.** Rejected because the committed removal frame plus immediate unary echo is sufficient, preserves the current Session object, and avoids turning a local mutation into two list requests. Reconnect baselines remain the repair path. + +## Verification + +Workspace package tests pin successful metadata-only deletion, unknown-id idempotence, table-failure rollback, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close. + +The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload. + +## Consequences + +Deleting a Workspace is intentionally reversible by registering the same directory again, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns. diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md new file mode 100644 index 0000000000..76377ebc5e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md @@ -0,0 +1,53 @@ +# Agent Note(agent 决策记录):删除 Workspace 注册记录 + +Status: implemented + +[English](2026-07-27-workspace-registration-deletion.md) | 中文 + +## Problem + +Workspace 注册已有代码目录,使 GUI 能够为目录命名,并对其会话排序。该记录没有可靠的来源信息来证明 Harness 创建或拥有该目录,会话日志也是独立的持久化对象。若将行内 Delete 操作视为递归删除源码或删除会话,就会破坏该记录所有权边界之外的数据。 + +现有菜单行仅提供视觉效果,因此持久顺序、Workspace 表、Host 流、并发浏览器标签页、重连基线,以及列表请求与变更并发时的删除语义也没有定义。 + +## Decision + +`ctx.workspace.delete(id)` 只删除 Workspace 注册记录:其 id 会从持久 `workspaceIds` 中移除,`workspaces` 表行与实体缓存条目会消失,有序 `sessionIds` 账本也随该行一并消失。它绝不调用文件系统移除操作或 `SessionPersistence`;目录、所有用户文件、所有实时会话和所有持久化会话日志都会保留。侧边栏分组是所有存续 Workspace 账本的补集,因此这些会话(包括当前会话)会立即出现在 Ungrouped 下。 + +未知 id 在 domain seam 返回 `false`。`workspace.delete({ workspaceId })` 将该结果映射为 `workspace-not-found`;成功时返回 `{ deleted: true }`。`workspace.list` 仍是重连基线。 + +## 持久提交与发布 + +注册表操作会串行执行创建与删除。删除时先写入移除该 id 后的 Workspace 顺序,再从缓存中移除实体,最后删除表行。表删除是通知提交点:只有缓存停止发布该实体后,包不变量才接受该删除;Host 也只根据这次已提交的删除发出 `host/workspace-removed`。表写入失败时,系统会恢复缓存和此前的持久顺序,且不会发布移除帧。 + +Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合,只在删除表行时移除该 id。因此,创建回滚不会发出错误的移除帧,而每个已连接标签页都能收到从自身投影中删除该记录所需的准确 id。 + +## 客户端收敛 + +`WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 + +## 确认交互 + +现有 Workspace 行菜单会在删除前打开共享 `Modal`。文案明确说明三项后果:Workspace 会从列表中移除,文件夹和会话日志会保留,相关会话会出现在 Ungrouped 下。请求待处理期间,确认与 Cancel 控件均被禁用,重复确认会被忽略,Escape 或 Close 也无法关闭此次操作。失败时 `Modal` 保持打开并显示错误;提交前使用 Cancel、Escape 或 Close 绝不会触发删除。 + +菜单、`Modal` 和按钮保留现有结构与设计 token。会话删除仍仅提供视觉效果,不在本决策范围内。 + +## Alternatives considered + +**级联删除会话。** 不予采纳,因为 Workspace 注册记录不拥有会话持久化,且产品需求是将历史记录保留在 Ungrouped 下。会话删除需要自己的生命周期、运行状态检查、后代对象的处理语义和明确 UI。 + +**将文件夹移到废纸篓。** 不予采纳,因为该记录无法证明目录所有权。未来的破坏性文件系统操作必须使用单独名称、单独确认,并实施明确的安全边界。 + +**先删除表行,之后再修复顺序。** 不予采纳,因为崩溃或写入失败会使已初始化注册表的顺序与表不一致。注册表会在同一串行操作内更新二者,并在表操作失败时恢复此前顺序。 + +**成功后重新拉取两个列表。** 不予采纳,因为已提交的移除帧与即时一元回显已足够,既能保留当前会话对象,也避免将局部变更扩大为两次列表请求。重连基线仍是修复路径。 + +## Verification + +Workspace 包测试固定了仅删除元数据的成功路径、未知 id 的幂等行为、表操作失败回滚,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。 + +组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。 + +## Consequences + +删除 Workspace 后仍可重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。 diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml index 5ab7a8229d..6e5f8b8391 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-24-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f -2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +2026-07-24-domain-kv-storage-and-workspace.md: 230877628428dc88dbddeecfe5f4353cf15e151d +2026-07-24-domain-kv-storage-and-workspace.zh.md: 050f72cd3327f83e2c3f3cefcab63c01e8f112ee diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md index cd666a47a3..2308776284 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md @@ -11,7 +11,9 @@ The host's only persistence surface is the session event log (`packages/session- - **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned). - **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates. -Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code. +Separately, Session deletion needs a `SessionPersistence` delete primitive and a `session.delete` endpoint. That gap's design is settled in this note, but its implementation remains future work. + +The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) supersedes only that coupling: deleting a Workspace registration preserves its Sessions and their logs, while Session deletion remains separate future work. The cascade design below is therefore not the Workspace GUI delete semantic. ## Proposal @@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service { get(id: WorkspaceId): Workspace | undefined list(): Workspace[] resolveByPath(path: string): Promise // 同 realpath 口径,故 async - // delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口 + delete(id: WorkspaceId): Promise // 只删注册记录;目录与 session 日志保留 } ``` - **Path canon**: the stored value = `fs.realpath(input)` (trailing slashes, `..`, and symlinks all resolved); uniqueness = string equality after normalization (a symlink resolving to the same directory counts as a collision). A missing directory makes create reject outright (realpath fails — a workspace must point at an existing directory; "Create new = make the directory" is upper-layer interaction: mkdir first, then create). The session cwd in attach checks follows the same canon. Single-valued cwd + unique path ⇒ one session structurally belongs to at most one workspace; double bookkeeping is impossible on the write side. - **Title**: a display name, defaults to `basename(path)`, mutable, duplicates allowed. Ownership is never derived from cwd as a fallback — cwd cannot express ordering, and ownership is a workspace-side fact; sessions started headless belong to no workspace. - Consumers see only the `Workspace` interface; `WorkspaceEntity` stays inside the package (a single implementation does not pre-split a seam). Entities are unique per id (registry cache); the record snapshot is swapped in place after each write, and the outside sees getters only. Every write funnels through the entity's internal `mutate(fn)` → `table.update`, with `updatedAt` refreshed inside mutate. Domain objects never cross RPC; next phase the wire layer projects records into zod wire schemas. -- **Workspace deletion is future work as a whole** (settled 2026-07-24): the registry ships no delete method this phase — the half-measure "delete the record, keep the sessions" is not exposed; deletion and the session cascade (`recursive` parameter, running checks, bottom-up order, crash-rerun convergence) land as one complete semantic together with the session delete primitive; the order then is delete sessions one by one → prune the ledger → delete the workspace record. +- **Session deletion remains future work.** The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) ships `ctx.workspace.delete(id)` as a metadata-only operation that preserves Sessions and logs. Recursive Session deletion, running checks, and crash-rerun convergence belong to a separate `session.delete` capability. Consistency doctrine (the ledger = the only ownership authority; the implementation and test baseline): @@ -285,7 +287,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha | Not doing | Trigger | Rework point | Groundwork | | --- | --- | --- | --- | -| The full deletion suite (`SessionPersistence.delete`, the deleted event, `registry.delete` cascade, recursive delete, running checks) | future work starts (before the GUI needs delete interactions) | implement per the future-work section above: the session primitive + `registry.delete(id, { recursive? })` land as one | orchestration rules and rejection table settled in this note; no deletion entry exists this phase, so no half-semantics to stay compatible with | +| Session deletion (`SessionPersistence.delete`, the deleted event, recursive delete, running checks) | a destructive Session-delete product flow starts | implement the session primitive plus `session.delete`; keep it independent from Workspace registration deletion | orchestration rules and rejection table above remain groundwork; Workspace deletion preserves Sessions and logs | | The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already | | Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only | | Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process | @@ -296,7 +298,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha | Cross-table atomic transactions | one business operation touching two tables of one domain atomically | `domain.transact(fn)`; JSON whole-unit rewrite is naturally atomic, SQLite wraps a transaction | — | | Secondary indexes / conditional queries | in-memory filtering stops scaling (tens of thousands of records) | SQLite JSON1 over the value column, a read-only query facet on the seam | the JSON backend does not follow | | Moving a session across workspaces | a product need appears | relax the attach check into a "detach first, then attach" orchestration | — | -| RPC/GUI/boot | next phase | `workspace.*` + `session.delete` endpoints, wire schemas, boot mounting, sidebar on real data | this phase's model and semantics are the direct source of the wire projection | +| Session-delete RPC/GUI | a destructive Session-delete product flow starts | `session.delete` endpoint, wire schema, and explicit confirmation UI | Workspace RPC/GUI is shipped separately; no cascade coupling remains | ## Alternatives considered @@ -317,7 +319,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha ## Acceptance criteria - This phase's four test suites all green: the shared backend contract suite on both json/sqlite, registry/mount disposer semantics, the domain layer (including the six open steps and fail-loud routing), and full workspace semantics (create/attach checks/consistency doctrine). -- `ctx.workspace` completes the create → attach → list lifecycle under a test assembly (deletion is future work). +- `ctx.workspace` completes the create → attach → list → metadata-only delete lifecycle under a test assembly. - Zero diff in the session-persistence packages (the acceptance line for not touching the session side this phase). - No new snapshots this phase (no model-visible or assembly surface); added next phase with the RPC wiring. diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md index 81adf1eb6b..050f72cd33 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md @@ -11,7 +11,9 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session-persis - **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。 - **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。 -另外,workspace 删除最终需要删除其关联 session,而 `SessionPersistence` 没有删除原语,host 也没有 `session.delete` 端点——该空白的设计随本 Note 定案,但实施标记为 future work:本期不动 session 侧任何代码。 +另外,Session 删除需要 `SessionPersistence` 删除原语和 `session.delete` 端点。该空白的设计随本 Note 定案,但实现仍属未来工作。 + +后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)取代的仅是上述耦合关系:删除 Workspace 注册记录会保留相关 Session 及其日志,Session 删除仍是独立的未来工作。因此,下文的级联设计并不是 Workspace GUI 的删除语义。 ## Proposal @@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service { get(id: WorkspaceId): Workspace | undefined list(): Workspace[] resolveByPath(path: string): Promise // 同 realpath 口径,故 async - // delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口 + delete(id: WorkspaceId): Promise // 只删注册记录;目录与 session 日志保留 } ``` - **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create)。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace,双重记账写侧不可能。 - **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实;headless 直开的 session 不属于任何 workspace。 - 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(registry 缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update`,`updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。 -- **workspace 删除整体为 future work**(2026-07-24 拍板):本期 registry 不提供 delete 方法——半截的"只删记录留 session"语义不对外暴露,删除与 session 级联(`recursive` 参数、运行中检查、自底向上、崩溃重跑收敛)作为一个完整语义随 session 删除原语一起落地;届时顺序为逐个删 session → 摘账 → 删记录。 +- **Session 删除仍属未来工作。** 后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)已将 `ctx.workspace.delete(id)` 作为仅删除元数据、保留 Session 与日志的操作交付。递归删除 Session、运行中检查和崩溃重跑收敛属于独立的 `session.delete` 能力。 一致性口径(账 = 归属唯一依据;实现与测试基准): @@ -285,7 +287,7 @@ export class WorkspaceRegistry extends Service { | 不做 | 触发条件 | 返工点 | 预埋 | | --- | --- | --- | --- | -| 删除全套(`SessionPersistence.delete`、deleted 事件、`registry.delete` 级联、递归删、运行中检查) | future work 启动(GUI 需要删除交互前) | 按上文 future work 节实施:session 原语 + `registry.delete(id, { recursive? })` 一体落地 | 编排规则/拒绝清单已定案在本 Note;本期无任何删除入口,无半截语义要兼容 | +| Session 删除(`SessionPersistence.delete`、deleted 事件、递归删除、运行中检查) | 破坏性的 Session 删除产品流启动 | 实现 Session 原语及 `session.delete`;与 Workspace 注册记录删除保持独立 | 上文编排规则和拒绝清单仍是基础;Workspace 删除会保留 Session 与日志 | | `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 | | 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 | | 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence) | 进程内已有 `domain/changed` | @@ -296,7 +298,7 @@ export class WorkspaceRegistry extends Service { | 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`;JSON 天然原子,SQLite 包事务 | — | | 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 | | session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — | -| RPC/GUI/boot | 下期 | `workspace.*` + `session.delete` 端点、wire schema、boot 挂载、sidebar 接真数据 | 本期模型与语义即 wire 投影的直接来源 | +| Session 删除 RPC/GUI | 破坏性的 Session 删除产品流启动 | `session.delete` 端点、wire schema 与明确的确认 UI | Workspace RPC/GUI 已独立交付,不再存在级联耦合 | ## Alternatives considered @@ -317,7 +319,7 @@ export class WorkspaceRegistry extends Service { ## Acceptance criteria - 测试矩阵本期四套件全绿:backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。 -- `ctx.workspace` 可在测试组装下完成 create → attach → list 生命周期(删除为 future work)。 +- `ctx.workspace` 可在测试组装下完成 create → attach → list → 仅删除元数据的 delete 生命周期。 - session-persistence 包零 diff(本期不动 session 侧的验收线)。 - 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index a19211c6bf..4dbcca36ae 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -5,12 +5,13 @@ // calls: workspace.create/rename are host RPCs with no model involvement, // and the one session row the flat/hover scenarios need comes from a seeded // fixture (the seeded-history seed reused verbatim — no new recording). -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -105,6 +106,86 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete')) + // Register the scaffold's existing project directory through the real UI. + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) + await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) + await useFolder.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0) + + const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) + if (workspace === undefined) throw new Error('GUI did not register the existing project directory') + await workspace.attachSession(SessionId(SEED_ID)) + const header = (await scaffold.ctx.sessionPersistence.list()) + .find(candidate => candidate.id === SEED_ID) + if (header === undefined) throw new Error('seeded Session log disappeared before deletion') + const logLocation = scaffold.ctx.sessionPersistence.locate(header) + if (logLocation === undefined) throw new Error('JSONL persistence did not expose the seeded log path') + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + + // Open the seeded (first/accounted) Session so deletion must preserve the + // current selection while it moves into Ungrouped. + const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first() + await groupRow.waitFor({ timeout: 10_000 }) + const groupSection = groupRow.locator('..') + if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click() + await expect.poll( + () => groupSection.locator('[role="treeitem"]').count(), + { timeout: 10_000 }, + ).toBeGreaterThanOrEqual(2) + const seededRow = groupSection.locator('[role="treeitem"]').nth(1) + await seededRow.click() + await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true') + + await groupRow.hover() + await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click() + await page.getByRole('menuitem', { name: 'Delete workspace' }).click() + const dialog = page.getByRole('dialog', { name: 'Delete workspace' }) + await dialog.waitFor({ timeout: 10_000 }) + const copy = await dialog.textContent() + expect(copy).toContain('workspace list') + expect(copy).toContain('folder and session logs will be kept') + expect(copy).toContain('sessions will appear under Ungrouped') + await dialog.getByRole('button', { name: 'Delete workspace' }).click() + await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) + + expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined() + await expect.poll( + () => page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).count(), + { timeout: 10_000 }, + ).toBe(0) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) + .toBeGreaterThanOrEqual(1) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 10_000 }, + ).toBe(1) + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 15_000 }, + ).toBe(1) + expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined() + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('switches to the flat "In one list" view and persists the preference', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat')) // Grouped default: workspace group rows render (the seeded session sits @@ -134,12 +215,20 @@ describe('web e2e: workspace management (create / rename / flat view / hover car onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) // Expand Ungrouped to reveal the seeded session row, then dwell on it // (the card opens after a 500ms hover delay, portaled to body). - await page.getByText('Ungrouped', { exact: true }).click() - // A cold summary carries no durable title, so the row falls back to a - // cwd-derived display title — anchored on the run-local workspace-root - // basename rather than a literal. - const wsBase = scaffold.workspaceCwd.split('/').pop()! - const sessionRow = page.locator('[role="treeitem"]').filter({ hasText: wsBase }).first() + const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + const ungroupedSection = ungroupedRow.locator('..') + // Initial-current auto-expansion can race this following test's gesture; + // converge on expanded rather than assuming which update wins first. + await expect.poll(async () => { + if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { + await page.getByText('Ungrouped', { exact: true }).click() + await page.waitForTimeout(50) + } + return await ungroupedRow.getAttribute('aria-expanded') + }, { timeout: 5_000 }).toBe('true') + // The only visible child is the non-blank persisted Session; the blank + // Session created while adopting the Workspace remains hidden. + const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.hover() // Card content: the full title plus the Idle status line (display-only diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cc21210f3b..2c72632c2f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2015,6 +2015,16 @@ get(id: WorkspaceId): Workspace | undefined */ list(): Workspace[] +/** + * Delete one workspace registration while retaining its directory and every + * session log. The durable order is updated before the table deletion; a + * failed table write restores the prior order and keeps the entity + * published. Unknown ids are an idempotent no-op for domain callers. + * @param id - Workspace registration to remove. + * @returns `true` when a record was deleted, `false` when it was unknown. + */ +delete(id: WorkspaceId): Promise + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index eaab0a43f9..fcd04f2460 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -723,6 +723,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { workspace: { ...workspace } }) }, + delete: (request) => { + const { workspaceId } = request.payload + const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + if (index === -1) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + workspaces.splice(index, 1) + emitHost({ type: 'host/workspace-removed', workspaceId }) + return ok(request, { deleted: true as const }) + }, insertSessionBefore: (request) => { const { workspaceId, sessionId, beforeSessionId } = request.payload const workspace = workspaces.find(w => w.workspaceId === workspaceId) @@ -914,6 +928,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) + case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'command.list': return this.api.commands.list(request) // The in-memory execute never blocks, so a never-aborting signal is faithful here. diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bf7295cc50..1c58e604ab 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient { rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), + delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 0374f92b3b..7200291229 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -365,6 +365,31 @@ describe('createFixtureApi', () => { expect(noop.result.value.workspace.updatedAt).toBe(before) }) + it('workspace.delete removes only the Workspace row and emits the removal frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) + expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) + await consuming + expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }]) + const list = await api.workspace.list(req({})) + if (!list.result.ok) throw new Error('workspace list failed') + expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) + const sessions = await api.sessions.list(req({})) + if (!sessions.result.ok) throw new Error('session list failed') + expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha') + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 5e73979b0b..bb4a2d4d2b 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 4724ebc75d441252245a0e811a4ae34f8b529a98 -README.zh.md: 6a0076742efccaf946910c77c77a9b74194b9dc5 +# pnpm run verify-translation-pairing --write packages/client/runtime/README.md +README.md: d2a10b3d97837ac859c52c206afab06913ea222e +README.zh.md: f23f8cb184242edbd6d19aeff5823f1efbee8eba diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4724ebc75d..d2a10b3d97 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -6,7 +6,9 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a ## Workspace and Session lists -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. + +`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6a0076742e..f23f8cb184 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -6,7 +6,9 @@ ## Workspace 与 Session 列表 -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 + +`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index e7caecfe82..83275e9a2a 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -19,6 +19,10 @@ export interface WorkspaceListSnapshot { error: RpcError | null } +type WorkspaceDelta = + | { type: 'upsert'; workspace: WorkspaceView } + | { type: 'remove'; workspaceId: WorkspaceId } + /** Workspace object cluster driven by one list baseline and changed-frame upserts. */ export class WorkspaceManager { private items: Workspace[] = [] @@ -28,7 +32,8 @@ export class WorkspaceManager { private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null private inflight: Promise | null = null - private refreshFrames: WorkspaceView[] | null = null + private refreshFrames: WorkspaceDelta[] | null = null + private readonly removedIds = new Set() private snapshotCache: WorkspaceListSnapshot private readonly notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() @@ -51,7 +56,7 @@ export class WorkspaceManager { this.state = 'loading' this.error = null const established = this.itemViews() - const frames: WorkspaceView[] = [] + const frames: WorkspaceDelta[] = [] this.refreshFrames = frames this.notifier.markDirty() this.inflight = (async () => { @@ -61,7 +66,8 @@ export class WorkspaceManager { let items = this.phase === 'pending' ? result.value.items : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) - for (const workspace of frames) items = upsertWorkspace(items, workspace) + items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) + for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) this.state = 'idle' this.phase = 'ready' @@ -111,6 +117,18 @@ export class WorkspaceManager { return result } + /** + * Delete a Workspace registration and remove its local projection from the + * unary response without waiting for the Host frame. + * @param workspaceId - target workspace. + * @returns the wire result. + */ + async delete(workspaceId: WorkspaceId): Promise> { + const { result } = await this.api.workspace.delete({ workspaceId }) + if (result.ok) this.remove(workspaceId) + return result + } + /** * Move a session within its Workspace's manual order, then publish the * returned snapshot without waiting for the changed frame. @@ -139,6 +157,7 @@ export class WorkspaceManager { */ handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) + else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) } /** Re-pull the baseline after each connection generation. */ @@ -175,7 +194,8 @@ export class WorkspaceManager { /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { - this.refreshFrames?.push(view) + if (this.removedIds.has(view.workspaceId)) return + this.refreshFrames?.push({ type: 'upsert', workspace: view }) const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) // Mutation responses and changed frames race (two carriers, no ordering): // reject a snapshot strictly older than the installed projection so a @@ -195,6 +215,17 @@ export class WorkspaceManager { this.notifier.markDirty() } + /** Remove one id idempotently and retain a tombstone against late echoes. */ + private remove(workspaceId: WorkspaceId): void { + this.refreshFrames?.push({ type: 'remove', workspaceId }) + this.removedIds.add(workspaceId) + const items = this.items.filter(item => + item.getSnapshot().view?.workspaceId !== workspaceId) + if (items.length === this.items.length) return + this.items = items + this.notifier.markDirty() + } + private installViews(views: readonly WorkspaceView[]): void { const existing = new Map( this.items.flatMap((workspace) => { @@ -234,3 +265,10 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi ? [workspace, ...items] : items.map((item, position) => position === index ? workspace : item) } + + +function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { + return delta.type === 'upsert' + ? upsertWorkspace(items, delta.workspace) + : items.filter(workspace => workspace.workspaceId !== delta.workspaceId) +} diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1e281ca792..4cb26aa220 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -174,6 +174,16 @@ export class WorkspacesService { return result.value.workspace } + /** + * Delete one Workspace registration. Sessions, session logs, and the + * directory remain Host-owned outside this operation. + * @param workspaceId - target workspace. + */ + async delete(workspaceId: WorkspaceId): Promise { + const result = await this.manager.delete(workspaceId) + if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) + } + /** * Move a session within its Workspace's manual order (DOM-insertBefore-like). * @param workspaceId - owning workspace. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index dcb334f6ea..9f6147cdd3 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -96,6 +96,9 @@ export class FakeApiClient implements IApiClient { onWorkspaceRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + onWorkspaceDelete: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ deleted: true })) + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) @@ -103,6 +106,7 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index d020b74fec..210c04d896 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -76,6 +76,48 @@ describe('WorkspaceManager', () => { ok: false, error: { code: 'internal', message: 'create transport' }, }) }) + + it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const manager = new WorkspaceManager(api) + const hydration = manager.refresh() + manager.handleHostEnvelope({ + rpcId: 'removed' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] })) + await hydration + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + + manager.handleHostEnvelope({ + rpcId: 'late-change' as never, + payload: { type: 'host/workspace-changed', workspace: workspace('gone') }, + }) + manager.handleHostEnvelope({ + rpcId: 'duplicate-remove' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + }) + + it('removes from the unary delete echo while a refresh is in flight', async () => { + const api = new FakeApiClient() + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] })) + const manager = new WorkspaceManager(api) + await manager.refresh() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const refresh = manager.refresh() + + await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }]) + expect(manager.getSnapshot().items).toEqual([]) + gate.resolve(ok({ items: [workspace('gone')] as never[] })) + await refresh + expect(manager.getSnapshot().items).toEqual([]) + }) }) describe('WorkspacesService', () => { @@ -175,4 +217,20 @@ describe('WorkspacesService', () => { })) await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) }) + + it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) + await workspaces.refresh() + await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined() + expect(workspaces.list.getSnapshot().items).toEqual([]) + + api.onWorkspaceDelete = () => Promise.resolve(err({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' }, + })) + await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) + }) }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index d0f2d0a20a..88638b9e35 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33 -README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010 +# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md +README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259 +README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index e0247b3e26..b5a78c30dd 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No Workspace rename/delete controls** — the picker supports selection and creation only. +- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions. - **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 92ef463faa..904543a48f 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -18,5 +18,5 @@ ## 已知限制与暂缓事项 -- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。 +- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 - **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index d6375cb698..c03d511c92 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -258,6 +258,16 @@ color: var(--dsw-alias-state-error-primary); } +.deleteAction:not(:disabled) { + color: var(--dsw-alias-state-error-primary); +} + +.deleteStatus { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + @media (prefers-reduced-motion: reduce) { .wide { animation: none; diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 0dc6485929..0090164928 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -87,10 +87,15 @@ type SessionTreeProps = Pick< query: string /** Open the browser-owned rename dialog for a real Workspace group. */ onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void + /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ + onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) { +function SessionTree({ + useSessions, startSession, open, workspaces, query, + onRenameRequest, onDeleteRequest, insertSessionBefore, +}: SessionTreeProps) { const list = useSessions((s) => s) const current = list.current const [expandedProjects, setExpandedProjects] = useState([]) @@ -128,11 +133,17 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen onCreate={() => { if (group.workspaceId !== undefined) startSession(group.workspaceId) }} - onRename={group.workspaceId === undefined + actions={group.workspaceId === undefined ? undefined - : () => { - /* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */ - if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + : { + rename: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }, + delete: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) + }, }} /> {group.sessions.map((node, index) => { @@ -236,6 +247,7 @@ export function WorkspaceBrowser({ startSession, open, renameWorkspace, + deleteWorkspace, insertSessionBefore, createWorkspace, }: WorkspaceBrowserProps) { @@ -291,6 +303,30 @@ export function WorkspaceBrowser({ }) } + // Delete dialog is separate from the row so a successful removal can + // unmount that row without tearing down the in-flight confirmation state. + const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState(null) + const closeDelete = () => { + if (deleting) return + setDeleteTarget(null) + setDeleteError(null) + } + const confirmDelete = () => { + /* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */ + if (deleting || deleteTarget === null) return + setDeleting(true) + setDeleteError(null) + deleteWorkspace(deleteTarget.workspaceId).then(() => { + setDeleting(false) + setDeleteTarget(null) + }).catch((reason: unknown) => { + setDeleting(false) + setDeleteError(reason instanceof Error ? reason.message : String(reason)) + }) + } + return (
@@ -382,6 +418,10 @@ export function WorkspaceBrowser({ setRenameDraft(currentTitle) setRenameError(null) }} + onDeleteRequest={(workspaceId, title) => { + setDeleteTarget({ workspaceId, title }) + setDeleteError(null) + }} /> ))}
@@ -416,6 +456,30 @@ export function WorkspaceBrowser({ )} {renameError !== null &&
{renameError}
} + + + + + )} + > + {deleting &&
Deleting workspace…
} + {deleteError !== null &&
{deleteError}
} +
) } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 6008da553f..2e4c88dd6b 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -32,6 +32,8 @@ export type WorkspaceBrowserInjected = { open: (sessionId: SessionId) => void /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */ renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise + /** Delete only a Host Workspace registration; directory and Session logs remain. */ + deleteWorkspace: (workspaceId: WorkspaceId) => Promise /** * Reorder a session inside its Workspace account (DOM-insertBefore * semantics: omitted anchor appends to the end). The view refreshes from diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index a444464441..98adfecce3 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -39,6 +39,7 @@ export function apply(ctx: ClientContext): void { startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, open: (sessionId) => { ctx.sessions.open(sessionId) }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, + deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index a100da83e9..ae866f58cf 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -3,7 +3,7 @@ * all data and callbacks arrive via props. Hover swaps (folder->chevron, * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only * except workspace Rename; the session hover card is suppressed while a menu - * is open. + * is open. Workspace Rename/Delete are wired; session actions remain visual-only. */ import { useState } from 'react' import clsx from 'clsx' @@ -39,12 +39,12 @@ const WORKSPACE_MENU_ITEMS = [ * @param props.onCreate - start a frontend Session inside this Workspace. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions }: { group: GroupNode onToggle: () => void onCreate: () => void - /** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */ - onRename?: (() => void) | undefined + /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ + actions?: { rename: () => void; delete: () => void } | undefined }) { const row = group const active = group.expanded && group.containsCurrent @@ -68,15 +68,15 @@ export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { {count} - {onRename !== undefined && ( + {actions !== undefined && ( { setMenuOpen(false) }} items={WORKSPACE_MENU_ITEMS} onSelect={(id) => { setMenuOpen(false) - if (id === 'rename') onRename() - // Delete is visual-only for now. + if (id === 'rename') actions.rename() + else actions.delete() }} portal closeOnPointerLeave diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 70cfb36940..6ccd923793 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -98,12 +98,16 @@ describe('workspace browser rows', () => { it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { const onRename = vi.fn() + const onDelete = vi.fn() const onToggle = vi.fn() const group: GroupNode = { key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], } - render() + render() fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) // Opening the menu neither toggles the group nor renames yet. expect(onToggle).not.toHaveBeenCalled() @@ -111,11 +115,11 @@ describe('workspace browser rows', () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) expect(onRename).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() - // Delete stays visual-only: selecting it just closes the menu. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) expect(screen.queryByRole('menu')).toBeNull() expect(onRename).toHaveBeenCalledOnce() + expect(onDelete).toHaveBeenCalledOnce() // Escape closes without selecting (Menu onClose path). fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) fireEvent.keyDown(document, { key: 'Escape' }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e9b55e7b76..1dbe895b74 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -54,6 +54,7 @@ function mount(overrides: Partial = {}) { startSession: vi.fn(), open: vi.fn(), renameWorkspace: vi.fn(async () => {}), + deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), ...overrides, @@ -457,6 +458,74 @@ describe('WorkspaceBrowser', () => { await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) }) + it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => { + let resolveDelete!: () => void + const deleteWorkspace = vi.fn(() => new Promise((resolve) => { resolveDelete = resolve })) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])), + deleteWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + const dialog = screen.getByRole('dialog', { name: 'Delete workspace' }) + expect(dialog.textContent).toContain('removes “Alpha” from the workspace list') + expect(dialog.textContent).toContain('folder and session logs will be kept') + expect(dialog.textContent).toContain('sessions will appear under Ungrouped') + + const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement + fireEvent.click(confirm) + fireEvent.click(confirm) + expect(deleteWorkspace).toHaveBeenCalledOnce() + expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha')) + expect(confirm.disabled).toBe(true) + expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByRole('status').textContent).toBe('Deleting workspace…') + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() + await act(async () => { resolveDelete() }) + expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() + }) + + it('keeps the delete dialog open on failure and allows retry or cancellation', async () => { + const deleteWorkspace = vi.fn() + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockRejectedValueOnce('denied') + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + deleteWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') }) + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() + }) + + it('Cancel, Escape, and Close dismiss deletion without calling the action', () => { + const deleteWorkspace = vi.fn(async () => {}) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + deleteWorkspace, + }) + const open = () => { + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + } + open() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + open() + fireEvent.keyDown(document, { key: 'Escape' }) + open() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(deleteWorkspace).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() + }) + it('search hides drag affordances (rows are not draggable during search)', () => { const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })]) mount({ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 224e8300ab..f33722a1fc 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -942,6 +942,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'list(): Workspace[]', jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */', }, + { + signature: 'delete(id: WorkspaceId): Promise', + jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', + }, { signature: 'async resolveByPath(path: string): Promise', jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index eb06e14d2d..653906ba08 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f -README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10 +# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md +README.md: dc29abdc10f536463358db92a7ac25c1579f2a50 +README.zh.md: a69d51de086dfbc692dccf3f5f88ce7e36c74e9c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 43ad70fa8b..dc29abdc10 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cc95a7512f..a69d51de08 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -12,7 +12,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f81f5c9be8..34e67edef9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -764,6 +764,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { workspace: workspaceView(workspace) }) }, + async delete(request) { + const { workspaceId } = request.payload + const operation = workspaceCreationChain.then(() => + ctx.workspace.delete(brandWorkspaceId(workspaceId))) + workspaceCreationChain = operation.then(() => undefined, () => undefined) + if (!await operation) return workspaceNotFound(request, workspaceId) + return ok(request, { deleted: true as const }) + }, + async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) @@ -977,8 +986,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) })) }), ctx.on('domain/changed', (change) => { - if (change.domain !== 'workspace' || change.operation !== 'put') return + if (change.domain !== 'workspace') return if (change.table === '') { + if (change.operation !== 'put') return const state = workspaceDomainState.parse(change.value) for (const workspaceId of state.workspaceIds) { if (committedWorkspaceIds.has(workspaceId)) continue @@ -991,7 +1001,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return } - if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return + if (change.table !== 'workspaces') return + if (change.operation === 'deleted') { + if (!committedWorkspaceIds.delete(change.key)) return + queue.push(frame({ + type: 'host/workspace-removed', + workspaceId: change.key as WorkspaceId, + })) + return + } + if (!committedWorkspaceIds.has(change.key)) return // Existing-entity table writes are complete attach/touch commits. // A new entity's first put waits for the global registry write above. queue.push(frame({ diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index e95b371c54..973db5a91e 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts' import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' import { approvalRequestIdSchema } from './approvals.schema.ts' import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts' -import { workspaceViewSchema } from './workspace.schema.ts' +import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' /** Question shape validated strictly against core dsh-user-interaction. */ export const askUserQuestionItemSchema = z.object({ @@ -47,6 +47,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }), z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), + z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), z.object({ type: z.literal('host/commands-changed') }), z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index db572215cb..bf66cbf76b 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -85,7 +85,9 @@ export type MuxFrame = * agent-error is the only outlet for live failures with no turn position; * workspace-changed pushes the full new snapshot after every durable * workspace mutation (create/attach/order change — the client upserts, while - * `workspace.list` provides the reconnect baseline). + * `workspace.list` provides the reconnect baseline); workspace-removed is the + * committed registration-deletion increment and never implies directory or + * session-log deletion. */ export type HostFrame = | { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string } @@ -93,6 +95,7 @@ export type HostFrame = | { type: 'host/session-status'; sessionId: SessionId; running: boolean } | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } + | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } /** * The command registry changed (`commands/change` passthrough). Pure * invalidation signal, no payload: clients refetch `command.list` in the diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index abe992584c..68ccc9ec89 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -26,6 +26,7 @@ export interface RpcMethodMap { 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] + 'workspace.delete': WorkspaceApi['delete'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 47c3ae6d59..e16e5339da 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -59,6 +59,16 @@ export const workspaceRenameValueSchema = z.object({ workspace: workspaceViewSchema, }) satisfies z.ZodType>> +/** workspace.delete request payload. */ +export const workspaceDeleteRequestSchema = z.object({ + workspaceId: workspaceIdSchema, +}) satisfies z.ZodType>> + +/** workspace.delete response value. */ +export const workspaceDeleteValueSchema = z.object({ + deleted: z.literal(true), +}) satisfies z.ZodType>> + /** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ export const workspaceInsertSessionBeforeRequestSchema = z.object({ workspaceId: workspaceIdSchema, diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 6ec636126b..ff22d845fb 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -65,6 +65,14 @@ export interface WorkspaceApi { rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>): Promise> + /** + * Removes one Workspace registration. The directory, every user file, and + * every session log remain untouched; those Sessions consequently become + * ungrouped. An unknown id fails with `workspace-not-found`. + */ + delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): + Promise> + /** * Moves an accounted session within its workspace's manual order, * DOM-insertBefore-like: with `beforeSessionId` the session is inserted diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0424ba7a4f..8762670cd7 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -23,6 +23,7 @@ import { } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, + workspaceDeleteValueSchema, workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, workspaceRenameValueSchema, @@ -60,6 +61,7 @@ export interface IApiClient { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> + delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> } commands: { @@ -91,6 +93,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.list', payload, signal), create: (payload, signal) => this.callUnary('workspace.create', payload, signal), rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), + delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index b79980d63e..3bbcbffba1 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -24,6 +24,7 @@ import { import { hostDescribeRequestSchema } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, + workspaceDeleteRequestSchema, workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, workspaceRenameRequestSchema, @@ -57,6 +58,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, + 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 11cdf5795c..d5ba628590 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -244,4 +244,31 @@ describe('Host Workspace increments', () => { abort.abort() expect(await next).toMatchObject({ done: true }) }) + + it('deletes the registration, keeps its session and folder, and streams one removal', async () => { + const { api, ctx } = await harness() + const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace + const sessionId = SessionId('session-kept-after-workspace-delete') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const removed = nextHostFrame(stream) + expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))) + expect(await removed).toMatchObject({ + payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId }, + }) + expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) + expect(ctx.agents.get(sessionId)).toBeDefined() + expect(existsSync(workspace.path)).toBe(true) + + const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId })) + expect(missing.result).toMatchObject({ + ok: false, + error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } }, + }) + abort.abort() + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index a9a5eac9ba..38ad7a52c9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -40,6 +40,7 @@ function scriptedApi(overrides: { list: r => ok(r, { items: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + delete: r => ok(r, { deleted: true as const }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), }, commands: { @@ -76,13 +77,15 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } }) }) - it('routes workspace rename and insertSessionBefore through the wire', async () => { + it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => { const api = scriptedApi() const c = client(api) const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) expect(renamed.result.ok).toBe(true) const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) + const deleted = await c.workspace.delete({ workspaceId: 'w1' as never }) + expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) expect(anchored.result.ok).toBe(true) const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index e8d65d2a62..0a92b9f5e5 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -58,6 +58,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, } }, + async delete(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } } + }, async insertSessionBefore(request) { return { rpcId: request.rpcId, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 02ca8dec22..c1af02b7b9 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -14,6 +14,7 @@ import { import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, + workspaceDeleteRequestSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, @@ -177,6 +178,13 @@ describe('workspace domain schemas', () => { expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') }) + it('validates workspace deletion payload and receipt', () => { + expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1') + expect(() => workspaceDeleteRequestSchema.parse({})).toThrow() + expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true }) + expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() + }) + it('insertSessionBefore accepts an anchored and an anchorless move', () => { expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() @@ -273,6 +281,11 @@ describe('events frame schemas', () => { { type: 'host/session-removed', sessionId: 's' }, { type: 'host/session-status', sessionId: 's', running: true }, { type: 'host/agent-error', sessionId: 's', message: 'boom' }, + { type: 'host/workspace-changed', workspace: { + workspaceId: 'w', path: '/w', title: 'w', sessionIds: [], + createdAt: '0', updatedAt: '0', + } }, + { type: 'host/workspace-removed', workspaceId: 'w' }, { type: 'host/commands-changed' }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] diff --git a/packages/workspace/README.i18n.yaml b/packages/workspace/README.i18n.yaml index a5400bc218..6d62c08c0b 100644 --- a/packages/workspace/README.i18n.yaml +++ b/packages/workspace/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 0d5ebabfbbb2922a369adb3a5d67ea4aafbe700f -README.zh.md: b82e8e6138f3e97c3c047cf1812cee8e558ea29b +# pnpm run verify-translation-pairing --write packages/workspace/README.md +README.md: ba92e95d3cde0a95eaaeae5a9b4384c3b8c9c4b8 +README.zh.md: 8c8146bba5fa6d81c0ce5d2ed29add77b4083270 diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 0d5ebabfbb..ba92e95d3c 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -2,10 +2,10 @@ English | [中文](README.zh.md) -The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). +The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md). | Package | Role | ctx key | |---|---|---| | `workspace/` | `WorkspaceRegistry` service over the storage domain form: realpath-unique paths, session-ownership accounting, entity cache | `ctx.workspace` | -Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deletion (workspace and session cascade) is deliberately absent this phase and ships with the session-side primitives. +Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deleting a Workspace removes only this registry record and account: directories, user files, and session logs remain, and the Sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)). diff --git a/packages/workspace/README.zh.md b/packages/workspace/README.zh.md index b82e8e6138..8c8146bba5 100644 --- a/packages/workspace/README.zh.md +++ b/packages/workspace/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 +Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)。 | 包 | 职责 | ctx 键 | |---|---|---| | `workspace/` | 位于存储领域形式之上的 `WorkspaceRegistry` 服务:按 realpath 唯一的路径、会话所有权计数、实体缓存 | `ctx.workspace` | -所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。本阶段有意不提供删除(workspace 与会话级联);该功能将与会话侧原语一起交付。 +所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。删除 Workspace 只会移除该注册表记录及账本:目录、用户文件和会话日志都会保留,相关会话则进入 Ungrouped(参见[决策记录](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。 diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index b3e0df9280..0904711ad3 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 0d395ecc58fc5e3362cb5f3c565a0539bb09c4dd -README.zh.md: 017e1e4d3aae9f8708ead3565f8b5b59d9b249ca +# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md +README.md: 52d03b33b3482dcb6a2f5feddbc15ac9fefee0a8 +README.zh.md: f899abdc3dd2a551179cd710c6dda84f804a8e80 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 0d395ecc58..52d03b33b3 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -10,6 +10,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title. - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. +- `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. - `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. @@ -35,5 +36,5 @@ Independent of live requests: the package never touches a request prefix, so it ## Known Limitations and Deferred Work -- No delete entry point in this phase — workspace deletion ships as one complete semantic together with the session-delete primitive and cascade orchestration (future-work section of the Agent Note); a half "drop the record, keep the sessions" operation is deliberately not exposed. +- Session deletion and destructive folder removal are separate, absent capabilities; Workspace registration deletion never substitutes for either ([decision](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)). - The header index refreshes at startup and when attach must resolve an uncached persisted id; deletion or cwd damage performed by another process is observed after the next refresh or restart. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 017e1e4d3a..f899abdc3d 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -10,6 +10,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径不能创建重复标题。 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它应用同一 `realpath` 规范,并会拒绝缺失路径,而不是创建路径。 +- `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话账本。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、实时会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 - `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话为空操作,workspace 顺序绝不改变。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、从两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 @@ -35,5 +36,5 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 ## 已知限制与延后工作 -- 本阶段没有删除入口:workspace 删除将与会话删除原语和级联编排一起作为完整语义交付(参见 Agent Note 的未来工作一节);系统有意不公开「删除记录、保留会话」的半成品操作。 +- 会话删除与破坏性的文件夹移除是彼此独立且尚未提供的功能;删除 Workspace 注册记录绝不能替代二者(参见[决策记录](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。 - 头部索引会在启动时刷新,也会在 attach 必须解析未缓存持久 id 时刷新;另一进程执行的删除或 cwd 破坏会在下次刷新或重启后被观测。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 0f849e7374..2699365608 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -168,6 +168,18 @@ export class WorkspaceRegistry extends Service { }) } + /** + * Delete one workspace registration while retaining its directory and every + * session log. The durable order is updated before the table deletion; a + * failed table write restores the prior order and keeps the entity + * published. Unknown ids are an idempotent no-op for domain callers. + * @param id - Workspace registration to remove. + * @returns `true` when a record was deleted, `false` when it was unknown. + */ + delete(id: WorkspaceId): Promise { + return this.enqueueOperation(() => this.deleteKnown(id)) + } + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -231,6 +243,33 @@ export class WorkspaceRegistry extends Service { return entity } + private async deleteKnown(id: WorkspaceId): Promise { + const entity = this.entities.get(id) + if (entity === undefined) return false + const state = this.requireState() + const nextState = { + initialized: true, + workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), + } + await this.setState(nextState) + this.entities.delete(id) + try { + await this.requireTable().delete(id) + } catch (error) { + this.entities.set(id, entity) + try { + await this.setState(state) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `workspace '${id}' record deletion and registry-order rollback both failed`, + ) + } + throw error + } + return true + } + private async bootstrap(headers: readonly SessionHeader[]): Promise { const table = this.requireTable() const state = this.requireState() diff --git a/packages/workspace/workspace/src/invariant.ts b/packages/workspace/workspace/src/invariant.ts index 1764ce2fe3..808ce1dedf 100644 --- a/packages/workspace/workspace/src/invariant.ts +++ b/packages/workspace/workspace/src/invariant.ts @@ -20,8 +20,9 @@ export const inject = ['invariants'] * domain's durable table. Every `domain/changed` for the `workspaces` table * must name a record the cache already holds an entity for (the registry * caches before the durable put and mutates only through cached entities). - * A delete is valid only for create rollback, after the provisional cache - * entry has been removed; deleting a published entity proves a bypass. + * A delete is valid only after the registry has removed the entity from its + * cache, whether for create rollback or an explicit registration deletion; + * deleting while the cache still publishes the entity proves a bypass. */ const install: InvariantInstaller = Object.assign( (ctx: Context, fail: (message: string) => never) => { diff --git a/packages/workspace/workspace/tests/invariant.spec.ts b/packages/workspace/workspace/tests/invariant.spec.ts index ea1fbaa64c..0d0556a44a 100644 --- a/packages/workspace/workspace/tests/invariant.spec.ts +++ b/packages/workspace/workspace/tests/invariant.spec.ts @@ -49,7 +49,7 @@ describe('workspace cache/table invariant', () => { .toThrow(/cache still publishes/) }) - it('allows deletion only after a provisional create cache entry was removed for rollback', async () => { + it('allows deletion after the registry removed the cache entry for rollback or explicit deletion', async () => { const ctx = await setup([]) expect(() => { ctx.emit('domain/changed', deleted()) }).not.toThrow() }) diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 8ce70cc7d5..ed08b5ba50 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -424,6 +424,40 @@ describe('WorkspaceRegistry create and lookup', () => { expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1) }) + it('deletes only the registration and leaves its directory and session headers untouched', async () => { + const dir = await makeDir('delete-registration') + const result = await harness({ sessions: [header('kept-session', dir)] }) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('kept-session')) + + await expect(result.registry.delete(workspace.id)).resolves.toBe(true) + await expect(result.registry.delete(workspace.id)).resolves.toBe(false) + expect(result.registry.get(workspace.id)).toBeUndefined() + expect(result.registry.list()).toEqual([]) + expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false) + await expect(realpath(dir)).resolves.toBe(dir) + expect(result.list).toHaveBeenCalledTimes(1) + expect(result.load).not.toHaveBeenCalled() + expect(result.inspect).not.toHaveBeenCalled() + }) + + it('rolls registry order and cache back when record deletion fails', async () => { + const dir = await makeDir('delete-rollback') + const pool = new MemoryMediaPool() + const result = await harness({ + pool, + backend: selectiveFailureBackend(pool, { deleteAt: 1 }), + }) + const workspace = await result.registry.create(dir) + + await expect(result.registry.delete(workspace.id)).rejects.toThrow(/selected rollback delete failure/) + expect(result.registry.get(workspace.id)).toBe(workspace) + expect(result.registry.list()).toEqual([workspace]) + expect(storedState(pool).workspaceIds).toEqual([workspace.id]) + expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir }) + }) + it('rejects table access before the registry has started', async () => { const dir = await makeDir('unstarted') const registry = new WorkspaceRegistry(new Context()) From a70923ba216eb09c91634e6f0db1059ae089baa1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:51:47 +0800 Subject: [PATCH 50/75] fix(session-checkpoint-policy): fail fast on an impossible crash marker vi.waitFor retries every callback throw, so the mismatch branch inside the callback waited the full 30s deadline for a fixture that writes the marker once and cannot recover. Terminal states (complete marker, or content that can no longer become the expected marker) now resolve out of the retry loop and the mismatch throws after it, restoring the old loop's immediate failure. --- .../tests/crash-recovery.e2e.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts index d87ba13b3a..25c7d5797d 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -19,17 +19,21 @@ const roots: string[] = [] const CHILD_FAILPOINT_TIMEOUT_MS = 30_000 async function waitForMarker(path: string, expected: string): Promise { - return await vi.waitFor(async () => { - const content = await readFile(path, 'utf8').catch((error: unknown) => { + // vi.waitFor retries every callback throw, so terminal states RESOLVE out + // of the retry loop (complete marker, or content that can no longer become + // the expected marker) and only the still-in-progress states throw-to-retry. + const content = await vi.waitFor(async () => { + const current = await readFile(path, 'utf8').catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error }) }) - if (content === expected) return content - if (!expected.startsWith(content)) { - throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`) - } + if (current === expected || !expected.startsWith(current)) return current throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`) }, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS }) + if (content !== expected) { + throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`) + } + return content } async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> { From 5fa74343aab77f543c3dfdac2ee7d387e769132c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 12:54:35 +0800 Subject: [PATCH 51/75] docs(ci): six always-on instances, no pre-registered spares The spare tier is retired. Steady-state pool load is one serial standby job per master push, so six always-on instances already are the failover capacity; pre-registered offline runners are a silently expiring guarantee (GitHub garbage-collects them after 30 days offline). Incident-time extra capacity is a one-minute org-token registration, now documented in the runbook. --- ...-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 6 +++--- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- ...2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 6 +++--- .../process/2026-07-26-ci-failover-runbook.md | 9 +++------ .../process/2026-07-26-ci-failover-runbook.zh.md | 9 +++------ 6 files changed, 14 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 84a10e5ab9..5ebd95248c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: 21e602b2b5850176df981dcf448f4f827b756719 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: ba49ff18ac304f4078d4c8ebfd00bb1a85ada0b3 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +2026-07-22-evidence-based-larger-hosted-runners.md: 5b399be5571ddaf1f775ba43a2233198b8e09b18 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 40970ec33c1a16af85ea47be3fc932209efdd654 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 21e602b2b5..5b399be557 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -52,7 +52,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with four always-on systemd-managed runner instances plus four registered spares) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index ba49ff18ac..40970ec33c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.zh.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index a2725da1b2..efb5fdd1cc 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-26-ci-failover-runbook.md: db8e0676ecc6eeaea16438e7868ccf9ac43887cc -2026-07-26-ci-failover-runbook.zh.md: b3b4149f460784e88ce03458fc556f402c38fa2f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +2026-07-26-ci-failover-runbook.md: 0bce83e0f9c842fa3dd73ae9c0a3eefc0975cdae +2026-07-26-ci-failover-runbook.zh.md: 4bc6c67bab754ad3f0127557b0d5e04f7934c8a2 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index db8e0676ec..0bce83e0f9 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -14,7 +14,7 @@ Each of the three required Linux jobs resolves its runner pool through the `DSH_ ### What the in-house pool is -`vm-backup`: one 64-core VM, four always-on systemd-managed runner instances, four registered spares. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. +`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. ### Switch (repo admin, ~1 minute, no merge) @@ -24,15 +24,12 @@ Each of the three required Linux jobs resolves its runner pool through the `DSH_ ### Capacity during failover -Four always-on instances absorb normal PR traffic. If queues build, bring the four registered spares online on the VM (no token needed — they are already registered): +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner) — cloning an existing runner directory and running `config.sh` takes about a minute per instance. -```bash -for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done -``` ### Switch back -Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Stop the spare instances if they were started. +Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Remove any extra instances that were registered during the incident. ### Trust boundary diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index b3b4149f46..4bc6c67bab 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 自有池是什么 -`vm-backup`:一台 64 核虚拟机,4 个常驻 systemd 管理的运行器实例,另有 4 个已注册备用位。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 +`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 ### 切换步骤(仓库管理员,约 1 分钟,无需合并) @@ -24,15 +24,12 @@ Status: implemented ### 切换期间的容量 -4 个常驻实例可承接正常 PR 流量。若出现排队,在虚拟机上把 4 个已注册的备用位拉起(无需 token——它们已注册): +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例——复制现有 runner 目录再跑 `config.sh`,每个约一分钟。 -```bash -for i in 7 8 9 10; do cd /data_local/actions-runner-$i && sudo ./svc.sh install ubuntu && sudo ./svc.sh start; done -``` ### 切回 -删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若启动过备用实例,将其停止。 +删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若故障期间追加注册过实例,将其移除。 ### 信任边界 From 073c698c72940adaa6a04427232117c32edb8827 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:15:09 +0800 Subject: [PATCH 52/75] =?UTF-8?q?ci:=20finish=20the=20corepack=20sweep=20?= =?UTF-8?q?=E2=80=94=20pnpm/action-setup=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the symmetric-cache conversion: provisioning and caching are separable concerns, so the remaining 8 corepack enable sites (the three enterprise restore-only PR jobs, the required Windows job, serial-macos/serial-windows, the cache-less ci.yml jobs, and sandbox.yml) now provision pnpm via pnpm/action-setup@v4 too — only the provisioning line moves; every deliberate cache shape (enterprise restore-only pairing, serial-linux producer, Windows cache-less) is byte-preserved. corepack is gone from CI entirely. Agent Note retitled to the decision's real shape (provisioning policy, with caching as per-job policy on top), EN+ZH, pair re-recorded; composite-action and stop-at-cache-workflows alternatives recorded. --- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +- ...m-action-setup-for-symmetric-ci-caching.md | 28 ++++++------- ...ction-setup-for-symmetric-ci-caching.zh.md | 28 ++++++------- .github/workflows/ci.yml | 41 +++++++++---------- .github/workflows/sandbox.yml | 5 +-- 5 files changed, 52 insertions(+), 54 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 23f0580c07..0b452fcf81 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 0cc0a09dfe1019d80b08599a245ce4bb044d81c5 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 477f8eed61579eb9c4f18701a39307daf098407d +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: d734f6be65affb15f7e56c08b0b6d268002a44d8 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 77ea0aa1cc7443ca7cd9e89b652a9c908bab64e5 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index 0cc0a09dfe..d734f6be65 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -1,4 +1,4 @@ -# Agent Note: Use pnpm/action-setup for symmetric CI pnpm caching +# Agent Note: Provision CI pnpm via pnpm/action-setup Status: implemented @@ -6,27 +6,27 @@ English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) ## Problem -Five workflows repeated a hand-rolled three-step pnpm setup — `corepack enable`, `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines total). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and also insulates against corepack's removal from newer Node distributions. +Every workflow hand-provisioned pnpm with `corepack enable`, and five of them further repeated a hand-rolled cache setup — `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines of drifting copies). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and corepack's removal from newer Node distributions made every `corepack enable` a known future break. ## Decision -The symmetric-cache setups use `pnpm/action-setup@v4` followed by `actions/setup-node` with `cache: pnpm`, the `landlock-run.yml` pattern: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input, mirroring the required Windows job's deliberate skip; the consolidated benchmark caches on both platforms as before. +`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. Caching remains per-job policy on top of it, in three deliberate shapes: -Explicitly NOT converted: - -- the three enterprise-runner PR jobs in `ci.yml` — they deliberately use `actions/cache/restore` only, keeping cache compression/upload off the paid latency-critical path, an asymmetry `setup-node`'s cache cannot express; -- the Windows job, which deliberately skips the store cache; -- the store-cache step of `ci.yml`'s serial-linux job — the job swaps `corepack enable` for `pnpm/action-setup@v4`, but its `pnpm store path` + `actions/cache@v4` steps stay hand-rolled because the master-push serial-linux run is the save side that populates the exact key and path the enterprise restore-only jobs consume; converting the producer to `setup-node`'s own key format would silently starve their restores. +- **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps, unchanged): the three enterprise-runner PR jobs restore without saving, keeping cache compression/upload off the paid latency-critical path — an asymmetry `setup-node`'s cache cannot express — and the master-push serial-linux job keeps its `pnpm store path` + `actions/cache@v4` save side, because it populates the exact key and path those restore-only jobs consume; converting the producer to `setup-node`'s key format would silently starve their restores. +- **Cache-less** (no store cache at all): the required Windows job and serial-windows (many-file store extraction is slower than a clean install there), serial-macos, sandbox.yml, and the coverage/consumers enterprise jobs that already restore via the shared enterprise key. ## Alternatives considered -- **Keep the hand-rolled steps.** They work, but they are five drifting copies of setup boilerplate, and the corepack dependency is a known future break. -- **Convert everything including the enterprise jobs.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. -- **Convert serial-linux's store cache too.** Rejected during implementation: the proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. +- **Keep the hand-rolled steps.** They worked, but they were drifting copies of setup boilerplate, and the corepack dependency was a known future break. +- **Convert the enterprise jobs' caching to `cache: pnpm`.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. +- **Convert serial-linux's store cache.** Rejected during implementation: the original proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. +- **Stop at the cache-bearing workflows and leave the other `corepack enable` sites.** Rejected on review follow-up: provisioning and caching are separable concerns, and leaving corepack in the cache-less jobs kept the future break and two provisioning idioms for no benefit. +- **A composite action wrapping action-setup + setup-node.** Rejected for now: the remaining per-job variation (node-version matrices, per-platform conditional caching, the restore-only pairing) is deliberate policy, not boilerplate — a wrapper would grow mirroring inputs or flatten a real asymmetry, and the two-line pair is already near the floor. ## Consequences -- The corepack dependency is gone from every converted workflow; pnpm arrives via the pnpm team's official action, already trusted in-repo (`landlock-run.yml`). -- The cache-key format changed once; one cold run per converted lane repopulates it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. +- The corepack dependency is gone from CI entirely; pnpm arrives via the pnpm team's official action everywhere, and the version pin stays single-sourced in `package.json`'s `packageManager` field. +- The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. - `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. -- About 75 net lines of workflow YAML removed; the enterprise-runner PR jobs and the Windows job are byte-identical, and serial-linux keeps producing the key they restore. +- About 75 net lines of workflow YAML removed. The enterprise-runner PR jobs' and Windows jobs' cache behavior is unchanged (only their provisioning line moved to the action), and serial-linux keeps producing the key the restore-only jobs consume. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 477f8eed61..77ea0aa1cc 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 用 pnpm/action-setup 实现对称的 CI pnpm 缓存 +# Agent Note: 经由 pnpm/action-setup 提供 CI 的 pnpm Status: implemented @@ -6,27 +6,27 @@ Status: implemented ## 问题 -五个工作流曾重复着同一套手写(hand-rolled)的三步 pnpm 设置——`corepack enable`、`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(合计约 40–60 行 YAML)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,同时还能隔绝 corepack 被从较新 Node 发行版中移除的影响。 +每个工作流都曾用 `corepack enable` 手工提供 pnpm,其中五个还各自重复着一套手写(hand-rolled)的缓存设置——`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(约 40–60 行各自漂移的 YAML 副本)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,而 corepack 被从较新 Node 发行版中移除,使每一处 `corepack enable` 都成了已知的未来失效点。 ## 决策 -各对称缓存设置现采用 `pnpm/action-setup@v4` 后接带 `cache: pnpm` 的 `actions/setup-node`,即 `landlock-run.yml` 的模式:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux,与必需 Windows 作业刻意跳过缓存的做法保持一致;consolidated benchmark 与之前一样在两个平台上都启用缓存。 +`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。缓存仍是叠加其上的按作业政策,保持三种刻意的形态: -以下明确未做转换: - -- `ci.yml` 中运行在企业 runner 上的三个 PR(Pull Request)作业——它们刻意只用 `actions/cache/restore`,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外,这种不对称是 `setup-node` 的缓存无法表达的; -- Windows 作业,它刻意跳过 store 缓存; -- `ci.yml` 中 serial-linux 作业的 store 缓存步骤——该作业把 `corepack enable` 换成了 `pnpm/action-setup@v4`,但其 `pnpm store path` + `actions/cache@v4` 步骤仍保持手写,因为 master 推送触发的 serial-linux 运行正是写入侧,负责填充企业只恢复不上传(restore-only)作业所消费的那个精确缓存键与路径;把生产者改成 `setup-node` 自有的键格式,会悄然断供它们的恢复。 +- **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤,保持不变):企业 runner 上的三个 PR(Pull Request)作业只恢复不保存,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外——这种不对称是 `setup-node` 的缓存无法表达的;master 推送触发的 serial-linux 作业保留其 `pnpm store path` + `actions/cache@v4` 写入侧,因为它负责填充那些只恢复不上传(restore-only)作业所消费的精确缓存键与路径;把生产者改成 `setup-node` 的键格式,会悄然断供它们的恢复。 +- **无缓存**(完全不设 store 缓存):必需的 Windows 作业与 serial-windows(在那里解压海量小文件的 store 缓存比干净安装更慢)、serial-macos、sandbox.yml,以及本就经共享企业键恢复的 coverage/consumers 企业作业。 ## 曾考虑的替代方案 -- **保留手写步骤。** 它们能用,但那是五份会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 -- **连企业作业在内全部转换。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 -- **连 serial-linux 的 store 缓存也转换。** 实现期间否决:提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 +- **保留手写步骤。** 它们能用,但那是会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 +- **把企业作业的缓存也转换成 `cache: pnpm`。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 +- **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 +- **只转换带缓存的工作流,留下其余 `corepack enable` 站点。** 评审跟进时否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。 +- **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意的政策而非样板——包装层要么长出镜像这些差异的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。 ## 后果 -- 每个已转换的工作流都不再依赖 corepack;pnpm 经由 pnpm 团队的官方 action 提供,该 action 已在仓库内获得信任(`landlock-run.yml`)。 -- 缓存键格式变更了一次;每条已转换泳道各跑一次冷运行以重建缓存,此后命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 +- corepack 依赖已从 CI 中彻底消失;pnpm 在所有工作流中都经由 pnpm 团队的官方 action 提供,版本锁定继续单一来源于 `package.json` 的 `packageManager` 字段。 +- 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 - `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。 -- 净删除约 75 行工作流 YAML;企业 runner 上的 PR 作业与 Windows 作业逐字节未动,serial-linux 继续生产它们所恢复的缓存键。 +- 净删除约 75 行工作流 YAML。企业 runner 上的 PR 作业与 Windows 作业的缓存行为未变(只有提供 pnpm 的那一行改用了 action),serial-linux 继续生产只恢复不上传作业所消费的缓存键。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ee12e9768..6813b75c56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,14 +52,14 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack and install dependencies - run: | - corepack enable - pnpm install --frozen-lockfile + - name: Install (immutable) + run: pnpm install --frozen-lockfile - name: Run static gates env: @@ -98,13 +98,14 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack, install dependencies, and prepare bubblewrap + - name: Install dependencies and prepare bubblewrap run: | - corepack enable pnpm install --frozen-lockfile & install_pid=$! bash scripts/prepare-ci-bubblewrap.sh & @@ -158,13 +159,14 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack, install dependencies, and prepare bubblewrap + - name: Install dependencies and prepare bubblewrap run: | - corepack enable pnpm install --frozen-lockfile & install_pid=$! bash scripts/prepare-ci-bubblewrap.sh & @@ -291,17 +293,17 @@ jobs: reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v4 + + # Extracting the many-file pnpm store cache is slower than a clean install, + # and saving it adds more latency after gates, so this job runs cache-less. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - # Extracting the many-file pnpm store cache is slower than a clean install, - # and saving it adds more latency after gates. - - name: Enable corepack and install (immutable) + - name: Install (immutable) shell: pwsh - run: | - corepack enable - pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Run blocking and observational Windows gates concurrently shell: pwsh @@ -374,13 +376,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - run: corepack enable - - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -406,14 +407,12 @@ jobs: reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - shell: pwsh - run: corepack enable - # Master refreshes the small cache that pull requests restore without # putting package-store extraction back on the Windows critical path. - uses: actions/cache@v4 diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 51dfe06f1c..0d14914bfd 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -53,13 +53,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: 24 - - name: Enable corepack (pnpm) - run: corepack enable - - name: Install (immutable) run: pnpm install --frozen-lockfile From 1a8225ee6cc62438a0c7c54e19ee76ba76b61b33 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 27 Jul 2026 14:35:05 +0800 Subject: [PATCH 53/75] ci: retrigger after failover switch From 7bd96af5eb8d889b0652f1d4972a4e3e4c9649e2 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Mon, 27 Jul 2026 14:37:04 +0800 Subject: [PATCH 54/75] fix(workspace): make deletion recoverable --- ...-workspace-registration-deletion.i18n.yaml | 4 +- ...6-07-27-workspace-registration-deletion.md | 8 +- ...7-27-workspace-registration-deletion.zh.md | 8 +- apps/web/tests/workspace-management.e2e.ts | 29 ++++ .../runtime/src/client/workspaces/manager.ts | 10 +- .../ui-workspace/src/client/rows/Rows.tsx | 4 + .../tests/api-proxy-workspace.spec.ts | 6 + packages/workspace/workspace/README.i18n.yaml | 4 +- packages/workspace/workspace/README.md | 2 + packages/workspace/workspace/README.zh.md | 2 + packages/workspace/workspace/src/index.ts | 75 ++++++++- packages/workspace/workspace/src/spec.ts | 11 ++ .../workspace/tests/workspace.spec.ts | 151 +++++++++++++++++- 13 files changed, 294 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml index 847b040457..93c78373c6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.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-workspace-registration-deletion.md -2026-07-27-workspace-registration-deletion.md: cae01d529bc6fd97da6fb61839bd5ec8e21557e2 -2026-07-27-workspace-registration-deletion.zh.md: 76377ebc5e93101e1e3efce1d29c3c654df032c2 +2026-07-27-workspace-registration-deletion.md: 58ae5c4bef2cf1cb0a0158eda5eb37daf2e9703d +2026-07-27-workspace-registration-deletion.zh.md: 7a79a1ccc53a0d4fd7e5ab453239ade955313c6e diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md index cae01d529b..58ae5c4bef 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md @@ -22,6 +22,8 @@ Registry operations serialize create and delete. Deletion first writes the Works The Host stream keeps its committed-id set through the preceding global-order write and removes the id only on the table deletion. Create rollback therefore emits no false removal, while every connected tab receives exactly the id needed to delete its projection. +Create and delete write a durable `pendingMutation` before their record/order pair can diverge. Startup completes only the named create or delete and clears the marker; it never infers crash provenance from an orphan row alone. Unmarked order/table divergence therefore retains the registry's fail-loud corruption behavior. A deletion whose table write committed but marker cleanup failed still reports success—the requested state and removal frame are already committed—and the next startup clears that marker idempotently. + ## Client convergence `WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta. @@ -40,14 +42,16 @@ The menu, Modal, and buttons retain their existing structure and design tokens. **Delete the table row and repair order later.** Rejected because a crash or write failure would leave an initialized registry whose order and table disagree. The registry updates both under one serialized operation and restores the prior order on table failure. +**Delete every unreferenced row at startup.** Rejected because the same shape can come from unexplained order corruption; silently discarding it could lose Workspace metadata and Session accounting. Recovery requires the explicit pending marker written by the owning mutation. + **Refetch both lists after success.** Rejected because the committed removal frame plus immediate unary echo is sufficient, preserves the current Session object, and avoids turning a local mutation into two list requests. Reconnect baselines remain the repair path. ## Verification -Workspace package tests pin successful metadata-only deletion, unknown-id idempotence, table-failure rollback, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close. +Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close. The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload. ## Consequences -Deleting a Workspace is intentionally reversible by registering the same directory again, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns. +Deleting a Workspace is intentionally reversible by registering the same directory again with a fresh id, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns. diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md index 76377ebc5e..7a79a1ccc5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md @@ -22,6 +22,8 @@ Workspace 注册已有代码目录,使 GUI 能够为目录命名,并对其 Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合,只在删除表行时移除该 id。因此,创建回滚不会发出错误的移除帧,而每个已连接标签页都能收到从自身投影中删除该记录所需的准确 id。 +Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendingMutation`。启动时只补全其中明确命名的 create 或 delete,并清除该标记;系统绝不会仅凭孤立表行的形状推断崩溃来源。因此,没有标记的顺序/表分叉仍会保持注册表原有的损坏直接失败语义。如果删除的表写入已经提交、但标记清理失败,操作仍会报告成功——请求状态和移除帧都已经提交——下一次启动会以幂等方式清除该标记。 + ## 客户端收敛 `WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 @@ -40,14 +42,16 @@ Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合 **先删除表行,之后再修复顺序。** 不予采纳,因为崩溃或写入失败会使已初始化注册表的顺序与表不一致。注册表会在同一串行操作内更新二者,并在表操作失败时恢复此前顺序。 +**启动时删除所有未引用表行。** 不予采纳,因为来源不明的顺序损坏也会呈现相同形状;静默丢弃可能损失 Workspace 元数据和 Session 账本。恢复必须依赖拥有该变更的操作预先写入的明确待处理标记。 + **成功后重新拉取两个列表。** 不予采纳,因为已提交的移除帧与即时一元回显已足够,既能保留当前会话对象,也避免将局部变更扩大为两次列表请求。重连基线仍是修复路径。 ## Verification -Workspace 包测试固定了仅删除元数据的成功路径、未知 id 的幂等行为、表操作失败回滚,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。 +Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。 组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。 ## Consequences -删除 Workspace 后仍可重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。 +删除 Workspace 后仍可使用新 id 重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 4dbcca36ae..3239dcfc12 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -169,6 +169,34 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await stat(logLocation.path) expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + // Re-registering the exact deleted path immediately, without a reload, is + // a supported reversible flow. It creates a fresh Workspace id without + // re-adopting the retained Session. + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) + await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) + await reuseFolder.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0) + const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) + expect(reregistered?.id).toBeDefined() + expect(reregistered?.id).not.toBe(workspace.id) + expect(reregistered?.sessionIds).toEqual([]) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) + .toBeGreaterThanOrEqual(1) + expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') + await stat(logLocation.path) + + // Restore the deleted-registry state so reload still verifies deletion + // persistence independently of the successful re-registration above. + if (reregistered === undefined) throw new Error('same-path re-registration did not materialize') + await scaffold.ctx.workspace.delete(reregistered.id) + await expect.poll( + () => page.getByRole('button', { name: `Workspace actions for ${reregistered.title}` }).count(), + { timeout: 10_000 }, + ).toBe(0) + const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -183,6 +211,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') await stat(logLocation.path) expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 83275e9a2a..7179ed9eb9 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -33,6 +33,14 @@ export class WorkspaceManager { private error: RpcError | null = null private inflight: Promise | null = null private refreshFrames: WorkspaceDelta[] | null = null + /** + * Ids this process has seen removed, kept for the connection's lifetime so + * a late changed frame or a stale baseline row cannot resurrect a deleted + * row. Correctness rests on Host ids never being reused (the registry mints + * a fresh `randomUUID` per record, including when the same directory is + * registered again) — a path-derived id scheme would turn these entries + * into permanent blindfolds and must clear them instead. + */ private readonly removedIds = new Set() private snapshotCache: WorkspaceListSnapshot private readonly notifier = new Notifier(() => { @@ -266,7 +274,7 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi : items.map((item, position) => position === index ? workspace : item) } - +/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */ function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { return delta.type === 'upsert' ? upsertWorkspace(items, delta.workspace) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index ae866f58cf..e245f8b217 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -75,6 +75,10 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: { items={WORKSPACE_MENU_ITEMS} onSelect={(id) => { setMenuOpen(false) + // Unknown ids leave before the dispatch: a future menu row must + // not inherit the destructive branch as an else fallback. + /* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */ + if (id !== 'rename' && id !== 'delete') return if (id === 'rename') actions.rename() else actions.delete() }} diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index d5ba628590..bbd57cb6dc 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -269,6 +269,12 @@ describe('Host Workspace increments', () => { ok: false, error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } }, }) + + const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace + expect(reregistered.workspaceId).not.toBe(workspace.workspaceId) + expect(reregistered.path).toBe(workspace.path) + expect(reregistered.sessionIds).toEqual([]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) abort.abort() }) }) diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 0904711ad3..b5eaefa98c 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/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/workspace/workspace/README.md -README.md: 52d03b33b3482dcb6a2f5feddbc15ac9fefee0a8 -README.zh.md: f899abdc3dd2a551179cd710c6dda84f804a8e80 +README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62 +README.zh.md: 7960a2d13df4f881687fd88cdb07e237b3abb7c8 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 52d03b33b3..bee3e4fcb5 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -18,6 +18,8 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n `storageDomain` and `sessionPersistence` are required startup dependencies. An unavailable peer leaves the plugin pending and cannot commit an empty initialized marker. On the first successful start, the registry calls `SessionPersistence.list()` and uses only header `id`, `cwd`, and `createdAt` to group valid historical directories and persist initial order; it never reads event bodies. The initialized marker is written last, so partial bootstrap writes are reused safely after restart. Later cwd-only sessions remain Ungrouped. +Create and delete persist an explicit pending-mutation marker before their record and order can diverge. Startup completes only the marked mutation, then clears the marker; an unmarked order/table mismatch remains unexplained corruption and fails loud. Deleting and re-registering the same path creates a fresh Workspace id and does not automatically re-adopt the retained Sessions. + ## Model Experience ### Workspace records and session accounts diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index f899abdc3d..7960a2d13d 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -18,6 +18,8 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 `storageDomain` 和 `sessionPersistence` 是启动必需依赖。对等服务不可用时,插件保持待处理,且不能提交空的已初始化标记。首次成功启动时,注册表调用 `SessionPersistence.list()`,仅使用头部 `id`、`cwd` 和 `createdAt` 对有效历史目录分组并持久化初始顺序;它绝不读取事件正文。已初始化标记最后写入,因此重启后可安全复用部分启动写入。后续仅有 cwd 的会话仍属于 Ungrouped。 +Create 与 delete 会在记录和顺序可能分叉之前,先持久化明确的待处理变更标记。启动时只补全被该标记证明的变更,随后清除标记;没有标记的顺序/表不一致仍属于来源不明的损坏,并会直接失败。删除后重新注册同一路径会生成新的 Workspace id,且不会自动重新接纳保留下来的 Session。 + ## 模型体验 ### Workspace 记录与会话记账 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 2699365608..5172c63805 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -109,6 +109,7 @@ export class WorkspaceRegistry extends Service { this.global = domain.global this.state = domain.global.get() + await this.recoverPendingMutation() this.validateStoredState(this.state) if (!this.state.initialized) { const headers = await this.ctx.sessionPersistence.list() @@ -218,10 +219,28 @@ export class WorkspaceRegistry extends Service { } const entity = new WorkspaceEntity(this.host, id, record) this.entities.set(id, entity) + const pendingState: WorkspaceDomainState = { + ...state, + pendingMutation: { operation: 'create', workspaceId: id }, + } + try { + await this.setState(pendingState) + } catch (error) { + this.entities.delete(id) + throw error + } try { await table.put(id, record) } catch (error) { this.entities.delete(id) + try { + await this.setState(state) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `workspace '${id}' record write and pending-marker rollback both failed`, + ) + } throw error } @@ -232,10 +251,17 @@ export class WorkspaceRegistry extends Service { try { await table.delete(id) } catch (rollbackError) { - this.entities.set(id, entity) throw new AggregateError( [error, rollbackError], - `workspace '${id}' was stored but its registry order and rollback both failed`, + `workspace '${id}' order write and record rollback both failed; the pending marker remains recoverable`, + ) + } + try { + await this.setState(state) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `workspace '${id}' order write and pending-marker rollback both failed`, ) } throw error @@ -251,7 +277,10 @@ export class WorkspaceRegistry extends Service { initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), } - await this.setState(nextState) + await this.setState({ + ...nextState, + pendingMutation: { operation: 'delete', workspaceId: id }, + }) this.entities.delete(id) try { await this.requireTable().delete(id) @@ -260,6 +289,10 @@ export class WorkspaceRegistry extends Service { try { await this.setState(state) } catch (rollbackError) { + // The durable marker still says to finish deletion, so the cache must + // agree with that recoverable direction rather than republish a row + // absent from the persisted order. + this.entities.delete(id) throw new AggregateError( [error, rollbackError], `workspace '${id}' record deletion and registry-order rollback both failed`, @@ -267,9 +300,38 @@ export class WorkspaceRegistry extends Service { } throw error } + try { + await this.setState(nextState) + } catch (error) { + // The deletion committed at the table write and was already published + // to Host streams. Keep the durable marker for startup recovery rather + // than reporting failure after the requested state became true. + this.ctx.logger.warn( + `workspace '${id}' was deleted but its pending marker could not be cleared: ${String(error)}`, + ) + } return true } + /** + * Complete the one mutation explicitly named by durable state. Unexplained + * order/table divergence still reaches {@link validateStoredState} and + * fails loud; this path never infers provenance from shape alone. + */ + private async recoverPendingMutation(): Promise { + const state = this.requireState() + const pending = state.pendingMutation + if (pending === undefined) return + if (state.workspaceIds.includes(pending.workspaceId)) { + throw new Error( + `workspace domain is inconsistent: pending ${pending.operation} workspace ` + + `'${pending.workspaceId}' is still present in registry order`, + ) + } + await this.requireTable().delete(pending.workspaceId) + await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds }) + } + private async bootstrap(headers: readonly SessionHeader[]): Promise { const table = this.requireTable() const state = this.requireState() @@ -493,7 +555,12 @@ export class WorkspaceRegistry extends Service { } private enqueueOperation(operation: () => Promise): Promise { - const result = this.operationTail.then(operation) + const result = this.operationTail.then(async () => { + // A committed delete may leave only its marker cleanup pending. Retry + // recovery before another create/delete can overwrite that provenance. + await this.recoverPendingMutation() + return await operation() + }) this.operationTail = result.then(() => {}, () => {}) return result } diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts index 8df908949a..7b1a6a41d0 100644 --- a/packages/workspace/workspace/src/spec.ts +++ b/packages/workspace/workspace/src/spec.ts @@ -29,6 +29,16 @@ export const workspaceRecord = z.object({ /** One stored workspace record, inferred from {@link workspaceRecord}. */ export type WorkspaceRecord = z.infer +/** + * Recoverable two-write mutation marker. The marker is persisted before the + * record/order pair can diverge, so startup can distinguish an interrupted + * registry operation from unexplained medium corruption. + */ +const workspacePendingMutation = z.discriminatedUnion('operation', [ + z.object({ operation: z.literal('create'), workspaceId }), + z.object({ operation: z.literal('delete'), workspaceId }), +]) + /** * Durable registry state. `initialized` distinguishes a valid empty registry * from one that still needs the header-only history bootstrap; @@ -37,6 +47,7 @@ export type WorkspaceRecord = z.infer export const workspaceDomainState = z.object({ initialized: z.boolean(), workspaceIds: z.array(workspaceId), + pendingMutation: workspacePendingMutation.optional(), }) /** Durable registry state inferred from {@link workspaceDomainState}. */ diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index ed08b5ba50..4576155f3b 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -89,7 +89,7 @@ async function storageContext(pool: MemoryMediaPool, backend: StorageBackend = n /** Backend wrapper that injects one selected bootstrap write failure. */ function selectiveFailureBackend( pool: MemoryMediaPool, - failure: { putAt?: number; deleteAt?: number; globalAt?: number }, + failure: { putAt?: number; deleteAt?: number; globalAt?: number | readonly number[] }, ): StorageBackend { const inner = new MemoryStorageBackend(pool) let puts = 0 @@ -113,7 +113,8 @@ function selectiveFailureBackend( }, setGlobal: async (value) => { globals += 1 - if (globals === failure.globalAt) throw new Error('selected bootstrap marker failure') + const failAt = Array.isArray(failure.globalAt) ? failure.globalAt : [failure.globalAt] + if (failAt.includes(globals)) throw new Error('selected bootstrap marker failure') await unit.setGlobal(value) }, close: () => unit.close(), @@ -394,19 +395,34 @@ describe('WorkspaceRegistry create and lookup', () => { it('rolls back the provisional cache when the record write fails', async () => { const dir = await makeDir('write-failure') - const result = await harness() - result.pool.failNextWrites = 1 - await expect(result.registry.create(dir)).rejects.toThrow(/injected/) + const pool = new MemoryMediaPool() + const result = await harness({ + pool, + backend: selectiveFailureBackend(pool, { putAt: 1 }), + }) + await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap put failure/) expect(result.registry.list()).toEqual([]) expect(await result.registry.create(dir)).toBeDefined() }) + it('does not publish a Workspace when its pending marker cannot be written', async () => { + const dir = await makeDir('pending-marker-write-failure') + const pool = new MemoryMediaPool() + const result = await harness({ + pool, + backend: selectiveFailureBackend(pool, { globalAt: 2 }), + }) + await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap marker failure/) + expect(result.registry.list()).toEqual([]) + expect(pool.media.get('workspace')!.tables.get('workspaces')?.size ?? 0).toBe(0) + }) + it('rolls back a record when registry-order persistence fails', async () => { const dir = await makeDir('order-write-failure') const pool = new MemoryMediaPool() const result = await harness({ pool, - backend: selectiveFailureBackend(pool, { globalAt: 2 }), + backend: selectiveFailureBackend(pool, { globalAt: 3 }), }) await expect(result.registry.create(dir)).rejects.toThrow(/marker failure/) expect(result.registry.list()).toEqual([]) @@ -418,12 +434,38 @@ describe('WorkspaceRegistry create and lookup', () => { const pool = new MemoryMediaPool() const result = await harness({ pool, - backend: selectiveFailureBackend(pool, { globalAt: 2, deleteAt: 1 }), + backend: selectiveFailureBackend(pool, { globalAt: 3, deleteAt: 1 }), }) await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError) expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1) }) + it('reports a record write and pending-marker rollback failure together', async () => { + const dir = await makeDir('record-marker-rollback-failure') + const pool = new MemoryMediaPool() + const result = await harness({ + pool, + backend: selectiveFailureBackend(pool, { putAt: 1, globalAt: 3 }), + }) + await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError) + expect(storedState(pool)).toMatchObject({ + pendingMutation: { operation: 'create' }, + }) + }) + + it('reports an order write and pending-marker rollback failure together', async () => { + const dir = await makeDir('order-marker-rollback-failure') + const pool = new MemoryMediaPool() + const result = await harness({ + pool, + backend: selectiveFailureBackend(pool, { globalAt: [3, 4] }), + }) + await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError) + expect(storedState(pool)).toMatchObject({ + pendingMutation: { operation: 'create' }, + }) + }) + it('deletes only the registration and leaves its directory and session headers untouched', async () => { const dir = await makeDir('delete-registration') const result = await harness({ sessions: [header('kept-session', dir)] }) @@ -440,6 +482,11 @@ describe('WorkspaceRegistry create and lookup', () => { expect(result.list).toHaveBeenCalledTimes(1) expect(result.load).not.toHaveBeenCalled() expect(result.inspect).not.toHaveBeenCalled() + + const reregistered = await result.registry.create(dir) + expect(reregistered.id).not.toBe(workspace.id) + expect(reregistered.path).toBe(dir) + expect(reregistered.sessionIds).toEqual([]) }) it('rolls registry order and cache back when record deletion fails', async () => { @@ -458,11 +505,58 @@ describe('WorkspaceRegistry create and lookup', () => { expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir }) }) + it('commits deletion and leaves a recoverable marker when marker cleanup fails', async () => { + const dir = await makeDir('delete-marker-cleanup') + const pool = new MemoryMediaPool() + const first = await harness({ + pool, + backend: selectiveFailureBackend(pool, { globalAt: 5 }), + }) + const workspace = await first.registry.create(dir) + + await expect(first.registry.delete(workspace.id)).resolves.toBe(true) + expect(first.registry.list()).toEqual([]) + expect(storedState(pool)).toEqual({ + initialized: true, + workspaceIds: [], + pendingMutation: { operation: 'delete', workspaceId: workspace.id }, + }) + const reregistered = await first.registry.create(dir) + expect(reregistered.id).not.toBe(workspace.id) + expect(storedState(pool)).toEqual({ + initialized: true, + workspaceIds: [reregistered.id], + }) + await first.fiber.dispose() + + const restarted = await harness({ pool }) + expect(restarted.registry.list().map(item => item.id)).toEqual([reregistered.id]) + }) + + it('keeps the failed deletion unpublished when record and order rollback both fail', async () => { + const dir = await makeDir('delete-double-failure') + const pool = new MemoryMediaPool() + const result = await harness({ + pool, + backend: selectiveFailureBackend(pool, { deleteAt: 1, globalAt: 5 }), + }) + const workspace = await result.registry.create(dir) + + await expect(result.registry.delete(workspace.id)).rejects.toBeInstanceOf(AggregateError) + expect(result.registry.get(workspace.id)).toBeUndefined() + expect(storedState(pool)).toMatchObject({ + workspaceIds: [], + pendingMutation: { operation: 'delete', workspaceId: workspace.id }, + }) + }) + it('rejects table access before the registry has started', async () => { const dir = await makeDir('unstarted') const registry = new WorkspaceRegistry(new Context()) await expect(registry.create(dir)).rejects.toThrow(/not started/) expect(() => registry.list()).toThrow(/not started/) + const internals = registry as unknown as { requireTable(): unknown } + expect(() => internals.requireTable()).toThrow(/not started/) }) }) @@ -650,6 +744,49 @@ describe('header-validated membership projection', () => { internals.entities.delete(workspace.id) expect(() => result.registry.list()).toThrow(/references missing workspace/) }) + + it('recovers only an explicitly marked interrupted create or delete', async () => { + const createDir = await makeDir('pending-create') + const deleteDir = await makeDir('pending-delete') + const createId = WorkspaceId('00000000-0000-4000-8000-000000000004') + const deleteId = WorkspaceId('00000000-0000-4000-8000-000000000005') + + const interruptedCreate = storedPool( + [[createId, record(createDir, [])]], + { + initialized: true, + workspaceIds: [], + pendingMutation: { operation: 'create', workspaceId: createId }, + }, + ) + const createRecovery = await harness({ pool: interruptedCreate }) + expect(createRecovery.registry.list()).toEqual([]) + expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false) + expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] }) + + const interruptedDelete = storedPool( + [[deleteId, record(deleteDir, [])]], + { + initialized: true, + workspaceIds: [], + pendingMutation: { operation: 'delete', workspaceId: deleteId }, + }, + ) + const deleteRecovery = await harness({ pool: interruptedDelete }) + expect(deleteRecovery.registry.list()).toEqual([]) + expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false) + expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] }) + + const corruptPending = storedPool( + [[deleteId, record(deleteDir, [])]], + { + initialized: true, + workspaceIds: [deleteId], + pendingMutation: { operation: 'delete', workspaceId: deleteId }, + }, + ) + await expect(harness({ pool: corruptPending })).rejects.toThrow(/still present in registry order/) + }) }) describe('workspace mutation and status', () => { From be80eb04ad4876dd3c60e000d9b7e1836bed3a1f Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 27 Jul 2026 14:45:54 +0800 Subject: [PATCH 55/75] ci: retrigger after runner-group policy fix From fe246e4a0a14a4ce154e05e52188bac098dea80c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 15:17:48 +0800 Subject: [PATCH 56/75] =?UTF-8?q?ci:=20failover=20round=20=E2=80=94=20aggr?= =?UTF-8?q?egate=20follows=20the=20selector,=20tighter=20shared-VM=20bound?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - all-checks-passed now resolves its pool through the same DSH_CI_FAILOVER expression as the worker jobs it aggregates. Pinned to the hosted pool it would leave the branch-protection verdict queued on the failed pool after every failover job passed — observed live during the 2026-07-27 outage as a required check looping against dead capacity. - Coverage worker bound under failover drops 12 → 8 and snapshot concurrency 16 → 12: the pool now runs six always-on instances (the spare tier was retired), so worst case is 6 × 8 = 48 coverage workers on the shared 64-core VM. --- .github/workflows/ci.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5afa5d7f62..df3d386e39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,11 +102,12 @@ jobs: || 'dsh-enterprise-ubuntu-24-04-32core-test' }} name: node 24 / coverage env: - # Failover halves the worker bound: the hosted 32-core runner is + # Failover shrinks the worker bound: the hosted 32-core runner is # exclusive to one job, but the failover pool shares one 64-core VM - # across four runner instances, and the timing-sensitive process - # suites have documented aggregate-contention failures. - DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '12' || '24' }} + # across six always-on runner instances, and the timing-sensitive + # process suites have documented aggregate-contention failures. + # 8 × 6 instances = 48 workers worst case on 64 cores. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '8' || '24' }} DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 @@ -160,7 +161,7 @@ jobs: DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_PUBLINT_CONCURRENCY: '8' # Failover halves snapshot concurrency for the shared 64-core VM. - DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '16' || '32' }} + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '12' || '32' }} steps: - uses: actions/checkout@v6 with: @@ -765,8 +766,15 @@ jobs: # 'cancelled' and 'skipped'. all-checks-passed: name: all checks passed - # The required verdict must not add a separate standard-hosted billing dependency. - runs-on: dsh-enterprise-ubuntu-latest-32core-test + # The required verdict must not add a separate standard-hosted billing + # dependency — and it must follow the failover selector like the worker + # jobs it aggregates: if it stayed pinned to the hosted pool, every + # failover-passed run would still leave the branch-protection verdict + # queued forever on the failed pool. + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') + || 'dsh-enterprise-ubuntu-latest-32core-test' }} needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows] if: always() && github.event_name == 'pull_request' steps: From aedf7fbf349df99a89faa72ddc17ec95ea2aff53 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 15:27:40 +0800 Subject: [PATCH 57/75] docs(i18n): keep the runbook link target identical across the pair The pairing gate requires link target #9 to be byte-identical between the language sides; my earlier 'fix' pointed the zh side at the zh runbook and broke the contract. Reverted to the shared target and re-recorded the pairing hash. --- .../2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 5ebd95248c..99cabc76bb 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md 2026-07-22-evidence-based-larger-hosted-runners.md: 5b399be5571ddaf1f775ba43a2233198b8e09b18 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 40970ec33c1a16af85ea47be3fc932209efdd654 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: f77516e2375bfc0557679d05fd275bd9cee7d8eb diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 40970ec33c..f77516e237 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.zh.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 ## 曾考虑的替代方案 From caabf8f671d194194b4d8b876566b847d2f73ddf Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 15:36:11 +0800 Subject: [PATCH 58/75] ci: dependabot stays hosted under failover; runbook matches shipped bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All four failover selectors (three workers + the verdict job) and the paired env/cache expressions now exclude dependabot[bot]: under failover, dependency-supplied code keeps queueing for the hosted pool instead of executing on the persistent VM. A delayed Dependabot PR during an outage is an acceptable cost; dependency code on the privileged host is not. - Runbook (both languages): records the shipped failover bounds (coverage 8, snapshots 12, sized for six instances) and documents that the verdict job follows the selector too — operators previously had no explanation for a verdict queued after all workers passed. - Local static gate green: 32 passed, 0 failed (translation pairing 519 pairs consistent). --- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../process/2026-07-26-ci-failover-runbook.md | 6 +++--- .../process/2026-07-26-ci-failover-runbook.zh.md | 6 +++--- .github/workflows/ci.yml | 14 +++++++++----- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index efb5fdd1cc..26f7f23f85 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 0bce83e0f9c842fa3dd73ae9c0a3eefc0975cdae -2026-07-26-ci-failover-runbook.zh.md: 4bc6c67bab754ad3f0127557b0d5e04f7934c8a2 +2026-07-26-ci-failover-runbook.md: ab1a727caa045d2074a9c577416f96f45efcd0aa +2026-07-26-ci-failover-runbook.zh.md: 5dfaca0c1c0f443307bea28bb6544385ebb68bb7 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 0bce83e0f9..ab1a727caa 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -6,11 +6,11 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md) ## Problem -The three required Linux jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch a repository admin can throw without merging anything. +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) and the required verdict job that aggregates them (`all checks passed`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch a repository admin can throw without merging anything. ## Decision -Each of the three required Linux jobs resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by a repository admin, all three retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is admin-only repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. +Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by a repository admin, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is admin-only repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. ### What the in-house pool is @@ -20,7 +20,7 @@ Each of the three required Linux jobs resolves its runner pool through the `DSH_ 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. -3. That is the entire switch. Under failover the workflow also, automatically: halves `DSH_COVERAGE_MAX_WORKERS` to 12 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 16 (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). +3. That is the entire switch. Under failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). ### Capacity during failover diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 4bc6c67bab..5dfaca0c1c 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个仓库管理员无需合并任何代码即可触发的开关。 +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)以及聚合它们的必需判定作业(`all checks passed`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个仓库管理员无需合并任何代码即可触发的开关。 ## 决策 -三个必需的 Linux 作业各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由仓库管理员设为 `selfhosted` 时,三者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是仅限管理员的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 +三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由仓库管理员设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是仅限管理员的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 ### 自有池是什么 @@ -20,7 +20,7 @@ Status: implemented 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 -3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 12、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 16(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 +3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏 6 × 8 = 48 个覆盖率工作进程对 64 核)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 ### 切换期间的容量 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df3d386e39..8306b7e034 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: >- ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / static @@ -60,7 +61,7 @@ jobs: # compression and upload on the paid latency-critical path. Skipped # under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 - if: vars.DSH_CI_FAILOVER != 'selfhosted' + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -98,6 +99,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: >- ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-enterprise-ubuntu-24-04-32core-test' }} name: node 24 / coverage @@ -107,7 +109,7 @@ jobs: # across six always-on runner instances, and the timing-sensitive # process suites have documented aggregate-contention failures. # 8 × 6 instances = 48 workers worst case on 64 cores. - DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '8' || '24' }} + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '24' }} DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 @@ -118,7 +120,7 @@ jobs: # serves warm installs directly, and this hosted-path restore would # spend ~52 s pulling ~180 MB into a path pnpm never reads there. - uses: actions/cache/restore@v4 - if: vars.DSH_CI_FAILOVER != 'selfhosted' + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -151,6 +153,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: >- ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / snapshots and artifacts @@ -161,7 +164,7 @@ jobs: DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_PUBLINT_CONCURRENCY: '8' # Failover halves snapshot concurrency for the shared 64-core VM. - DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && '12' || '32' }} + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: - uses: actions/checkout@v6 with: @@ -177,7 +180,7 @@ jobs: # Skipped under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 - if: vars.DSH_CI_FAILOVER != 'selfhosted' + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -773,6 +776,7 @@ jobs: # queued forever on the failed pool. runs-on: >- ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-enterprise-ubuntu-latest-32core-test' }} needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows] From d606ab6877a4516a422ab45e4160873843aa9a55 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 15:47:19 +0800 Subject: [PATCH 59/75] =?UTF-8?q?ci:=20standby=20archive=20base=20ref;=20r?= =?UTF-8?q?unbook=20=E2=80=94=20dependabot=20exception,=20clean=20bootstra?= =?UTF-8?q?p,=20variable=20trust=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serial-linux-selfhosted now fetches depth 2 and passes DSH_ARCHIVE_BASE_REF=github.event.before, running the same frozen-archive comparison as serial-linux instead of diffing the new manifest against itself. - Runbook (both languages): documents the deliberate dependabot exception (queued-on-hosted during failover is expected, not a failed switch); corrects the emergency-capacity bootstrap to exclude .runner/.credentials when cloning a runner directory; and replaces the 'admin-only' variable claim with the accurate trust-model statement — repository variables are writer-manageable, which in this private fork-disabled repo with an all-workflows runner group is routing among members, not an escalation. Static gate green locally: 32 passed, 0 failed. --- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../implemented/process/2026-07-26-ci-failover-runbook.md | 8 ++++++-- .../process/2026-07-26-ci-failover-runbook.zh.md | 8 ++++++-- .github/workflows/ci.yml | 6 ++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 26f7f23f85..7b8d08befe 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: ab1a727caa045d2074a9c577416f96f45efcd0aa -2026-07-26-ci-failover-runbook.zh.md: 5dfaca0c1c0f443307bea28bb6544385ebb68bb7 +2026-07-26-ci-failover-runbook.md: 55c1350593562d62463e751451d50a79cf45a1d6 +2026-07-26-ci-failover-runbook.zh.md: 13977b78244440a23722d089849ea7ff6b751aea diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index ab1a727caa..55c1350593 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -22,9 +22,13 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. 3. That is the entire switch. Under failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). -### Capacity during failover +#**Dependabot exception.** All four selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VM. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. -Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner) — cloning an existing runner directory and running `config.sh` takes about a minute per instance. +**Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so the switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner group admits all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VM by pushing a branch workflow. The boundary against untrusted code is repository membership; the variable only routes work for members. + +## Capacity during failover + +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh`; copying `.runner`/`.credentials` verbatim makes `config.sh` refuse with "already configured". About a minute per instance. ### Switch back diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 5dfaca0c1c..13977b7824 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -22,9 +22,13 @@ Status: implemented 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏 6 × 8 = 48 个覆盖率工作进程对 64 核)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 -### 切换期间的容量 +#**Dependabot 例外。**四个选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖方提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 -6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例——复制现有 runner 目录再跑 `config.sh`,每个约一分钟。 +**谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此该开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成越权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。 + +## 切换期间的容量 + +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`;原样拷贝 `.runner`/`.credentials` 会使 `config.sh` 以 "already configured" 拒绝。每个约一分钟。 ### 切回 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8306b7e034..8c3b854ae6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -423,7 +423,12 @@ jobs: name: serial / linux (self-hosted standby) runs-on: [self-hosted, linux, x64, vm-backup] steps: + # fetch-depth 2 + DSH_ARCHIVE_BASE_REF below: same frozen-archive + # comparison as serial-linux — without the prior commit the archive + # verifier defaults to HEAD and compares the new manifest with itself. - uses: actions/checkout@v6 + with: + fetch-depth: 2 - uses: actions/setup-node@v6 with: @@ -440,6 +445,7 @@ jobs: - name: Run complete unsharded primary Node CI serially env: + DSH_ARCHIVE_BASE_REF: ${{ github.event.before }} DSH_COVERAGE_MAX_WORKERS: '1' DSH_E2E_MAX_WORKERS: '1' DSH_ESLINT_CACHE: '1' From 4701373fc2cf87e14fb53885f9fa434b54ca0e20 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Mon, 27 Jul 2026 15:52:35 +0800 Subject: [PATCH 60/75] fix(workspace): remove transient duplicate warning --- ...-workspace-registration-deletion.i18n.yaml | 4 +- ...6-07-27-workspace-registration-deletion.md | 4 +- ...7-27-workspace-registration-deletion.zh.md | 4 +- apps/web/tests/workspace-management.e2e.ts | 88 +++++++++++++++++++ .../runtime/src/client/workspaces/manager.ts | 15 +++- .../src/client/WorkspaceBrowser.tsx | 15 +++- .../src/client/WorkspacePicker.tsx | 2 +- .../tests/workspace-browser.spec.tsx | 7 +- .../tests/workspace-picker.spec.tsx | 33 +++++-- 9 files changed, 155 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml index 93c78373c6..d576fb10e5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.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-workspace-registration-deletion.md -2026-07-27-workspace-registration-deletion.md: 58ae5c4bef2cf1cb0a0158eda5eb37daf2e9703d -2026-07-27-workspace-registration-deletion.zh.md: 7a79a1ccc53a0d4fd7e5ab453239ade955313c6e +2026-07-27-workspace-registration-deletion.md: 8168b0832ca39e6023f6981815ffe758b5695361 +2026-07-27-workspace-registration-deletion.zh.md: b0df6982ac81426a5b0ce2f0e2b0e744212e3f5b diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md index 58ae5c4bef..8168b0832c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md @@ -28,6 +28,8 @@ Create and delete write a durable `pendingMutation` before their record/order pa `WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta. +The delete confirmation remains pending until the React Workspace projection has committed the removed id, so the next create gesture cannot observe one stale list frame. During create, duplicate-name validation is suppressed while the request is pending because the committed `host/workspace-changed` frame may publish the newly created Workspace before its unary response; after failure returns the form to editing, validation uses the latest list again. + ## Confirmation interaction The existing Workspace row menu opens a shared `Modal` before deletion. The text states all three consequences: the Workspace leaves the list, the folder and session logs remain, and its Sessions appear under Ungrouped. While the request is pending, the confirm and Cancel controls are disabled, duplicate confirmation is ignored, and Escape or Close cannot dismiss the operation. Failure keeps the Modal open with the error; Cancel, Escape, and Close before submission never delete. @@ -48,7 +50,7 @@ The menu, Modal, and buttons retain their existing structure and design tokens. ## Verification -Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close. +Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, projection-settled closing, pending-state duplicate suppression, success-frame-before-unary ordering, failure, Cancel, Escape, and Close. The browser scenario observes every transient alert, slot error, console error, and page error while reusing a deleted title for a different directory. The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload. diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md index 7a79a1ccc5..b0df6982ac 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md @@ -28,6 +28,8 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin `WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 +删除确认框会保持待处理,直到 React Workspace 投影已经提交目标 id 的移除,因此下一次创建操作不会读到一帧陈旧列表。创建请求进行中会暂停重复名称校验,因为已提交的 `host/workspace-changed` 帧可能先于一元响应发布刚创建的 Workspace;如果请求失败并让表单回到可编辑状态,系统会重新使用最新列表执行校验。 + ## 确认交互 现有 Workspace 行菜单会在删除前打开共享 `Modal`。文案明确说明三项后果:Workspace 会从列表中移除,文件夹和会话日志会保留,相关会话会出现在 Ungrouped 下。请求待处理期间,确认与 Cancel 控件均被禁用,重复确认会被忽略,Escape 或 Close 也无法关闭此次操作。失败时 `Modal` 保持打开并显示错误;提交前使用 Cancel、Escape 或 Close 绝不会触发删除。 @@ -48,7 +50,7 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin ## Verification -Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。 +Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、待处理状态下抑制重复提交、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。 组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 3239dcfc12..98a2338064 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -108,6 +108,31 @@ describe('web e2e: workspace management (create / rename / flat view / hover car it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete')) + const slotConsoleErrors: string[] = [] + const transientSlotErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) { + slotConsoleErrors.push(message.text()) + } + }) + await page.exposeFunction('recordDshSlotError', (key: string) => { + if (!transientSlotErrors.includes(key)) transientSlotErrors.push(key) + }) + await page.evaluate(() => { + const target = window as unknown as { recordDshSlotError(key: string): Promise } + const seen = new Set() + const collect = (): void => { + for (const node of document.querySelectorAll('[data-slot-error]')) { + const key = node.dataset.slotError ?? '' + if (!seen.has(key)) { + seen.add(key) + void target.recordDshSlotError(key) + } + } + } + new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true }) + collect() + }) // Register the scaffold's existing project directory through the real UI. await page.getByRole('button', { name: 'Create workspace' }).click() await page.getByRole('menuitem', { name: 'Create workspace' }).hover() @@ -212,6 +237,69 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await stat(logLocation.path) expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + expect(transientSlotErrors).toEqual([]) + expect(slotConsoleErrors).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it('reuses a deleted title for a different new directory without any transient error surface', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-reuse-title')) + const title = 'same-name' + const oldPath = join(scaffold.workspaceCwd, 'adopted', title) + await mkdir(oldPath, { recursive: true }) + const transientErrors: string[] = [] + const consoleErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + await page.exposeFunction('recordDshTransientWorkspaceError', (message: string) => { + if (!transientErrors.includes(message)) transientErrors.push(message) + }) + await page.evaluate(() => { + const target = window as unknown as { + recordDshTransientWorkspaceError(message: string): Promise + } + const collect = (): void => { + for (const node of document.querySelectorAll('[data-slot-error], [role="alert"]')) { + const message = node.dataset.slotError ?? node.textContent?.trim() ?? '' + if (message !== '') void target.recordDshTransientWorkspaceError(message) + } + } + new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true }) + collect() + }) + + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const adopt = page.getByRole('dialog', { name: 'Use an existing folder' }) + await adopt.getByLabel('Existing folder path').fill(oldPath) + await adopt.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0) + const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath) + if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered') + + const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first() + await oldRow.hover() + await page.getByRole('button', { name: `Workspace actions for ${title}` }).click() + await page.getByRole('menuitem', { name: 'Delete workspace' }).click() + await page.getByRole('dialog', { name: 'Delete workspace' }) + .getByRole('button', { name: 'Delete workspace' }).click() + await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined() + + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() + const create = page.getByRole('dialog', { name: 'Create a new workspace' }) + await create.getByLabel('New workspace name').fill(title) + await create.getByRole('button', { name: 'Create workspace' }).click() + await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0) + const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title) + expect(fresh?.id).toBeDefined() + expect(fresh?.id).not.toBe(oldWorkspace.id) + expect(fresh?.path).toBe(join(scaffold.workspaceCwd, title)) + expect(transientErrors).toEqual([]) + expect(consoleErrors).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 7179ed9eb9..ce4198cd01 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -133,7 +133,7 @@ export class WorkspaceManager { */ async delete(workspaceId: WorkspaceId): Promise> { const { result } = await this.api.workspace.delete({ workspaceId }) - if (result.ok) this.remove(workspaceId) + if (result.ok) this.remove(workspaceId, true) return result } @@ -224,14 +224,21 @@ export class WorkspaceManager { } /** Remove one id idempotently and retain a tombstone against late echoes. */ - private remove(workspaceId: WorkspaceId): void { + private remove(workspaceId: WorkspaceId, direct = false): void { this.refreshFrames?.push({ type: 'remove', workspaceId }) this.removedIds.add(workspaceId) const items = this.items.filter(item => item.getSnapshot().view?.workspaceId !== workspaceId) - if (items.length === this.items.length) return + if (items.length === this.items.length) { + // The Host frame may have removed the row first but left its batched + // notification pending. A successful unary echo still flushes that + // committed state before the user action resolves. + if (direct) this.notifier.notifyNow() + return + } this.items = items - this.notifier.markDirty() + if (direct) this.notifier.notifyNow() + else this.notifier.markDirty() } private installViews(views: readonly WorkspaceView[]): void { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 0090164928..c56de93c56 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -307,7 +307,15 @@ export function WorkspaceBrowser({ // unmount that row without tearing down the in-flight confirmation state. const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) const [deleting, setDeleting] = useState(false) + const [deleteCommittedId, setDeleteCommittedId] = useState(null) const [deleteError, setDeleteError] = useState(null) + useEffect(() => { + if (deleteCommittedId === null + || workspaces.some(workspace => workspace.workspaceId === deleteCommittedId)) return + setDeleting(false) + setDeleteCommittedId(null) + setDeleteTarget(null) + }, [deleteCommittedId, workspaces]) const closeDelete = () => { if (deleting) return setDeleteTarget(null) @@ -317,10 +325,13 @@ export function WorkspaceBrowser({ /* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */ if (deleting || deleteTarget === null) return setDeleting(true) + setDeleteCommittedId(null) setDeleteError(null) deleteWorkspace(deleteTarget.workspaceId).then(() => { - setDeleting(false) - setDeleteTarget(null) + // Keep the confirmation pending until this component has rendered the + // committed list projection without the deleted id. Closing earlier + // exposes one stale React frame to the next Create Workspace gesture. + setDeleteCommittedId(deleteTarget.workspaceId) }).catch((reason: unknown) => { setDeleting(false) setDeleteError(reason instanceof Error ? reason.message : String(reason)) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 2be39875bc..99ed2831a6 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -60,7 +60,7 @@ export function WorkspaceCreateFlow({ const [creating, setCreating] = useState(false) const [modalError, setModalError] = useState(null) const normalizedWorkspaceName = workspaceName.trim() - const duplicateWorkspaceName = normalizedWorkspaceName !== '' + const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) const items: MenuEntry[] = [ diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 1dbe895b74..33abdc1231 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -461,7 +461,7 @@ describe('WorkspaceBrowser', () => { it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => { let resolveDelete!: () => void const deleteWorkspace = vi.fn(() => new Promise((resolve) => { resolveDelete = resolve })) - mount({ + const browser = mount({ useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])), deleteWorkspace, }) @@ -484,6 +484,11 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() await act(async () => { resolveDelete() }) + // RPC success alone does not close: the component waits until its + // useWorkspaces projection has committed the removal, preventing a stale + // duplicate-name frame from leaking into the next create gesture. + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() + rerender(browser, { useWorkspaces: hook(workspaceState([])) }) expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index d487fae5f8..a5510178e7 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -35,18 +35,25 @@ function anchor(): { current: HTMLElement } { function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) { const onPick = vi.fn() const onClose = vi.fn() - const view = render( + const anchorRef = anchor() + const renderPicker = (nextItems: readonly WorkspaceView[]) => ( , + /> ) - return { view, onPick, onClose, createWorkspace } + const view = render( + renderPicker(items), + ) + return { + view, onPick, onClose, createWorkspace, + rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, + } } function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void { @@ -106,6 +113,22 @@ describe('WorkspacePicker', () => { expect(b.createWorkspace).not.toHaveBeenCalled() }) + it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => { + let resolve!: (workspace: WorkspaceView) => void + const pending = new Promise((settle) => { resolve = settle }) + const created = workspace('fresh', 'same-name') + const b = mount([], vi.fn(() => pending)) + chooseCreateItem('Create a new workspace') + fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + + b.rerenderItems([created]) + expect(screen.getByRole('status').textContent).toBe('Creating workspace…') + expect(screen.queryByRole('alert')).toBeNull() + await act(async () => { resolve(created); await pending }) + expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) + }) + it('exposes creation phase and error text while retaining the modal for retry', async () => { let reject!: (reason: unknown) => void const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) From e2eca69e9c0ba88f591afe84727b4634d596c3b3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 15:54:59 +0800 Subject: [PATCH 61/75] docs(ci): writer-level trust boundary stated everywhere; serial note counts four references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sweep every remaining 'admin-only' claim (workflow comments, runbook lines 13/40, topology note, all zh pairs): the variable is writer-manageable, and the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded) — stated identically at every site instead of only in the 'who can flip' paragraph. - Serial cross-platform reference note (both languages): master now runs four references — the three hosted OS legs plus the self-hosted standby drill, linked to the failover runbook. Static gate green locally: 32 passed, 0 failed. --- ...2026-07-21-serial-cross-platform-ci-reference.i18n.yaml | 6 +++--- .../2026-07-21-serial-cross-platform-ci-reference.md | 6 +++--- .../2026-07-21-serial-cross-platform-ci-reference.zh.md | 6 +++--- ...26-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../implemented/process/2026-07-26-ci-failover-runbook.md | 4 ++-- .../process/2026-07-26-ci-failover-runbook.zh.md | 4 ++-- .github/workflows/ci.yml | 7 ++++--- 10 files changed, 23 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 17edb300cc..50ac9c830b 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-serial-cross-platform-ci-reference.md: 5433d2c51831ce61d06a16ee0b0ed982911f9218 -2026-07-21-serial-cross-platform-ci-reference.zh.md: 041d53d13e14354c995e4b65defce94a97646b0a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +2026-07-21-serial-cross-platform-ci-reference.md: 3e3d3ed06a16baf81b940b50c3d3deb75b7d8894 +2026-07-21-serial-cross-platform-ci-reference.zh.md: e05f92c05ab66d5a444f29186c605b4609d36110 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 5433d2c518..3e3d3ed06a 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -14,15 +14,15 @@ Reviewers also need a direct answer to a simpler question: what happens when the ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. -Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. +Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. Platform ownership remains explicit inside that complete aggregate. `pty-local` supports Linux and macOS and therefore owns its unit and per-file coverage contract on POSIX rather than loading a backend that rejects `win32`; the Windows run still executes every portable package. Portable fixtures derive native paths through `node:path`, compare canonical identities with the same native realpath implementation as production, and use filenames legal on every host. ACP snapshot runs also pass both JavaScript and native realpath spellings of their generated cwd to the normalizer, which replaces aliases longest-first so Windows short and long paths cannot churn shared fixtures. The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. -Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. +Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 041d53d13e..e05f92c05a 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -14,15 +14,15 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 -每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 +每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS,因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端;Windows 仍会执行所有可移植包(package)。可移植 fixture(测试前置数据)通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACP(Agent Client Protocol)快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。 macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 -master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 +master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 99cabc76bb..f65ebb1ba4 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: 5b399be5571ddaf1f775ba43a2233198b8e09b18 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: f77516e2375bfc0557679d05fd275bd9cee7d8eb +2026-07-22-evidence-based-larger-hosted-runners.md: 180cc03ad091b2e9e96a86311515250f92065c6b +2026-07-22-evidence-based-larger-hosted-runners.zh.md: b81f67805fd543e81ede02ceeac2dda1831f4cef diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 5b399be557..180cc03ad0 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -52,7 +52,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the admin-only `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index f77516e237..b81f67805f 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过仅限管理员的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 7b8d08befe..de9ba10469 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 55c1350593562d62463e751451d50a79cf45a1d6 -2026-07-26-ci-failover-runbook.zh.md: 13977b78244440a23722d089849ea7ff6b751aea +2026-07-26-ci-failover-runbook.md: 05014454fa3e38045b89a857c346db0f897ab5a6 +2026-07-26-ci-failover-runbook.zh.md: e106a0de40799ca1c218217ea66c24068697dc53 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 55c1350593..05014454fa 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym ## Decision -Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by a repository admin, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is admin-only repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. +Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. ### What the in-house pool is @@ -37,7 +37,7 @@ Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhos ### Trust boundary -The variable is repository-admin-only state: a pull request can neither set it nor read a different value into effect, and the expressions live in the base branch's workflow definition. This failover path therefore adds no PR-editable route to the self-hosted pool. Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism. +The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 13977b7824..e106a0de40 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由仓库管理员设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是仅限管理员的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 +三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 ### 自有池是什么 @@ -37,7 +37,7 @@ Status: implemented ### 信任边界 -该变量是仅限仓库管理员的状态:拉取请求既不能设置它,也不能让不同的值生效,且表达式存在于基线分支的工作流定义中。因此这条故障切换路径没有增加任何可由 PR 编辑的自托管池访问途径。运行器侧的强制约束——通过组织级 runner group 把这批运行器限定到 master 引用的工作流——另行跟踪,与本机制互补。 +该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。(运行器侧的组织级 runner group 约束另行跟踪,与本机制互补。) ## 曾考虑的替代方案 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c3b854ae6..9fe393b93c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,9 @@ jobs: # FAILOVER: each Linux enterprise job resolves its pool through the # DSH_CI_FAILOVER repository variable. Unset (normal), the expressions # pick the hosted enterprise pools below. Setting the variable to - # 'selfhosted' (repo Settings → Actions → Variables; admin-only, not - # PR-editable, no merge required) retargets all three onto the in-house + # 'selfhosted' (repo Settings → Actions → Variables; writer-manageable + # repository state — not PR-editable, no merge required) retargets all + # three onto the in-house # vm-backup pool and re-running the failed jobs is the entire switch — # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The # in-house pool's readiness is re-proven on every master push by the @@ -411,7 +412,7 @@ jobs: # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if - # the hosted pools degrade (the switch is then setting the admin-only + # the hosted pools degrade (the switch is then setting the writer-manageable # DSH_CI_FAILOVER variable — see the failover runbook, no merge required). # Push-triggered, so it always executes the base branch's own workflow # definition — no PR-editable path selects these runners. Non-blocking for From 24d7211f09d28055c0795eac4c91f924d3593e3c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 16:02:19 +0800 Subject: [PATCH 62/75] docs(ci): stop claiming no PR-editable path reaches the standby pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standby lane itself is push-only, but under failover pull_request jobs do reach these runners with the PR merge ref's workflow. The workflow comment and the larger-runner note (both languages) now state that plainly and name the actual boundary — repository membership (private, forking disabled, Dependabot excluded) — matching the runbook. Static gate green locally: 32 passed, 0 failed. --- ...26-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .github/workflows/ci.yml | 7 +++++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index f65ebb1ba4..68b4098d4f 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: 180cc03ad091b2e9e96a86311515250f92065c6b -2026-07-22-evidence-based-larger-hosted-runners.zh.md: b81f67805fd543e81ede02ceeac2dda1831f4cef +2026-07-22-evidence-based-larger-hosted-runners.md: 67fc7ded5cffc6a219665f135a4c9e1cc4752691 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 71c5c067361b57fab5aae9e9ffa3850a30609db3 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 180cc03ad0..67fc7ded5c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -52,7 +52,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). Because the standby lane is push-triggered, it always executes the base branch's workflow definition — no pull-request-editable path can route code to these runners, and the repository additionally keeps forking disabled. +An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index b81f67805f..71c5c06736 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义——不存在任何可由拉取请求编辑的路径能把代码路由到这些运行器上;此外仓库继续保持禁用 fork。 +另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 ## 曾考虑的替代方案 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fe393b93c..140ae00446 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -414,8 +414,11 @@ jobs: # continuously proving that environment can take over a required lane if # the hosted pools degrade (the switch is then setting the writer-manageable # DSH_CI_FAILOVER variable — see the failover runbook, no merge required). - # Push-triggered, so it always executes the base branch's own workflow - # definition — no PR-editable path selects these runners. Non-blocking for + # Push-triggered, so this lane always executes the base branch's own + # workflow definition. (Under failover, pull_request jobs do reach these + # runners with the PR merge ref's workflow — the boundary there is + # repository membership: private, forking disabled, Dependabot excluded.) + # Non-blocking for # pull requests; no cache steps because the VM's persistent pnpm store and # tool caches make them redundant (and saving here would poison the hosted # cache namespace with self-hosted paths). From ce3b13bb0816d3a23b68b916087b3beef29fcc83 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 16:13:30 +0800 Subject: [PATCH 63/75] =?UTF-8?q?ci:=20standby=20fetches=20full=20history;?= =?UTF-8?q?=20runbook=20=E2=80=94=20writer=20wording=20throughout,=20maste?= =?UTF-8?q?r-ref=20pinning=20incompatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serial-linux-selfhosted checks out fetch-depth 0: depth 2 misses github.event.before on multi-commit or force pushes, failing the archive verifier on a valid tree. Full fetch is cheap against the VM's local mirror. - Runbook (both languages): every remaining admin phrasing (problem statement, switch heading, alternatives, consequences) now says writer; and the 'composes with this mechanism' claim about a master-ref-pinned runner group is replaced with the truth observed live on 2026-07-27 — master-ref pinning blocks PR failover, and the shipped posture is repository-scoped all-workflow group access. Static gate green locally: 32 passed, 0 failed. --- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../process/2026-07-26-ci-failover-runbook.md | 10 +++++----- .../process/2026-07-26-ci-failover-runbook.zh.md | 10 +++++----- .github/workflows/ci.yml | 9 +++++---- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index de9ba10469..658a85ce34 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 05014454fa3e38045b89a857c346db0f897ab5a6 -2026-07-26-ci-failover-runbook.zh.md: e106a0de40799ca1c218217ea66c24068697dc53 +2026-07-26-ci-failover-runbook.md: ca4349661d03ff4e28d7c3c2b6e910106ff4aa30 +2026-07-26-ci-failover-runbook.zh.md: 1d59bd537879f531c9075e833c9e1dbfbd4bb0a2 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 05014454fa..ca4349661d 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -6,7 +6,7 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md) ## Problem -The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) and the required verdict job that aggregates them (`all checks passed`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch a repository admin can throw without merging anything. +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) and the required verdict job that aggregates them (`all checks passed`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch any responder with repository write access can throw without merging anything. ## Decision @@ -16,7 +16,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver `vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. -### Switch (repo admin, ~1 minute, no merge) +### Switch (any repository writer, ~1 minute, no merge) 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. @@ -37,14 +37,14 @@ Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhos ### Trust boundary -The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism. +The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the four failover jobs are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access. ## Alternatives considered -**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is admin-controlled state that takes effect on re-run without a merge. +**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is writer-manageable state that takes effect on re-run without a merge. **Keep the self-hosted pool always in the required path.** Rejected because it trades hosted-pool availability for the in-house VM's, moving a single point of failure rather than adding a fallback. The variable keeps the hosted pools primary and the self-hosted pool a proven, one-action standby. ## Consequences -Recovering from a hosted-pool outage is a single admin variable plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg. +Recovering from a hosted-pool outage is a single variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index e106a0de40..1d59bd5378 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)以及聚合它们的必需判定作业(`all checks passed`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个仓库管理员无需合并任何代码即可触发的开关。 +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)以及聚合它们的必需判定作业(`all checks passed`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented `vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 -### 切换步骤(仓库管理员,约 1 分钟,无需合并) +### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 @@ -37,14 +37,14 @@ Status: implemented ### 信任边界 -该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。(运行器侧的组织级 runner group 约束另行跟踪,与本机制互补。) +该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——四个故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。 ## 曾考虑的替代方案 -**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是管理员控制的状态,重跑即生效,无需合并。 +**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是写者可管理的状态,重跑即生效,无需合并。 **让自托管池长期处于必需路径中。** 否决,因为这是拿托管池的可用性去换自有虚拟机的可用性,只是搬移了单点故障而非增加回退。该变量让托管池保持主路径,自托管池作为一个经过验证、一步即可启用的热备。 ## 后果 -从托管池故障中恢复只需一个管理员变量加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。 +从托管池故障中恢复只需一个变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 140ae00446..3c952a7bb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -427,12 +427,13 @@ jobs: name: serial / linux (self-hosted standby) runs-on: [self-hosted, linux, x64, vm-backup] steps: - # fetch-depth 2 + DSH_ARCHIVE_BASE_REF below: same frozen-archive - # comparison as serial-linux — without the prior commit the archive - # verifier defaults to HEAD and compares the new manifest with itself. + # Full history + DSH_ARCHIVE_BASE_REF below: same frozen-archive + # comparison as serial-linux. Depth 2 would miss github.event.before + # on multi-commit or force pushes; full fetch is cheap here because + # checkout resolves against the VM's local mirror. - uses: actions/checkout@v6 with: - fetch-depth: 2 + fetch-depth: 0 - uses: actions/setup-node@v6 with: From 3cf2853b3f04fdceb3ff99109dea8f764d28fbc3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 16:22:30 +0800 Subject: [PATCH 64/75] docs(ci): bootstrap procedure starts the listener service config.sh only registers; the runner stays offline until svc.sh install/start. Both language sides updated so emergency capacity actually comes online. --- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../implemented/process/2026-07-26-ci-failover-runbook.md | 2 +- .../implemented/process/2026-07-26-ci-failover-runbook.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 658a85ce34..0e3b0820c0 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: ca4349661d03ff4e28d7c3c2b6e910106ff4aa30 -2026-07-26-ci-failover-runbook.zh.md: 1d59bd537879f531c9075e833c9e1dbfbd4bb0a2 +2026-07-26-ci-failover-runbook.md: 80dd7c4291e3de11c2f13b3247af56762396c720 +2026-07-26-ci-failover-runbook.zh.md: 7933a857f1559c540fccc2cd89352c4fe351dd7e diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index ca4349661d..80dd7c4291 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -28,7 +28,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver ## Capacity during failover -Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh`; copying `.runner`/`.credentials` verbatim makes `config.sh` refuse with "already configured". About a minute per instance. +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; only a started service adds capacity. About a minute per instance. ### Switch back diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 1d59bd5378..7933a857f1 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -28,7 +28,7 @@ Status: implemented ## 切换期间的容量 -6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`;原样拷贝 `.runner`/`.credentials` 会使 `config.sh` 以 "already configured" 拒绝。每个约一分钟。 +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;只有启动了服务的 runner 才会增加容量。每个约一分钟。 ### 切回 From ebb5bf4c1941cb5bddf7395a3ba6a7e718f9f9eb Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 16:25:45 +0800 Subject: [PATCH 65/75] docs(ci): starting a new failover runner needs svc.sh, not just config.sh config.sh registers the instance without starting a listener, so the procedure as written left the new runner offline and added no capacity. --- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../implemented/process/2026-07-26-ci-failover-runbook.md | 2 +- .../implemented/process/2026-07-26-ci-failover-runbook.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index de9ba10469..db702da620 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 05014454fa3e38045b89a857c346db0f897ab5a6 -2026-07-26-ci-failover-runbook.zh.md: e106a0de40799ca1c218217ea66c24068697dc53 +2026-07-26-ci-failover-runbook.md: b93c86d73f319f40706a4f9b31f448804e7b5ba8 +2026-07-26-ci-failover-runbook.zh.md: 25b83981e70070800c5a4037807d26811e55124d diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 05014454fa..b93c86d73f 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -28,7 +28,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver ## Capacity during failover -Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh`; copying `.runner`/`.credentials` verbatim makes `config.sh` refuse with "already configured". About a minute per instance. +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh`; copying `.runner`/`.credentials` verbatim makes `config.sh` refuse with "already configured". `config.sh` only registers the instance — it starts no listener, so a runner that stops there is registered and offline, adding no capacity. Install and start its service too: `sudo ./svc.sh install && sudo ./svc.sh start` (this pool is systemd-managed; a foreground `./run.sh` also works but dies with the shell). Confirm the instance reports Idle in org Settings → Actions → Runners before counting it. About a minute per instance. ### Switch back diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index e106a0de40..25b83981e7 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -28,7 +28,7 @@ Status: implemented ## 切换期间的容量 -6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`;原样拷贝 `.runner`/`.credentials` 会使 `config.sh` 以 "already configured" 拒绝。每个约一分钟。 +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`;原样拷贝 `.runner`/`.credentials` 会使 `config.sh` 以 "already configured" 拒绝。`config.sh` 只完成注册,不启动监听进程,因此停在这一步的 runner 处于已注册但离线状态,不增加任何容量。还须安装并启动其服务:`sudo ./svc.sh install && sudo ./svc.sh start`(本池由 systemd 管理;前台运行 `./run.sh` 亦可,但会随 shell 退出而终止)。确认该实例在组织 Settings → Actions → Runners 中显示 Idle 后再计入容量。每个约一分钟。 ### 切回 From 7b49f4a4eeabc2970c4231ba26f5b2f9fbe56440 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 16:56:02 +0800 Subject: [PATCH 66/75] =?UTF-8?q?docs(ci):=20runbook=20=E2=80=94=20glob=20?= =?UTF-8?q?migrated=20identity=20files;=20scope=20the=20promise=20to=20ent?= =?UTF-8?q?erprise-pool=20outages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The clone exclusion is now '.runner*' (catches .runner_migrated / .credentials_migrated, which GitHub writes on migrated runners and which equally trip the already-configured refusal — hit live during the 2026-07-27 org-group migration). - The problem statement now states the verdict runs on ubuntu-latest, scopes the switch to enterprise Linux-pool outages, and names the residual dependency: node-compat/python-sdk/windows stay standard- hosted, so a broader hosted-capacity failure still blocks the verdict, with Windows the only leg lacking any in-house substitute. Static gate green locally: 32 passed, 0 failed. --- .../process/2026-07-26-ci-failover-runbook.i18n.yaml | 4 ++-- .../implemented/process/2026-07-26-ci-failover-runbook.md | 4 ++-- .../implemented/process/2026-07-26-ci-failover-runbook.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 0e3b0820c0..a7b9e827fa 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 80dd7c4291e3de11c2f13b3247af56762396c720 -2026-07-26-ci-failover-runbook.zh.md: 7933a857f1559c540fccc2cd89352c4fe351dd7e +2026-07-26-ci-failover-runbook.md: 4e4f8ea7fc60cf76fd8308147bbf7cc0bac74798 +2026-07-26-ci-failover-runbook.zh.md: bb7e43fe55c9cced51f042de6503978ec9349d6b diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 80dd7c4291..4e4f8ea7fc 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -6,7 +6,7 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md) ## Problem -The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) and the required verdict job that aggregates them (`all checks passed`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch any responder with repository write access can throw without merging anything. +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`, `windows`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`, and only the Windows leg has no in-house substitute at all — during the 2026-07-27 outage the standard pools recovered first, which is the ordering this design bets on. An outage therefore needs a switch any responder with repository write access can throw without merging anything. ## Decision @@ -28,7 +28,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver ## Capacity during failover -Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; only a started service adds capacity. About a minute per instance. +Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; only a started service adds capacity. About a minute per instance. ### Switch back diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 7933a857f1..bb7e43fe55 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)以及聚合它们的必需判定作业(`all checks passed`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障。**判定作业的其余必需依赖(`node-compat`、`python-sdk`、`windows`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`,且只有 Windows 这条腿完全没有自有替代——2026-07-27 的故障中标准池率先恢复,本设计押注的正是这一顺序。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 ## 决策 @@ -28,7 +28,7 @@ Status: implemented ## 切换期间的容量 -6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;只有启动了服务的 runner 才会增加容量。每个约一分钟。 +6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;只有启动了服务的 runner 才会增加容量。每个约一分钟。 ### 切回 From f12ea986cb46a414a276ba4d50c25d61aae12bb5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:04:05 +0800 Subject: [PATCH 67/75] ci: share one Wine Windows gate script between CI and an optional local gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/wine-windows-gates.sh owns the whole lane — workspace snapshot, concurrent Node/wineboot/install provisioning, entrypoint resolution, the vue link, smoke, and the two concurrent blocking gates — so the ci.yml windows job shrinks to runner provisioning plus one script call, and pnpm run check:windows-wine runs the identical script locally. The local path never mutates the working tree, persists the Wine prefix and the checksum-verified Windows Node zip under .cache/wine-windows/, and falls back to the cached zip offline. AGENTS.md ceiling 1680 -> 1695 for the one-line command entry. --- ...27-wine-windows-gates-experiment.i18n.yaml | 4 +- ...026-07-27-wine-windows-gates-experiment.md | 2 + ...-07-27-wine-windows-gates-experiment.zh.md | 2 + .github/workflows/ci.yml | 164 +++------------ AGENTS.md | 1 + package.json | 1 + scripts/doc-budgets.manifest.json | 2 +- scripts/wine-windows-gates.sh | 187 ++++++++++++++++++ 8 files changed, 218 insertions(+), 145 deletions(-) create mode 100755 scripts/wine-windows-gates.sh diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 8b8b736a99..cc4c64aae4 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.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-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: aab8aecdfca06c1f15641044a071015f543a84b6 -2026-07-27-wine-windows-gates-experiment.zh.md: 5239b185e1e0c63aa626ee3f20f3f298c0c8579d +2026-07-27-wine-windows-gates-experiment.md: 918d4ab4a1974c6f8ea4bebeb48025aeecf0ef57 +2026-07-27-wine-windows-gates-experiment.zh.md: e1de3395ccd59307b520cb59210923f7fe1a21fd diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index aab8aecdfc..918d4ab4a1 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -18,6 +18,8 @@ Dependencies install natively on Linux with `supportedArchitectures` extended to The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. +The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable. + Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). ## Measured results diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index 5239b185e1..e1de3395cc 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -18,6 +18,8 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 +门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 + 四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 ## 实测结果 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19deb87034..da19a514fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,20 +286,16 @@ jobs: # Windows Node under Wine on standard hosted Linux. The master # serial-windows job below keeps the complete native-kernel inventory — # including the observational portability gates this lane does not run — - # on real windows-2025. Direct tool entrypoints stand in for pnpm's cmd - # shims, which a Linux-side install does not create; layout, fidelity - # limits, and measured timings live in + # on real windows-2025. This job only provisions runner state (caches, + # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same + # script the optional local gate `pnpm run check:windows-wine` runs. + # Layout, fidelity limits, and measured timings live in # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md windows: if: github.event_name == 'pull_request' runs-on: ubuntu-latest name: windows node 24 / wine blocking timeout-minutes: 15 - env: - WINEDEBUG: '-all' - WINEARCH: win64 - # Skip Wine Mono / Gecko installers: Node needs neither. - WINEDLLOVERRIDES: 'mscoree,mshtml=' steps: - uses: actions/checkout@v6 with: @@ -328,144 +324,28 @@ jobs: path: ~/wine-debs key: ${{ steps.wine-cache-key.outputs.key }} - - name: Install dependencies and provision Wine concurrently + # Runner provisioning only — a developer machine installs Wine through + # its own package manager; the gate script assumes a wine binary and + # fails loud without one. Wine from the apt cache when present; else + # download the full dependency closure once and keep it for the next + # run. The `wine` dispatcher package (not bare `wine64`) is what puts a + # binary on PATH. + - name: Install Wine run: | - corepack enable - - # Windows-lane install-time overrides. supportedArchitectures - # additionally materializes the win32-x64 platform packages - # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) the - # Windows toolchain resolves at runtime; nodeLinker: hoisted lays - # node_modules out flat with real files because Windows Node under - # Wine does not realpath pnpm's isolated-layout symlinks. Neither - # override is recorded in the lockfile, so --frozen-lockfile stays - # valid. --ignore-scripts skips Linux lifecycle scripts no gate in - # this lane loads; the win32 binaries ship prebuilt. - cat >> pnpm-workspace.yaml <<'EOF' - - nodeLinker: hoisted - supportedArchitectures: - os: [current, win32] - cpu: [current, x64] - EOF - - pnpm install --frozen-lockfile --ignore-scripts & - install_pid=$! - - provision_wine() { - set -euo pipefail - # Wine from the apt cache when present; else download the full - # dependency closure once and keep it for the next run. The - # `wine` dispatcher package (not bare `wine64`) is what puts a - # binary on PATH. - if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then - sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb - else - sudo apt-get update - sudo apt-get install -y --no-install-recommends --download-only wine - mkdir -p "$HOME/wine-debs" - cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true - sudo apt-get install -y --no-install-recommends wine - fi - WINE_BIN='' - for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do - if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi - done - [ -n "$WINE_BIN" ] || { echo '::error::no wine binary found after install'; exit 1; } - echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV" - - # Windows Node for the repo's primary line, checksum-verified - # against the same dist directory. - version=$(curl -fsSL https://nodejs.org/dist/index.json \ - | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version') - echo "Windows Node: $version" - curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \ - "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip" - curl -fsSL "https://nodejs.org/dist/${version}/SHASUMS256.txt" \ - | awk -v a="node-${version}-win-x64.zip" '$2 == a { print $1 " '"$RUNNER_TEMP"'/node-win.zip" }' \ - | sha256sum --check - - unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win" - echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV" - - "$WINE_BIN" wineboot --init || true - wineserver -w || true - } - provision_wine & - wine_pid=$! - - install_status=0 - wait "$install_pid" || install_status=$? - wine_status=0 - wait "$wine_pid" || wine_status=$? - if (( install_status != 0 )); then exit "$install_status"; fi - exit "$wine_status" - - - name: Resolve entrypoints, link vue, smoke Windows Node - run: | - # Node under Wine cannot attach stdio to the Actions runner's pipes - # (Socket open EBADF at bootstrap), so every invocation runs through - # this wrapper: stdio to a regular file, replayed after exit. - cat > "$RUNNER_TEMP/wine-node.sh" <<'SH' - #!/usr/bin/env bash - set -u - log="$1"; shift - "$WINE_BIN" "$NODE_WIN" "$@" < /dev/null > "$log" 2>&1 - status=$? - tail -n 300 "$log" - exit "$status" - SH - chmod +x "$RUNNER_TEMP/wine-node.sh" - - resolve() { - local name="$1"; shift - for p in "$@"; do - if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi - done - echo "::error::$name not found at any of: $*"; return 1 - } - resolve TSC_JS node_modules/typescript/bin/tsc - resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs - resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js - - # VitePress links vue into the site's node_modules at build time; - # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows - # pre-existing Unix ones, so lay the link down host-side. - if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then - mkdir -p website/node_modules - ln -s ../../node_modules/vue website/node_modules/vue + if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then + sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends --download-only wine + mkdir -p "$HOME/wine-debs" + cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true + sudo apt-get install -y --no-install-recommends wine fi - "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" - - # The two blocking surfaces run concurrently, the same shape run-gates - # gives ci-windows-blocking on native Windows: `build` = tsc -b then - # tsdown, `production site` = the VitePress build. Both statuses are - # captured so one failure cannot hide the other's result. - - name: Run blocking Windows gates concurrently under Wine + - name: Run the Wine Windows gates run: | - build_gate() { - "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false || return $? - "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS" - } - site_gate() { - cd website - "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build . - } - start=$SECONDS - build_gate > "$RUNNER_TEMP/build-gate.out" 2>&1 & - build_pid=$! - site_gate > "$RUNNER_TEMP/site-gate.out" 2>&1 & - site_pid=$! - build_status=0 - wait "$build_pid" || build_status=$? - site_status=0 - wait "$site_pid" || site_status=$? - echo "== build gate (exit $build_status, $((SECONDS - start))s elapsed) ==" - tail -n 120 "$RUNNER_TEMP/build-gate.out" - echo "== production site gate (exit $site_status, $((SECONDS - start))s elapsed) ==" - tail -n 120 "$RUNNER_TEMP/site-gate.out" - if (( build_status != 0 )); then exit "$build_status"; fi - exit "$site_status" + corepack enable + bash scripts/wine-windows-gates.sh - name: Shut down wineserver if: always() diff --git a/AGENTS.md b/AGENTS.md index d6d0365dfa..0749bf5043 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime +pnpm run check:windows-wine # optional: CI's blocking Windows gates locally (needs wine) pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) diff --git a/package.json b/package.json index 90194a57d3..a1b3b1fdca 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", + "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index f1d40380e5..674d57ad97 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1680, + "AGENTS.md": 1695, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh new file mode 100755 index 0000000000..97f09bb9b3 --- /dev/null +++ b/scripts/wine-windows-gates.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Run the blocking Windows gates (workspace build, production site) with real +# win-x64 Node.js under Wine — the same script the pull-request `windows` job +# in ci.yml executes and the optional local gate `pnpm run check:windows-wine` +# wraps. Owning rationale, fidelity limits, and measured timings: +# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +# +# The working tree is never mutated: tracked plus untracked-unignored files +# are snapshotted into a scratch directory, the Wine-specific pnpm overrides +# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's +# pnpm-workspace.yaml, and the install and gates run there against the shared +# pnpm store. The Wine prefix and the checksum-verified Windows Node zip +# persist in .cache/wine-windows/ so reruns skip provisioning. +# +# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24) +# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache; +# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection. + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}" +cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}" + +export WINEDEBUG='-all' +export WINEARCH=win64 +# Skip Wine Mono / Gecko installers: Node needs neither. +export WINEDLLOVERRIDES='mscoree,mshtml=' +export WINEPREFIX="$cache_dir/prefix" + +# ---- preflight: fail loud before any expensive work -------------------- +wine_bin='' +for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi +done +missing=() +[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)') +command -v curl > /dev/null || missing+=('curl') +command -v unzip > /dev/null || missing+=('unzip') +if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi +command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)') +if (( ${#missing[@]} > 0 )); then + printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2 + exit 1 +fi + +scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")" +cleanup() { + wineserver -k > /dev/null 2>&1 || true + if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then + echo "wine-windows-gates: scratch tree kept at $scratch" + else + rm -rf "$scratch" + fi +} +trap cleanup EXIT +mkdir -p "$cache_dir" "$scratch/logs" + +# ---- provision Windows Node, boot Wine, snapshot + install concurrently ---- +provision_node() { + # Latest release of the primary line, checksum-verified against the same + # dist directory. Offline runs fall back to the newest cached zip, loudly. + local version zip + version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \ + | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \ + || true)" + if [ -n "$version" ]; then + zip="$cache_dir/node-$version-win-x64.zip" + if [ ! -f "$zip" ]; then + curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip" + curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \ + | awk -v a="node-$version-win-x64.zip" -v f="$zip.tmp" '$2 == a { print $1 " " f }' \ + | sha256sum --check - > /dev/null + mv "$zip.tmp" "$zip" + fi + else + zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)" + [ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; } + echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2 + fi + unzip -q -o "$zip" -d "$scratch/node-win" + echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path" +} + +boot_wine() { + "$wine_bin" wineboot --init > /dev/null 2>&1 || true + wineserver -w || true +} + +snapshot_and_install() { + # Tracked + untracked-unignored files, minus agent-session litter; the + # existence filter drops paths staged as deleted. Then the Wine-specific + # install-time overrides go on the SNAPSHOT only: hoisted because Windows + # Node under Wine does not realpath pnpm's isolated-layout symlinks, and + # win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize. + # Neither is recorded in the lockfile, so --frozen-lockfile stays valid; + # --ignore-scripts skips host lifecycle scripts no gate loads. + git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \ + | while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \ + | tar -C "$repo_root" --null --files-from=- -cf - \ + | tar -C "$scratch/tree" -xf - + cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF' + +nodeLinker: hoisted +supportedArchitectures: + os: [current, win32] + cpu: [current, x64] +EOF + (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ + || { tail -40 "$scratch/logs/install.log" >&2; return 1; } +} + +mkdir "$scratch/tree" +start=$SECONDS +provision_node & node_pid=$! +boot_wine & wine_pid=$! +snapshot_and_install & install_pid=$! +for task_pid in "$node_pid" "$wine_pid" "$install_pid"; do wait "$task_pid"; done +node_win="$(cat "$scratch/node-win-path")" +echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))" + +# ---- resolve entrypoints, lay the vue link, smoke ------------------------ +# Node under Wine cannot attach stdio to pipes the caller owns (Socket open +# EBADF at bootstrap), so every invocation routes stdio through a file. +wine_node() { + local log="$1" + shift + local status=0 + "$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$? + return "$status" +} + +cd "$scratch/tree" +tsc_js='node_modules/typescript/bin/tsc' +tsdown_js='node_modules/tsdown/dist/run.mjs' +vitepress_js='node_modules/vitepress/bin/vitepress.js' +[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js' +for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do + [ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; } +done +# VitePress links vue into the site's node_modules at build time; Wine cannot +# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones. +if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then + mkdir -p website/node_modules + ln -s ../../node_modules/vue website/node_modules/vue +fi + +wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" +cat "$scratch/logs/smoke.log" +grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; } + +# ---- the two blocking surfaces, concurrently ------------------------------ +# The same shape run-gates gives ci-windows-blocking on native Windows: +# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both +# statuses are captured so one failure cannot hide the other's result. +build_gate() { + wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $? + wine_node "$scratch/logs/tsdown.log" "$tsdown_js" +} +site_gate() { + cd website + wine_node "$scratch/logs/site.log" "../$vitepress_js" build . +} + +start=$SECONDS +build_gate & build_pid=$! +site_gate & site_pid=$! +build_status=0 +wait "$build_pid" || build_status=$? +site_status=0 +wait "$site_pid" || site_status=$? +elapsed=$((SECONDS - start)) + +report() { + local label="$1" status="$2" + shift 2 + if (( status == 0 )); then + echo "wine-windows-gates: PASS $label (${elapsed}s window)" + else + echo "== FAILED $label (exit $status) ==" >&2 + for log in "$@"; do tail -n 200 "$log" >&2 || true; done + fi +} +report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log" +report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log" +if (( build_status != 0 )); then exit "$build_status"; fi +exit "$site_status" From 8be70e72d216535f0ae1e8fd91aacb9b66af0626 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:15:37 +0800 Subject: [PATCH 68/75] docs: scope the local Wine gate to Windows-failure diagnosis only AGENTS.md and the Wine Agent Note pair state the run policy: CI owns the everyday win32 signal; run check:windows-wine only while investigating a known Windows-related failure, and pre-push selection never includes it. --- .../2026-07-27-wine-windows-gates-experiment.i18n.yaml | 4 ++-- .../process/2026-07-27-wine-windows-gates-experiment.md | 2 +- .../process/2026-07-27-wine-windows-gates-experiment.zh.md | 2 +- AGENTS.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index cc4c64aae4..5f939a7b67 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.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-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: 918d4ab4a1974c6f8ea4bebeb48025aeecf0ef57 -2026-07-27-wine-windows-gates-experiment.zh.md: e1de3395ccd59307b520cb59210923f7fe1a21fd +2026-07-27-wine-windows-gates-experiment.md: fd36a3322b0d63a0fa605ecfcec95c7522b43b25 +2026-07-27-wine-windows-gates-experiment.zh.md: 97a54161bb851785a9108cc45d59617e1a449e68 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index 918d4ab4a1..fd36a3322b 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -18,7 +18,7 @@ Dependencies install natively on Linux with `supportedArchitectures` extended to The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. -The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable. +The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The local gate is a diagnosis tool, not a routine check: run it only when investigating a known Windows-related failure; CI owns the everyday win32 signal, and [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) never selects it. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable. Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index e1de3395cc..97a54161bb 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -18,7 +18,7 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 -门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 +门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。该本地门禁是诊断工具而非例行检查:仅在排查已知的 Windows 相关失败时运行;日常 win32 信号归 CI 所有,[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 也从不选择它。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 diff --git a/AGENTS.md b/AGENTS.md index 0749bf5043..dabf2b9945 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime -pnpm run check:windows-wine # optional: CI's blocking Windows gates locally (needs wine) +pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) From e8cbf6cc723df323cd316d6dce6395d0f3da2412 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:22:01 +0800 Subject: [PATCH 69/75] fix: remove orig --- .../src/client/sessions/service.ts.orig | 590 ------------------ .../ui-workspace/src/client/index.ts.orig | 98 --- .../ui-workspace/src/client/tree.ts.orig | 321 ---------- 3 files changed, 1009 deletions(-) delete mode 100644 packages/client/runtime/src/client/sessions/service.ts.orig delete mode 100644 packages/client/ui-workspace/src/client/index.ts.orig delete mode 100644 packages/client/ui-workspace/src/client/tree.ts.orig diff --git a/packages/client/runtime/src/client/sessions/service.ts.orig b/packages/client/runtime/src/client/sessions/service.ts.orig deleted file mode 100644 index deb1616a8a..0000000000 --- a/packages/client/runtime/src/client/sessions/service.ts.orig +++ /dev/null @@ -1,590 +0,0 @@ -/** - * SessionsService: root sessions service — list snapshot store (manager - * projection; carries `current`, the persisted selection every - * session-scoped surface keys off — migrated here from ui-layout per the - * slot-parity design), Agent scope tree (mintScope pattern: no-op plugin - * Fiber + ctx.extend scope tag; one scope per session, agent id === session - * id), stable SessionBinding cache, ancestry walk. - * - * Scope lifecycle is stage-driven: a scope is minted lazily on first - * resolution (pure — resolution has no side effects and is render-safe); - * the event window and deferred teardown key off the STAGED session, which - * follows `list.current` exactly. Staging is the open signal: the window - * opens ⟺ the session is on stage (today the stage is `current`; the staged - * state can widen to a multi-pane list later). A session leaving the list - * tears its scope down immediately unless it is the staged one, whose scope - * survives frozen (read-only view) until the stage moves on. - */ -import type { Context, Fiber } from 'cordis' -import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' -import type { - HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, -} from '@deepseek-ai/dsh-client-ui-slots' -import type { SnapshotStore } from '../contract/store.ts' -import { createSnapshotStore } from '../contract/store.ts' -import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' -import { SessionManager } from './manager.ts' -import type { SessionListPhase } from './manager.ts' -import type { Session } from './session.ts' - -/** Session list row projected from the host list RPC plus live stream increments. */ -export interface SessionSummary { - id: SessionId - /** Latest durable log-backed title, absent until the host projects one. */ - title?: string - /** Human-facing label: durable title, project basename, then session id. */ - displayTitle: string - cwd?: string - parentId?: SessionId - running: boolean - /** - * Empty-log bit (host summary derivation mirror). List surfaces hide blank - * sessions; New Session reuses a blank one targeting the same workspace. - * Filtering stays with the consumer — the store carries every row. - */ - blank: boolean - updatedAt: number -} - -/** - * Session list store shape. `current` rides the same snapshot (arbitrated: - * the single useSessions standard hook reads list and selection together — - * sidebar highlighting and SessionProvider share one fact source). - */ -export interface SessionListState { - ids: SessionId[] - byId: Record - current: SessionId | undefined - /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ - phase: SessionListPhase -} - -/** Structured session-create failure. */ -export class SessionCreateError extends Error { - override readonly name = 'SessionCreateError' - - /** - * @param rpcError - Host business or folded transport error. - * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. - */ - constructor( - readonly rpcError: RpcError, - readonly requestedSessionId: SessionId | undefined, - ) { - super(`session create failed: ${rpcError.code}: ${rpcError.message}`) - } -} - -/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ -export interface SessionBinding { - readonly sessionId: SessionId - readonly session: Session - readonly ctx: Context -} - -// Scope primitives live in ../agents/scope.ts (the client mirror of host -// dsh-scope, keyed by Agent identity); re-exported here so existing -// consumers keep their import site. -export { scopeOf } from '../agents/scope.ts' - -/** - * Workspace display title of a session cwd: the path's last non-empty - * segment (both separators accepted; trailing separators ignored), or '' - * for separator-only paths — callers own their fallback (session id, raw - * cwd, default-directory copy). The repo-wide single basename derivation — - * every surface naming a workspace (picker rows, toggle labels, list titles) - * calls this instead of re-splitting paths. - * @param cwd - workspace directory path. - * @returns basename title, or '' when no non-empty segment exists. - */ -export function workspaceTitleOf(cwd: string): string { - return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '' -} - -/** - * Display title projection: durable title, project directory basename, then - * the raw id. - */ -function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { - if (title !== undefined) return title - if (cwd !== undefined && cwd !== '') { - const base = workspaceTitleOf(cwd) - if (base !== '') return base - } - return id -} - -interface ScopeRecord { - fiber: Fiber - ctx: Context - binding: SessionBinding - /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */ - provideInfo: SessionProvideInfo -} - -/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */ -export interface SessionProvideContribution { - /** Bare observable sources, keyed by hook base name ('input' → useInput). */ - hooks?: Record> - /** Stable plain members (action callbacks etc.), spread into standard props verbatim. */ - props?: Record -} - -/** - * Static declaration plus per-session resolver for one standard-kit - * contribution. The declared names let the renderer construct the same hook - * and prop surface while no session is current. - */ -export interface SessionProvideDescriptor { - /** Hook base names (`input` becomes `useInput`). */ - hooks?: readonly string[] - /** Plain standard-prop names. */ - props?: readonly string[] - /** Resolve every declared member for one definite session. */ - resolve(binding: SessionBinding): SessionProvideContribution -} - -/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */ -export class SessionsService { - /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */ - readonly list: SnapshotStore - /** The object-layer instance cluster and frame dispatch entry. */ - private readonly manager: SessionManager - - /** - * Persisted selection cell (the durable half of `list.current`). Private on - * purpose: reads go through the list snapshot; writes through {@link - * SessionsService.open} / {@link SessionsService.clear}. Projection - * validates it against the live list instead of destructively pruning, so a - * selection survives transient list states (reconnect re-pull) and - * resurfaces when its session returns. - */ - private readonly selection: SnapshotStore<{ sessionId?: SessionId }> - - private readonly scopes = new Map() - /** Registered per-session standard-props providers, in registration order. */ - private readonly providers: SessionProvideDescriptor[] = [] - /** Static no-session projection, rebuilt only when the provider roster changes. */ - private maybeInfo: SessionMaybeProvideInfo - /** - * The staged session id — follows `list.current` exactly, holding its last - * defined value across masked gaps (a transiently absent selection blanks - * `current` without moving the stage, so reconnect re-pulls and removals - * keep the staged scope's frozen view alive until the stage moves on). - */ - private watched: SessionId | undefined - /** Removed-while-staged sessions whose teardown waits for the stage to move away. */ - private readonly deferredRemovals = new Set() - - /** - * @param ctx - client root context (scope fibers mount under it). - * @param api - wire client shared with every Session. - */ - constructor(private readonly rootCtx: Context, api: IApiClient) { - this.selection = createSnapshotStore<{ sessionId?: SessionId }>( - {}, - { persist: { name: 'dsh.sessions.current' } }) - this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId) - this.list = createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'pending', - }) - // The manager owns wire truth; the store is its projection. Manager - // notifications are already microtask-batched. - this.manager.subscribe(() => { this.projectList() }) - // Stage follower: every current write (open() and projection alike) - // re-evaluates staging, so startup restore (persisted selection validated - // by the projection) and reconnect resurfacing open their window with no - // dedicated code path. Safe to run synchronously inside the store notify: - // the follower writes no list state — session.open()'s synchronous prefix - // touches only session-side state and its own microtask-batched notifier. - this.list.subscribe(() => { this.followCurrent() }) - // The runtime's own contribution comes first: useSession rides the same - // provide channel every plugin uses (no renderer special case). - this.providers.push({ - hooks: ['session'], - resolve: binding => ({ hooks: { session: binding.session } }), - }) - this.maybeInfo = this.materializeMaybeProvideInfo() - rootCtx.reflect.provide('sessions', this, undefined) - } - - /** - * Register a per-session standard-props provider: every session-scope slot - * component receives the contributed members as standard props (`hooks` - * sources become `use` selector hooks on the render side; `props` - * spread verbatim). Contributions materialize lazily with the session's - * scope record and die with it. Registration order is resolution order; - * duplicate member names fail loud at materialization. - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops). - */ - provide(descriptor: SessionProvideDescriptor): () => void { - this.providers.push(descriptor) - // Scopes may already exist (boot order: the list lands and resolves - // scopes before later plugins register) — their bundles must include - // every provider by first render, so re-materialize on roster change. - this.rematerializeProvideBundles() - return () => { - const at = this.providers.indexOf(descriptor) - if (at >= 0) this.providers.splice(at, 1) - this.rematerializeProvideBundles() - } - } - - /** Rebuild every live scope's standard-props bundle after a provider roster change. */ - private rematerializeProvideBundles(): void { - this.maybeInfo = this.materializeMaybeProvideInfo() - for (const record of this.scopes.values()) { - record.provideInfo = this.materializeProvideInfo(record.binding) - } - } - - /** Build the static no-session kit and reject duplicate declared names. */ - private materializeMaybeProvideInfo(): SessionMaybeProvideInfo { - const hooks: Record = {} - const props: Record = {} - for (const descriptor of this.providers) { - for (const name of descriptor.hooks ?? []) { - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = undefined - } - for (const name of descriptor.props ?? []) { - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = undefined - } - } - return { sessionId: undefined, hooks, props } - } - - /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */ - private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo { - const hooks: Record> = {} - const props: Record = {} - for (const descriptor of this.providers) { - const contribution = descriptor.resolve(binding) - const contributedHooks = contribution.hooks ?? {} - const contributedProps = contribution.props ?? {} - for (const name of Object.keys(contributedHooks)) { - if (!(descriptor.hooks ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared hook "${name}"`) - } - } - for (const name of Object.keys(contributedProps)) { - if (!(descriptor.props ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared prop "${name}"`) - } - } - for (const name of descriptor.hooks ?? []) { - const source = contributedHooks[name] - if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`) - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = source - } - for (const name of descriptor.props ?? []) { - if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`) - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = contributedProps[name] - } - } - return { sessionId: binding.sessionId, hooks, props } - } - - /** - * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere. - * @param id - session id (must exist in the list store). - */ - open(id: SessionId): void { - this.manager.select(id) - } - - /** - * Clear the current selection so the layout shows the no-session empty - * state (new-session affordance and the workspace preselection flow). - * Wipes the persisted selection too — a reload stays on empty until the - * user opens or starts a session. The staged scope keeps its frozen view - * per the masked-gap contract until the next open() moves the stage. - */ - clear(): void { - this.manager.clearSelection() - } - - /** - * Refresh the real Session baseline, reusing an in-flight pull. - * @returns completion of the current or newly started baseline pull. - */ - refresh(): Promise { - return this.manager.refreshList() - } - - /** - * Route a mux stream envelope into the Session object layer. - * @param envelope - validated mux stream envelope. - */ - handleMuxEnvelope(envelope: Parameters[0]): void { - this.manager.handleMuxEnvelope(envelope) - } - - /** - * Route a Host stream envelope into the Session object layer. - * @param envelope - validated Host stream envelope. - */ - handleHostEnvelope(envelope: Parameters[0]): void { - this.manager.handleHostEnvelope(envelope) - } - - /** Rebuild the Session baseline and every opened window after connection. */ - handleConnected(): void { - this.manager.handleConnected() - } - - /** - * Create a session on the host. Resolution guarantee: by the time the - * promise resolves, the created session is in the list store and - * {@link SessionsService.binding} resolves it — callers (New Session - * draft hand-off) may address the scope synchronously, without waiting a - * notifier flush. The synchronous projection below makes this structural - * rather than an accident of microtask ordering. - * @param opts - target workspace or directory and an optional preallocated id. - * @returns the new session id. - * @throws {SessionCreateError} with the requested id. - */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { - const result = await this.manager.create(opts) - if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) - this.projectList() - return result.value.sessionId - } - - /** - * Resolve an Agent-scoped context view (use-and-discard). - * @param id - session id (the agent identity — 1:1 same axis). - * @returns scoped ctx, or undefined for a session neither listed nor already scoped. - */ - scope(id: SessionId): Context | undefined { - return this.resolve(id)?.ctx - } - - /** - * Read the Agent scope tag off a context. Service-method seam: fetch - * bundles must reach scope resolution through ctx.sessions — a cross-bundle - * value import of the standalone helper would inline a second module - * instance whose private tag Symbol never matches. - * @param ctx - any client context. - * @returns the session id, or undefined on root contexts. - */ - scopeOf(ctx: Context): SessionId | undefined { - return scopeTagOf(ctx) - } - - /** - * Resolve the business Session behind an Agent-scoped context — the one - * hop every scoped consumer (event listeners, per-session controllers) - * takes from ctx-space into object-space (the client mirror of host - * `agent.session`). Same service-method seam as - * {@link SessionsService.scopeOf}. - * @param ctx - an Agent-scoped context. - * @returns the Session, or undefined when the ctx is untagged or its scope was pruned. - */ - sessionOf(ctx: Context): Session | undefined { - const id = scopeTagOf(ctx) - if (id === undefined) return undefined - return this.scopes.get(id)?.binding.session - } - - /** - * Resolve the stable session binding (scope-addressed assembly feed). Pure - * resolution — no staging, no window side effects. - * @param id - session id. - * @returns binding, or undefined for a session neither listed nor already scoped. - */ - binding(id: SessionId): SessionBinding | undefined { - return this.resolve(id)?.binding - } - - /** - * Resolve the render-layer standard-props bundle (SessionProvider's feed - * through the renderer host; ctx never enters the render layer). Pure - * resolution — render-safe: SessionProvider calls this during render, so no - * staging, no window side effects (StrictMode double-invokes and concurrent - * discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. - */ - provideInfo(id: string): SessionProvideInfo | undefined { - return this.resolve(id as SessionId)?.provideInfo - } - - /** - * Resolve the current-session-optional standard kit. Unknown or absent ids - * return the static no-session projection rather than removing hook props. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. - */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { - return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo - } - - /** - * Move the stage to the list's current session: sweep teardowns deferred - * behind the previous occupant and pull the new occupant's history window. - * Staging IS the open signal — the window opens ⟺ the session is on stage - * — and open() is idempotent (an in-flight or completed open no-ops; a - * failed one retries the next time current is touched). - */ - private followCurrent(): void { - const snapshot = this.list.getSnapshot() - const current = snapshot.current - // A masked gap (current blanked while the selection's session is - // transiently absent) holds the stage: tearing down on the gap would - // destroy exactly the frozen scope the mask exists to preserve. - if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return - this.watched = current - this.sweepDeferred() - const record = this.resolve(current) - /* v8 ignore next 3 -- defensive: current is always a listed id (open() - * validates and the projection masks absent selections), so resolve - * cannot miss; kept so a future current writer cannot crash the notify. */ - if (record !== undefined) { - void record.binding.session.open() - } - } - - /** - * Breadcrumb feed: walk parentId links inside the list store. - * @param id - session id. - * @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk). - */ - ancestry(id: SessionId): SessionSummary[] { - const { byId } = this.list.getSnapshot() - const chain: SessionSummary[] = [] - let cursor: SessionId | undefined = id - while (cursor !== undefined) { - const summary: SessionSummary | undefined = byId[cursor] - if (summary === undefined || chain.includes(summary)) break - chain.unshift(summary) - cursor = summary.parentId - } - return chain - } - - /** - * Lazily mint the scope + binding for an eligible session. Eligibility and - * prune share one predicate (decision 12): listed on the host — a scope is - * born when its session enters the client's view (list mirror row from the - * baseline pull, a create() echo, or the session-added frame) and dies with - * the prune when the row leaves. - */ - private resolve(id: SessionId): ScopeRecord | undefined { - const existing = this.scopes.get(id) - if (existing !== undefined) return existing - if (!this.eligible(id)) return undefined - const { fiber, ctx } = createScope(this.rootCtx, id) - const session = this.manager.get(id) - // The Session owns its scoped dispatch point (host Agent.loopCtx mirror); - // mint and bind are one step so a live scope record implies a bound actx. - session.bindScope(ctx) - const binding: SessionBinding = { sessionId: id, session, ctx } - const record: ScopeRecord = { - fiber, - ctx, - binding, - // Sources are bare observables; React binds selector hooks at its own seam. - provideInfo: this.materializeProvideInfo(binding), - } - this.scopes.set(id, record) - return record - } - - /** The one aliveness predicate shared by scope mint and prune: host-listed. */ - private eligible(id: SessionId): boolean { - return this.list.getSnapshot().byId[id] !== undefined - } - - /** Project the manager's list snapshot into the store (title derivation is display-only). */ - private projectList(): void { - const { items, current, phase } = this.manager.getListSnapshot() - const ids: SessionId[] = [] - const byId: Record = {} - for (const entry of items) { - ids.push(entry.sessionId) - byId[entry.sessionId] = { - id: entry.sessionId, - displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), - running: entry.running, - blank: entry.blank, - updatedAt: entry.updatedAt, - ...(entry.title !== undefined ? { title: entry.title } : {}), - ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), - ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), - } - } - const persisted = this.selection.getSnapshot().sessionId - // No current (cleared, or masked gap) wipes the persisted cell — a reload - // stays on empty; the in-memory selection still resurfaces a masked id. - if (current === undefined) { - if (persisted !== undefined) this.selection.set({}) - } else if (byId[current] !== undefined && persisted !== current) { - this.selection.set({ sessionId: current }) - } - this.list.set({ ids, byId, current, phase }) - this.pruneScopes(byId) - } - - /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */ - private pruneScopes(byId: Record): void { - void byId - for (const [id, record] of this.scopes) { - if (this.eligible(id)) continue - if (id === this.watched) { - this.deferredRemovals.add(id) - continue - } - this.scopes.delete(id) - this.deferredRemovals.delete(id) - this.dropScope(id, record) - } - } - - /** - * One teardown for the whole per-session axis (decision 12): the scope - * fiber (cascading every actx-registered effect: input shell, slash - * controller, popup, plugin stores, listeners), the session-keyed slot - * stores, and the Session instance itself — the host session log is the - * durable truth, a reopen lazily rebuilds and backfills via open(). - */ - private dropScope(id: SessionId, record: ScopeRecord): void { - void record.fiber.dispose() - // Release the Session's dispatch point with the scope it belongs to (a - // surviving instance — the live Intent — rebinds when resolve re-mints). - record.binding.session.unbindScope() - // Optional lookup: slots and sessions are sibling services with no - // declared dependency; a slots-less boot (object-layer tests) skips. - this.rootCtx.get('slots')?.pruneStoreScope(id) - this.manager.drop(id) - } - - /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */ - private sweepDeferred(): void { - for (const id of [...this.deferredRemovals]) { - /* v8 ignore next -- defensive: only the staged id ever defers, and every - * stage move sweeps first, so the set cannot contain the id the stage just - * moved to; kept as a guard against future extra sweep call sites. */ - if (id === this.watched) continue - // Eligible again? (A re-added id cancels the deferred teardown.) - if (this.eligible(id)) { - this.deferredRemovals.delete(id) - continue - } - const record = this.scopes.get(id) - this.deferredRemovals.delete(id) - /* v8 ignore next -- defensive: prune deletes a scope and its deferral - * together, so a deferred id always still owns its record; kept so a - * future teardown path cannot double-dispose. */ - if (record !== undefined) { - this.scopes.delete(id) - this.dropScope(id, record) - } - } - } -} diff --git a/packages/client/ui-workspace/src/client/index.ts.orig b/packages/client/ui-workspace/src/client/index.ts.orig deleted file mode 100644 index 7b5823cc39..0000000000 --- a/packages/client/ui-workspace/src/client/index.ts.orig +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills - * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), - * and WorkspacePicker fills the conversation hero's picker hole - * (`conversation.hero.workspace` — both hero forms). Both read real Host - * Workspaces through the global useWorkspaces hook. Export discipline: - * packages/client/AGENTS.md. - */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' -import { createWorkspaceViewStore } from './stores.ts' -import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' -import { WorkspacePicker } from './WorkspacePicker.tsx' - -export type { - WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, -} from './contract/slots.ts' - -/** - * Required services (cordis fiber inject). The target slots are declared by - * the ui-sidebar / ui-conversation applies, whose activation order relative - * to this one is NOT constrained: dshClient.inject edges are informational - * (loading/prefetch metadata, never apply sequencing) and neither owner - * provides a waitable service. apply therefore registers via - * declaration-aware deferral instead of assuming order. - */ -export const inject = ['slots', 'sessions', 'workspaces'] - -/** - * Register the browser and picker once their slot declarations are on the - * ledger. Inject factories return plain callbacks; data reads use the - * framework's global hooks. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - const browserInjected = (): WorkspaceBrowserInjected => ({ - // With a workspace: materialize (reuse-or-create the blank session) and - // navigate. Without one: clear the selection — the layout's empty seat - // shows the New Session pure view state and the user picks there. - startSession: (workspaceId) => { - if (workspaceId === undefined) { - ctx.sessions.clear() - return - } - void ctx.workspaces.connectWorkspace(workspaceId).then( - (sessionId) => { ctx.sessions.open(sessionId) }, - (reason: unknown) => { console.warn('new session failed:', reason) }, - ) - }, - open: (sessionId) => { ctx.sessions.open(sessionId) }, - renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, - insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { - await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) - }, - createWorkspace: input => ctx.workspaces.create(input), - }) - const pickerInjected = (): WorkspacePickerInjected => ({ - createWorkspace: input => ctx.workspaces.create(input), - }) - // Declaration-aware registration: each owner's declaring apply may activate - // after this one (entry activation order is unconstrained), and a register - // into an undeclared slot throws. Register once the declaration is on the - // ledger; the subscription also re-registers after an HMR collapse - // re-declares the slot (the cascade disposed our entry with it). - ctx.effect(() => { - const registrations = [ - { - name: 'sidebar.workspaces' as const, - component: WorkspaceBrowser, - register: () => ctx.slots.register( - { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, - WorkspaceBrowser, - ), - }, - { - name: 'conversation.hero.workspace' as const, - component: WorkspacePicker, - register: () => ctx.slots.register( - { name: 'conversation.hero.workspace', inject: pickerInjected }, - WorkspacePicker, - ), - }, - ] - const disposers = new Map void>() - const tryRegister = (entry: (typeof registrations)[number]): void => { - if (ctx.slots.spec(entry.name) === undefined) return - if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return - disposers.set(entry.name, entry.register()) - } - const unsubscribers = registrations.map(entry => - ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) - for (const entry of registrations) tryRegister(entry) - return () => { - for (const unsubscribe of unsubscribers) unsubscribe() - for (const dispose of disposers.values()) dispose() - } - }, 'ui-workspace: browser + picker registrations') -} diff --git a/packages/client/ui-workspace/src/client/tree.ts.orig b/packages/client/ui-workspace/src/client/tree.ts.orig deleted file mode 100644 index 6d3126fcd1..0000000000 --- a/packages/client/ui-workspace/src/client/tree.ts.orig +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Derives the workspace browser tree from Host Workspace order and membership. - * Unassigned Sessions trail under Ungrouped; blank Sessions remain visible. - */ -import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' - -/** Group key for Sessions outside every Workspace. */ -export const UNGROUPED_KEY = '' - -/** Display label for the ungrouped bucket row. */ -export const UNGROUPED_LABEL = 'Ungrouped' - -/** One session node of a group's visible tree (34px row; children render indented one step). */ -export interface SessionNode { - id: SessionId - title: string - /** Visible children, already expansion/search-filtered (empty when folded). */ - children: readonly SessionNode[] - /** The session HAS children in the data (the twist renders even while folded). */ - hasChildren: boolean - expanded: boolean - running: boolean - updatedAt: number -} - -/** One workspace group section: header row facts + the visible session tree. */ -export interface GroupNode { - /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ - key: string - /** Backing Workspace id; absent only for the ungrouped bucket. */ - workspaceId: WorkspaceId | undefined - cwd: string | undefined - label: string - /** Total visible sessions in the group. */ - sessionCount: number - expanded: boolean - /** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */ - containsCurrent: boolean - /** Visible roots (empty while the group is folded). */ - sessions: readonly SessionNode[] -} - -/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */ -export interface TreeView { - expandedProjects: readonly string[] - expandedSessions: readonly string[] - query: string -} - -interface Group { - key: string - workspaceId: WorkspaceId | undefined - cwd: string | undefined - label: string - summaries: Map - roots: SessionId[] - children: Map -} - -/** - * Directory display label: basename of the path (both separators accepted). - * Ungrouped-bucket fallback for surfaces without a workspace title. - * @param cwd - directory path, or undefined for the ungrouped bucket. - * @returns basename, the raw cwd when it has no basename, or the ungrouped label. - */ -export function projectLabel(cwd: string | undefined): string { - if (cwd === undefined || cwd === '') return UNGROUPED_LABEL - const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() - return base !== undefined && base !== '' ? base : cwd -} - -/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */ -function byRecency(a: SessionSummary, b: SessionSummary): number { - if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt - return a.id < b.id ? -1 : 1 -} - -/** Build one group's parent/child tree from an ordered member list. */ -function buildGroup( - key: string, - workspaceId: WorkspaceId | undefined, - cwd: string | undefined, - label: string, - members: readonly SessionSummary[], - order: 'account' | 'recency', -): Group { - const summaries = new Map(members.map(m => [m.id, m])) - const children = new Map() - const roots: SessionSummary[] = [] - for (const m of members) { - // A session is a tree child only when its parent lives in the same - // group; cross-group or unknown parents degrade to group roots. - if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) { - const kids = children.get(m.parentId) - if (kids === undefined) children.set(m.parentId, [m.id]) - else kids.push(m.id) - } else { - roots.push(m) - } - } - // Workspace order is the member iteration order (workspace.sessionIds), so - // attached groups keep insertion order; Ungrouped sorts by recency. - if (order === 'recency') { - roots.sort(byRecency) - for (const kids of children.values()) { - kids.sort((a, b) => { - const sa = summaries.get(a) - const sb = summaries.get(b) - /* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */ - if (sa === undefined || sb === undefined) return 0 - return byRecency(sa, sb) - }) - } - } - const rootIds = roots.map(r => r.id) - // parentId cycles (host bug) leave members unreachable from any root; - // surface them as extra roots — the flatten walk's visited set stops - // loops. Each node sits in at most one kids list and roots have no - // in-group parent, so the scan pushes every reachable node exactly once. - const reachable = new Set(rootIds) - const stack = [...rootIds] - while (stack.length > 0) { - const top = stack.pop() - /* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */ - if (top === undefined) break - for (const kid of children.get(top) ?? []) { - reachable.add(kid) - stack.push(kid) - } - } - for (const m of members) { - if (!reachable.has(m.id)) rootIds.push(m.id) - } - return { key, workspaceId, cwd, label, summaries, roots: rootIds, children } -} - -/** - * Group Sessions by Host Workspace: one group per entity in stable Host - * order, with members resolved from sessionIds in their stored order. Sessions - * outside every Workspace trail in the recency-ordered Ungrouped bucket. - */ -function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] { - const groups: Group[] = [] - const accounted = new Set() - for (const workspace of workspaces) { - const members: SessionSummary[] = [] - for (const id of workspace.sessionIds) { - const summary = list.byId[id] - if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands - accounted.add(id) - members.push(summary) - } - groups.push(buildGroup( - workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account', - )) - } - const stray = list.ids - .map(id => list.byId[id]) - .filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id)) - if (stray.length > 0) { - groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) - } - return groups -} - -function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode { - return { - id: s.id, - title: s.displayTitle, - children, - hasChildren, - expanded, - running: s.running, - updatedAt: s.updatedAt, - } -} - -function buildVisible(g: Group, expandedSessions: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = g.children.get(id) ?? [] - const expanded = expandedSessions.has(id) - const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : [] - return sessionNode(s, children, kids.length > 0, expanded) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - -/** Matched sessions plus their ancestor chains (forced visible under search). */ -function searchVisible(g: Group, q: string): Set { - const visible = new Set() - for (const m of g.summaries.values()) { - if (!m.displayTitle.toLowerCase().includes(q)) continue - let cur: SessionSummary | undefined = m - while (cur !== undefined && !visible.has(cur.id)) { - visible.add(cur.id) - cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined - } - } - return visible -} - -function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id) || !visible.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid)) - const children = kids.map(walk).filter((n): n is SessionNode => n !== null) - return sessionNode(s, children, kids.length > 0, kids.length > 0) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - -/** - * Derive the nested workspace browser group structure. - * - * Normal mode: every group shows; sessions populate under expanded groups, - * descending only into expanded sessions. Search mode (non-blank query, - * case-insensitive display-title substring): expansion state is ignored — - * matched sessions and their ancestor chains are forced visible, groups - * without a display-title or label hit are dropped, and a label-only hit - * keeps the bare group header. Blank sessions are excluded everywhere. - * @param list - sessions list snapshot (`current` feeds containsCurrent). - * @param workspaces - real workspaces in stable Host order. - * @param view - local expansion arrays and search query. - * @returns group sections in render order. - */ -export function deriveGroups( - list: SessionListState, - workspaces: readonly WorkspaceView[], - view: TreeView, -): GroupNode[] { - const q = view.query.trim().toLowerCase() - const expandedProjects = new Set(view.expandedProjects) - const expandedSessions = new Set(view.expandedSessions) - const currentGroup = list.current === undefined - ? undefined - : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY - const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces)) { - if (q === '') { - const expanded = expandedProjects.has(g.key) - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded, - containsCurrent: g.key === currentGroup, - sessions: expanded ? buildVisible(g, expandedSessions) : [], - }) - } else { - const visible = searchVisible(g, q) - if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded: visible.size > 0, - containsCurrent: g.key === currentGroup, - sessions: buildSearch(g, visible), - }) - } - } - return groups -} - -/** - * Derive the flat session list ("In one list" mode): every session — fork - * children included — as a top-level row, strictly newest-first. No grouping, - * no parent/child adjacency; rows reuse SessionNode with children always - * empty so the renderer stays branch-free. Search mode filters by - * case-insensitive display-title substring. - * @param list - sessions list snapshot. - * @param view - the search query (expansion state does not apply). - * @returns flat rows in render order. - */ -export function deriveFlat(list: SessionListState, view: Pick): SessionNode[] { - const q = view.query.trim().toLowerCase() - const rows: SessionSummary[] = [] - for (const id of list.ids) { - const s = list.byId[id] - if (s === undefined) continue - if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue - rows.push(s) - } - rows.sort(byRecency) - return rows.map(s => sessionNode(s, [], false, false)) -} - -/** - * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). - * @param updatedAt - epoch ms of the session's last activity. - * @param now - current epoch ms (injected for pure rendering). - * @returns the row's trailing time label. - */ -export function formatRelativeTime(updatedAt: number, now: number): string { - const MIN = 60_000 - const HOUR = 3_600_000 - const DAY = 86_400_000 - const diff = Math.max(0, now - updatedAt) - if (diff < MIN) return 'now' - if (diff < HOUR) return `${Math.floor(diff / MIN)}min` - if (diff < DAY) return `${Math.floor(diff / HOUR)}h` - if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d` - if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo` - return `${Math.floor(diff / (365 * DAY))}y` -} From 5cae7874f61c9af8fcc8df4f585ec5259766a61e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:26:58 +0800 Subject: [PATCH 70/75] git: add .orig to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c488fa5a91..4651b94215 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ python/**/.pytest_cache/ apps/web/dist/ .artifacts/ .playwright-mcp/ +.orig \ No newline at end of file From d270c285ab7d349521f75865c9b9804de68c0d8a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:49:11 +0800 Subject: [PATCH 71/75] fix(review): portable checksum verifier and full provisioning wait sha256sum-or-shasum selection happens in preflight (macOS ships shasum, not GNU coreutils) with a clear missing-tool hint; provisioning now waits for every child and reports each named status before exiting, so a fast failure can neither hand the EXIT trap a scratch tree that a still-running install is writing into nor bury the root cause behind a downstream symptom. --- scripts/wine-windows-gates.sh | 44 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 97f09bb9b3..8b6dcd8a0a 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -33,10 +33,19 @@ wine_bin='' for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi done +# GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both +# accept the same " " --check input. +checksum_tool='' +if command -v sha256sum > /dev/null; then + checksum_tool='sha256sum' +elif command -v shasum > /dev/null; then + checksum_tool='shasum' +fi missing=() [ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)') command -v curl > /dev/null || missing+=('curl') command -v unzip > /dev/null || missing+=('unzip') +[ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)') if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)') if (( ${#missing[@]} > 0 )); then @@ -44,6 +53,14 @@ if (( ${#missing[@]} > 0 )); then exit 1 fi +# Verify file $2 against SHA-256 hex $1 with whichever tool preflight found. +verify_sha256() { + case "$checksum_tool" in + sha256sum) printf '%s %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;; + shasum) printf '%s %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;; + esac +} + scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")" cleanup() { wineserver -k > /dev/null 2>&1 || true @@ -68,9 +85,11 @@ provision_node() { zip="$cache_dir/node-$version-win-x64.zip" if [ ! -f "$zip" ]; then curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip" - curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \ - | awk -v a="node-$version-win-x64.zip" -v f="$zip.tmp" '$2 == a { print $1 " " f }' \ - | sha256sum --check - > /dev/null + local expected + expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \ + | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')" + [ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; } + verify_sha256 "$expected" "$zip.tmp" mv "$zip.tmp" "$zip" fi else @@ -115,7 +134,24 @@ start=$SECONDS provision_node & node_pid=$! boot_wine & wine_pid=$! snapshot_and_install & install_pid=$! -for task_pid in "$node_pid" "$wine_pid" "$install_pid"; do wait "$task_pid"; done +# Wait for EVERY child before judging any: a bare `wait` under set -e would +# exit on the first failure and let the EXIT trap delete $scratch while the +# other children still run inside it. Named statuses also make the report +# point at the root cause instead of a downstream symptom. +node_status=0; wait "$node_pid" || node_status=$? +wine_status=0; wait "$wine_pid" || wine_status=$? +install_status=0; wait "$install_pid" || install_status=$? +provision_failed=0 +report_provision() { + if (( $2 != 0 )); then + echo "wine-windows-gates: FAILED $1 (exit $2)" >&2 + provision_failed=$2 + fi +} +report_provision 'Windows Node provisioning' "$node_status" +report_provision 'wineboot' "$wine_status" +report_provision 'workspace snapshot + pnpm install' "$install_status" +if (( provision_failed != 0 )); then exit "$provision_failed"; fi node_win="$(cat "$scratch/node-win-path")" echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))" From 194ea6bdf0d8a9cdec2e94de9f1f992b44f6f7d0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:03:21 +0800 Subject: [PATCH 72/75] docs(test): correct execa process-tree claims --- .../2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml | 6 +++--- .../2026-07-26-execa-for-test-subprocess-plumbing.md | 4 ++-- .../2026-07-26-execa-for-test-subprocess-plumbing.zh.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml index 606229b494..466071f552 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-26-execa-for-test-subprocess-plumbing.md: a25010b1cab7012cf9c659cfd8272d17e33618c5 -2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 733c9f7e7f666052f030ed3b0f916e4832aaa120 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +2026-07-26-execa-for-test-subprocess-plumbing.md: 958abc4aee94adb3e6206cc299595ad92bde4044 +2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 7027a8bde51f81bfa7774743f84639cbd4b667d8 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md index a25010b1ca..958abc4aee 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md @@ -25,13 +25,13 @@ Two related test-infra hand-rolls compounded the case: ## Alternatives considered - **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. -- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries. +- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn cross-platform timeout, termination, and result-normalization behavior that execa already carries. - **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. ## Consequences - The hand-rolled collect/timeout blocks are gone, including the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke`: spawn and stream failures settle through execa's result fields, so the `src/` file carries no coverage exemptions and the per-file gate covers every remaining branch. - Captured output is bounded by execa's default 100 MB `maxBuffer` (overflow terminates the subprocess) where it was previously unbounded; the `loader-smoke` README's limitation entry reflects this. -- Windows termination behavior (taskkill, exit-code mapping) is owned by execa instead of per-site hand-rolls; each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform. +- Direct-child timeout termination and exit/signal result normalization are owned by execa across platforms instead of per-site hand-rolls; process-tree termination remains outside these helpers, as the `loader-smoke` README states. Each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform. - execa is a new root devDependency (previously absent from the lockfile); it is one of the most-depended-on packages on npm and actively maintained, and the exe/runtime closure is unaffected (tests only). - The mock-server CLI's tokenizer-level error texts are no longer this repo's to choose: unknown options, missing values, and stray positionals report `parseArgs`'s wording, pinned as such in `tests/cli.spec.ts`. diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md index 733c9f7e7f..7027a8bde5 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md @@ -25,13 +25,13 @@ Status: implemented ## 曾考虑的替代方案 - **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 -- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。 +- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的跨平台超时、终止与结果规范化行为。 - **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 ## 后果 - 手写的收集/超时代码块全部移除,包括 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支:spawn 与流故障如今经由 execa 的结果字段结算,这个 `src/` 文件不再携带任何覆盖率豁免,逐文件门禁覆盖其余全部分支。 - 捕获的输出如今受 execa 默认 100 MB `maxBuffer` 约束(溢出即终止子进程),此前是无界的;`loader-smoke` README 的局限条目反映了这一点。 -- Windows 终止行为(taskkill、退出码映射)由 execa 拥有,不再逐处手写;每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。 +- 直接子进程的超时终止以及退出/信号结果规范化均由 execa 跨平台负责,不再逐处手写;如 `loader-smoke` README 所述,这些辅助函数依然不负责终止进程树。每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。 - execa 是新增的根 devDependency(此前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,exe/运行时闭包不受影响(仅测试使用)。 - mock-server CLI 切分器层面的错误文本不再由本仓库决定:未知选项、缺失取值与多余位置参数报告 `parseArgs` 的措辞,并在 `tests/cli.spec.ts` 中如此固定。 From 1665ab9884bee46c144c6aa5fbd9e115e497e08a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:39:20 +0800 Subject: [PATCH 73/75] ci: preserve stores across pnpm action setup Keep restore-only and self-hosted stores outside pnpm/action-setup's replaceable install directory, resolving the configured path for every paired cache. Pin Yarn 4 for the generated-project e2e so runner images without Corepack do not fall back to Yarn Classic, and update the bilingual decision record. --- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 6 +- ...m-action-setup-for-symmetric-ci-caching.md | 12 ++- ...ction-setup-for-symmetric-ci-caching.zh.md | 12 ++- .github/workflows/ci.yml | 99 +++++++++++++------ knip.json | 1 + package.json | 3 +- pnpm-lock.yaml | 10 ++ 7 files changed, 99 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 0b452fcf81..7ad4d7bee7 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: d734f6be65affb15f7e56c08b0b6d268002a44d8 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 77ea0aa1cc7443ca7cd9e89b652a9c908bab64e5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 006360cedfd4d1e2c2b67ede98062a375e316f47 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: d511e7a2b89788fe8addd2cc47634742c7a87fe4 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index d734f6be65..006360cedf 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -6,15 +6,15 @@ English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) ## Problem -Every workflow hand-provisioned pnpm with `corepack enable`, and five of them further repeated a hand-rolled cache setup — `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines of drifting copies). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and corepack's removal from newer Node distributions made every `corepack enable` a known future break. +Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned it with `corepack enable`, and five of them further repeated a hand-rolled cache setup — `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml`. The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and corepack's removal from newer Node distributions made every `corepack enable` a known future break. ## Decision -`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. Caching remains per-job policy on top of it, in three deliberate shapes: +`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. -- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps, unchanged): the three enterprise-runner PR jobs restore without saving, keeping cache compression/upload off the paid latency-critical path — an asymmetry `setup-node`'s cache cannot express — and the master-push serial-linux job keeps its `pnpm store path` + `actions/cache@v4` save side, because it populates the exact key and path those restore-only jobs consume; converting the producer to `setup-node`'s key format would silently starve their restores. -- **Cache-less** (no store cache at all): the required Windows job and serial-windows (many-file store extraction is slower than a clean install there), serial-macos, sandbox.yml, and the coverage/consumers enterprise jobs that already restore via the shared enterprise key. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based pull-request Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Cache-less or persistent** (no store-cache action): native serial-windows and serial-macos plus `sandbox.yml` install from a cold or runner-local store. The self-hosted standby and failover jobs reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered @@ -22,11 +22,13 @@ Every workflow hand-provisioned pnpm with `corepack enable`, and five of them fu - **Convert the enterprise jobs' caching to `cache: pnpm`.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. - **Convert serial-linux's store cache.** Rejected during implementation: the original proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. - **Stop at the cache-bearing workflows and leave the other `corepack enable` sites.** Rejected on review follow-up: provisioning and caching are separable concerns, and leaving corepack in the cache-less jobs kept the future break and two provisioning idioms for no benefit. +- **Rely on the runner image's Yarn.** Rejected: the hosted image exposes Yarn 1.22 after Corepack is removed, while the generated-project e2e requires Yarn 2 or newer. A locked root dev dependency makes that coverage independent of runner image contents. - **A composite action wrapping action-setup + setup-node.** Rejected for now: the remaining per-job variation (node-version matrices, per-platform conditional caching, the restore-only pairing) is deliberate policy, not boilerplate — a wrapper would grow mirroring inputs or flatten a real asymmetry, and the two-line pair is already near the floor. ## Consequences - The corepack dependency is gone from CI entirely; pnpm arrives via the pnpm team's official action everywhere, and the version pin stays single-sourced in `package.json`'s `packageManager` field. +- The generated-project e2e runs the root-pinned Yarn 4 CLI instead of inheriting or silently skipping the runner image's Yarn version. - The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. - `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. -- About 75 net lines of workflow YAML removed. The enterprise-runner PR jobs' and Windows jobs' cache behavior is unchanged (only their provisioning line moved to the action), and serial-linux keeps producing the key the restore-only jobs consume. +- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs and serial-linux resolve and share that stable path and exact key. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 77ea0aa1cc..d511e7a2b8 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -每个工作流都曾用 `corepack enable` 手工提供 pnpm,其中五个还各自重复着一套手写(hand-rolled)的缓存设置——`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(约 40–60 行各自漂移的 YAML 副本)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,而 corepack 被从较新 Node 发行版中移除,使每一处 `corepack enable` 都成了已知的未来失效点。 +除 `landlock-run.yml` 外,每个安装 pnpm 的工作流都曾用 `corepack enable` 手工提供 pnpm,其中五个还各自重复着一套手写(hand-rolled)的缓存设置——`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,而 corepack 被从较新 Node 发行版中移除,使每一处 `corepack enable` 都成了已知的未来失效点。 ## 决策 -`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。缓存仍是叠加其上的按作业政策,保持三种刻意的形态: +`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤,保持不变):企业 runner 上的三个 PR(Pull Request)作业只恢复不保存,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外——这种不对称是 `setup-node` 的缓存无法表达的;master 推送触发的 serial-linux 作业保留其 `pnpm store path` + `actions/cache@v4` 写入侧,因为它负责填充那些只恢复不上传(restore-only)作业所消费的精确缓存键与路径;把生产者改成 `setup-node` 的键格式,会悄然断供它们的恢复。 -- **无缓存**(完全不设 store 缓存):必需的 Windows 作业与 serial-windows(在那里解压海量小文件的 store 缓存比干净安装更慢)、serial-macos、sandbox.yml,以及本就经共享企业键恢复的 coverage/consumers 企业作业。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业与基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已能直接提供热安装。 +- **无缓存或持久化**(不使用 store 缓存 action):原生 serial-windows 和 serial-macos 加上 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。自托管热备与故障切换作业复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 @@ -22,11 +22,13 @@ Status: implemented - **把企业作业的缓存也转换成 `cache: pnpm`。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 - **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 - **只转换带缓存的工作流,留下其余 `corepack enable` 站点。** 评审跟进时否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。 +- **依赖 runner 镜像自带的 Yarn。** 否决:Corepack 移除后,托管镜像提供的是 Yarn 1.22,而 generated-project e2e 要求 Yarn 2 或更高版本。锁定版本的根开发依赖让该项覆盖率不再受 runner 镜像内容影响。 - **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意的政策而非样板——包装层要么长出镜像这些差异的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。 ## 后果 - corepack 依赖已从 CI 中彻底消失;pnpm 在所有工作流中都经由 pnpm 团队的官方 action 提供,版本锁定继续单一来源于 `package.json` 的 `packageManager` 字段。 +- generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。 - 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 - `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。 -- 净删除约 75 行工作流 YAML。企业 runner 上的 PR 作业与 Windows 作业的缓存行为未变(只有提供 pnpm 的那一行改用了 action),serial-linux 继续生产只恢复不上传作业所消费的缓存键。 +- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业与 serial-linux 会解析并共享这一稳定路径及精确键。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 349f4ef88c..615aaf8fc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,23 +58,31 @@ jobs: fetch-depth: 0 persist-credentials: false + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + # Pull requests consume the default-branch cache but do not put cache # compression and upload on the paid latency-critical path. Skipped # under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: - path: /home/runner/.local/share/pnpm/store/v11 + path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -117,23 +125,31 @@ jobs: with: persist-credentials: false - # Skipped under failover: the self-hosted VM's persistent pnpm store - # serves warm installs directly, and this hosted-path restore would - # spend ~52 s pulling ~180 MB into a path pnpm never reads there. - - uses: actions/cache/restore@v4 - if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' - with: - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Skipped under failover: the self-hosted VM's persistent pnpm store + # already serves warm installs, while restoring the hosted archive + # would spend ~52 s pulling ~180 MB into that populated store. + - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - name: Install dependencies and prepare bubblewrap run: | pnpm install --frozen-lockfile & @@ -180,15 +196,6 @@ jobs: - name: Restore built tree run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz" - # Skipped under failover — see the coverage lane's identical rationale. - - uses: actions/cache/restore@v4 - if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' - with: - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache/restore@v4 with: path: .cache/eslint @@ -202,6 +209,23 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Skipped under failover — see the coverage lane's identical rationale. + - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - name: Install dependencies and prepare bubblewrap run: | pnpm install --frozen-lockfile & @@ -340,9 +364,17 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + - uses: actions/cache/restore@v4 with: - path: /home/runner/.local/share/pnpm/store/v11 + path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- @@ -548,9 +580,13 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Resolve pnpm store path + - name: Configure pnpm store path id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" # Master refreshes the caches that pull requests restore without saving. # The store cache stays a hand-rolled actions/cache step rather than @@ -621,6 +657,9 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure persistent pnpm store + run: echo "PNPM_CONFIG_STORE_DIR=$HOME/.local/share/pnpm/store" >> "$GITHUB_ENV" + - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/knip.json b/knip.json index 110abb3a2b..7ab7d47e5f 100644 --- a/knip.json +++ b/knip.json @@ -13,6 +13,7 @@ "python/sdk-runtime" ], "ignoreDependencies": [ + "@yarnpkg/cli-dist", "lightningcss" ], "workspaces": { diff --git a/package.json b/package.json index 3aa3d6998a..27f192466c 100644 --- a/package.json +++ b/package.json @@ -113,9 +113,10 @@ "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.1.8", + "@yarnpkg/cli-dist": "4.17.1", "eslint": "^10.4.1", - "execa": "^10.0.0", "eslint-plugin-sonarjs": "^4.1.0", + "execa": "^10.0.0", "fast-check": "^4.8.0", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f82de24032..a24a701f61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.8 version: 4.1.8(vitest@4.1.8) + '@yarnpkg/cli-dist': + specifier: 4.17.1 + version: 4.17.1 eslint: specifier: ^10.4.1 version: 10.5.0(jiti@2.7.0) @@ -7468,6 +7471,11 @@ packages: '@xterm/headless@5.5.0': resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==} + '@yarnpkg/cli-dist@4.17.1': + resolution: {integrity: sha512-2tiSQuJNl/L3QwTdrq6lKWDpkcnp9MGvCT/rIldHcbu3SWfnLdmehvt3eulX1hT7FFt1Gjfq3CesF+kvhFip6g==} + engines: {node: '>=18.12.0'} + hasBin: true + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -12364,6 +12372,8 @@ snapshots: '@xterm/headless@5.5.0': {} + '@yarnpkg/cli-dist@4.17.1': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 From 7b9eceff5378c64bd1c7db63af7fd34d5856de49 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:43:00 +0800 Subject: [PATCH 74/75] fix(ci): make local Wine gate portable on macOS --- ...27-wine-windows-gates-experiment.i18n.yaml | 4 +-- ...026-07-27-wine-windows-gates-experiment.md | 2 +- ...-07-27-wine-windows-gates-experiment.zh.md | 2 +- scripts/client-tsconfig.spec.ts | 33 +++++++++++++++++++ tsconfig.client.json | 3 ++ 5 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 scripts/client-tsconfig.spec.ts diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 5f939a7b67..b8d12ccea3 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.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-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: fd36a3322b0d63a0fa605ecfcec95c7522b43b25 -2026-07-27-wine-windows-gates-experiment.zh.md: 97a54161bb851785a9108cc45d59617e1a449e68 +2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc +2026-07-27-wine-windows-gates-experiment.zh.md: f30e09ca7411ef83d02faf012ce54d6c6c65dff1 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index fd36a3322b..640c8e455b 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -20,7 +20,7 @@ The lane holds the wall clock of the Linux CI jobs through four levers: the mast The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The local gate is a diagnosis tool, not a routine check: run it only when investigating a known Windows-related failure; CI owns the everyday win32 signal, and [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) never selects it. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable. -Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). +Five environment constraints shape CI and local execution, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the caller's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); macOS Wine also exposes hoisted workspace links as ordinary directories, so the client test aggregate includes every package-local CSS module declaration instead of relying on project-reference realpaths; and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). ## Measured results diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index 97a54161bb..f30e09ca74 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -20,7 +20,7 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。该本地门禁是诊断工具而非例行检查:仅在排查已知的 Windows 相关失败时运行;日常 win32 信号归 CI 所有,[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 也从不选择它。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 -四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 +五条环境约束塑造了 CI 与本地执行,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到调用方的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);macOS Wine 也会把 hoisted workspace 链接暴露为普通目录,因此 client 测试聚合会纳入每个包自己的 CSS 模块声明,而不依赖 project-reference realpath;Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 ## 实测结果 diff --git a/scripts/client-tsconfig.spec.ts b/scripts/client-tsconfig.spec.ts new file mode 100644 index 0000000000..ed445aa80f --- /dev/null +++ b/scripts/client-tsconfig.spec.ts @@ -0,0 +1,33 @@ +/** Regression coverage for source declarations owned by the client test aggregate. */ + +import { existsSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +function clientCssDeclarations(): string[] { + const clientRoot = resolve(root, 'packages/client') + return readdirSync(clientRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts')) + .filter(existsSync) + .sort() +} + +describe('client TypeScript aggregate', () => { + it('loads package CSS declarations without relying on workspace-link realpaths', () => { + const configPath = resolve(root, 'tsconfig.client.json') + const read = ts.readConfigFile(configPath, file => ts.sys.readFile(file)) + if (read.error !== undefined) { + throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n')) + } + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root) + const loaded = parsed.fileNames + .filter(file => file.endsWith('/src/css-modules.d.ts')) + .sort() + expect(loaded).toEqual(clientCssDeclarations()) + }) +}) diff --git a/tsconfig.client.json b/tsconfig.client.json index 0da6e76918..d309b78261 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -14,6 +14,9 @@ "types": ["node"] }, "include": [ + // Source-subpath test imports can arrive through workspace links whose + // realpath semantics vary by host. Load package CSS declarations directly. + "packages/client/*/src/css-modules.d.ts", "packages/client/*/tests/**/*.ts", "packages/client/*/tests/**/*.tsx", "packages/client/tsdown.client.ts", From 961bde64737c7030c7a8351260e7cc589f342f37 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:48:11 +0800 Subject: [PATCH 75/75] test(sdk): keep fresh Yarn installs mutable in CI The live-link E2E intentionally creates a project without a lockfile. Prevent ambient CI=true from making Yarn 4's first install immutable, while preserving the CI environment for every other behavior under test. --- packages/sdk/create-sdk/tests/link-workspace.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk/create-sdk/tests/link-workspace.e2e.ts b/packages/sdk/create-sdk/tests/link-workspace.e2e.ts index 0fefefdffa..7d1944614d 100644 --- a/packages/sdk/create-sdk/tests/link-workspace.e2e.ts +++ b/packages/sdk/create-sdk/tests/link-workspace.e2e.ts @@ -90,6 +90,8 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () XDG_DATA_HOME: join(cacheRoot, 'data'), npm_config_cache: join(cacheRoot, 'npm'), ...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore }, + // A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable. + ...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {}, } await execFileAsync(name, manager.installCommand(), { cwd: root,