From 9b169f7555433ee762b39ab385072265e284aa64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:37:32 +0800 Subject: [PATCH 01/57] ci: run required checks on native Windows --- .agents/notes/archived/manifest.json | 3 + ...27-wine-windows-gates-experiment.i18n.yaml | 4 +- ...026-07-27-wine-windows-gates-experiment.md | 1 + ...-07-27-wine-windows-gates-experiment.zh.md | 1 + ...rial-cross-platform-ci-reference.i18n.yaml | 4 +- ...7-21-serial-cross-platform-ci-reference.md | 2 +- ...1-serial-cross-platform-ci-reference.zh.md | 2 +- ...ortable-required-pull-request-ci.i18n.yaml | 4 +- ...07-23-portable-required-pull-request-ci.md | 6 +- ...23-portable-required-pull-request-ci.zh.md | 6 +- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +- ...m-action-setup-for-symmetric-ci-caching.md | 4 +- ...ction-setup-for-symmetric-ci-caching.zh.md | 4 +- ...8-native-windows-pull-request-ci.i18n.yaml | 6 + ...26-08-08-native-windows-pull-request-ci.md | 33 +++ ...08-08-native-windows-pull-request-ci.zh.md | 33 +++ .github/AGENTS.md | 2 +- .github/workflows/ci.yml | 117 ++------- AGENTS.md | 1 - package.json | 1 - scripts/ci-workflow.spec.ts | 22 ++ scripts/wine-windows-gates.sh | 247 ------------------ 22 files changed, 144 insertions(+), 363 deletions(-) rename .agents/notes/{implemented => archived}/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml (67%) rename .agents/notes/{implemented => archived}/process/2026-07-27-wine-windows-gates-experiment.md (99%) rename .agents/notes/{implemented => archived}/process/2026-07-27-wine-windows-gates-experiment.zh.md (99%) create mode 100644 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md create mode 100644 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md delete mode 100755 scripts/wine-windows-gates.sh diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 1adde54c7b..638d87373d 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -298,6 +298,9 @@ "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml": "sha256:89b6dc255cb0dd9d97ae6f34f37d185412d4260d7bf2f87d6b25e340f01dc26e", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md": "sha256:1742e09435ade4a09349c8843eb381e870a2be2d51b74449f08260422c8096b3", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md": "sha256:220bd53a88617b09ee8970627c46540fa8951724b102acfe296a7ed7d0f7b5fe", + "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5", + "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936", + "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml similarity index 67% rename from .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml rename to .agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 19945a6580..f7995fd9cf 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/archived/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: 1b01fe00dc1588482442a3cedc35eefa7fdb0975 -2026-07-27-wine-windows-gates-experiment.zh.md: b50739f5b63c5836248c94927037e38900abc84f +2026-07-27-wine-windows-gates-experiment.md: ce2ad1ad4506f59083de5731677d052161849e3d +2026-07-27-wine-windows-gates-experiment.zh.md: a0a5e8e7cce078adf2c961fe4d8cc50edc28355c diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md rename to .agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.md index 1b01fe00dc..ce2ad1ad45 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.md @@ -1,6 +1,7 @@ # Agent Note: Wine-run Windows blocking gates on Linux runners Status: implemented +Archived: 2026-08-08 English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md rename to .agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.zh.md index b50739f5b6..a0a5e8e7cc 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -1,6 +1,7 @@ # Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁 Status: implemented +Archived: 2026-08-08 [English](2026-07-27-wine-windows-gates-experiment.md) | 中文 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 a1c49fdcae..dcb6e18e88 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 @@ -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-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: 71b364f72a094f7899a0929eec29f3a16ccc8a93 -2026-07-21-serial-cross-platform-ci-reference.zh.md: 4f5920ffc150ce16d5567ca05128f7524c2a2b21 +2026-07-21-serial-cross-platform-ci-reference.md: dfd762ad4d4eec61bc3a17d96865ccc4ac936ab3 +2026-07-21-serial-cross-platform-ci-reference.zh.md: 580340db2fbfc14458a4bbfbfa157db75e74e027 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 71b364f72a..dfd762ad4d 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 @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. 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; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard 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. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job also runs on standard `windows-2025` under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard 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. ## Alternatives considered 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 4f5920ffc1..580340db2f 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 @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.md),拉取请求必需的 Windows 作业也在标准 `windows-2025` 上运行;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 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 440691d218..768e4298c7 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 .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 1a6939e8386e381cba114a7be71993a644457a45 -2026-07-23-portable-required-pull-request-ci.zh.md: f7a95ced2353ca7aaee9a586cc4f0bb297cbf7d4 +2026-07-23-portable-required-pull-request-ci.md: 11de2b1dcbbdbdd7f9423131cd84c2940ac9218d +2026-07-23-portable-required-pull-request-ci.zh.md: 02f5d758e1259d6a8e7cba70e439775bd1df2b28 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 1a6939e838..11de2b1dcb 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,9 +12,9 @@ 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 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)), keeping the pull-request Windows contract independent of any Windows runner allocation; the complete native-kernel Windows inventory lives in the master serial reference. 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 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs on standard `windows-2025` under native Windows for the blocking surfaces and broader observational inventory ([native Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial references remain the complete unsharded cross-platform definitions. 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 / wine blocking` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. +The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / native complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. @@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) ## Consequences -Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine-hosted Windows job keeps its verdict on standard Linux allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. +Ordinary pull requests spend enterprise capacity on the Linux critical path while the native Windows job uses standard Windows allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work. 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 f7a95ced23..02f5d758e1 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,9 +12,9 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node 以覆盖阻断性检查范围([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 契约不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单位于 master 串行参考流程中。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `windows-2025` 上以原生 Windows 环境运行,覆盖阻断性检查范围和更广泛的观测性清单([原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 -两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 +两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / native complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 托管的 Windows 作业让其判定保持在标准 Linux 运行器分配上。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 +普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而原生 Windows 作业使用标准 Windows 运行器容量。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。 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 7005f08ce0..224544c058 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 .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: 2258a1fe5f91b27b8a3b636c7f3ad26f502a98df +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 6fba2ce3b508d9d6fc879460a63c0e12673bd866 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: d9da8a4abe13772f3cc282392c03cdea54726c01 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 006360cedf..6fba2ce3b5 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 @@ -13,8 +13,8 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i `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): 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. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs 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): the required native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered 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 2258a1fe5f..d9da8a4abe 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 @@ -13,8 +13,8 @@ Status: implemented `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)作业与基于 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,不传输托管缓存归档。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 +- **无缓存或持久化**(不使用 store 缓存 action):必需的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml new file mode 100644 index 0000000000..3b3d2dc1dd --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md +2026-08-08-native-windows-pull-request-ci.md: 926eec04cff63aaf25d1e85b3dcbc41464f25660 +2026-08-08-native-windows-pull-request-ci.zh.md: 8728f0eb08adcb1501f036bb19ff523ded406ccc diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md new file mode 100644 index 0000000000..926eec04cf --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -0,0 +1,33 @@ +# Agent Note: Native Windows pull-request CI + +Status: implemented + +English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) + +## Problem + +The required pull-request Windows verdict must protect behavior that depends on the operating system, not only toolchain branches selected by `process.platform`. The Wine lane executed Windows Node and PE binaries over a Linux kernel and case-sensitive ext4, required a hoisted dependency layout and host-created symlinks, and omitted NTFS, DACL, ConPTY, crash-durability, and the broader observational Windows inventory. With the native serial references disabled, ordinary CI had no real Windows-kernel signal. + +## Decision + +The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) runs on GitHub's standard `windows-2025` image under native PowerShell. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete`. The stable `windows` job id remains a dependency of `all checks passed`; its display name is `windows node 24 / native complete`. + +The aggregate keeps workspace build and production-site failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. + +Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. + +## Alternatives considered + +**Keep Wine on the required path.** Its warm wall clock was close to Linux CI and it selected win32 toolchain branches, but the compatibility-specific layout and kernel gaps could report green while supported native behavior was broken. Latency no longer outweighs that missing signal. + +**Restore the pre-Wine workflow verbatim.** The old definition captured the right runner boundary but also carried then-current provisioning and topology assumptions. Reconstructing the native job against the current actions, pnpm setup, gate graph, and aggregate dependency avoids reviving obsolete machinery. + +**Run native Windows only after merge.** A post-merge reference diagnoses portability regressions after they enter `master`; it cannot protect a pull request while those references are disabled or delayed. + +**Use an organization-owned larger Windows runner.** Larger images can reduce wall clock, but a required correctness path would then depend on repository-external labels and allocation. Standard `windows-2025` is the portable recovery boundary; larger runners remain benchmark targets. + +## Consequences + +Pull requests receive a real NT kernel, NTFS, PowerShell, Windows process, and native addon signal before the aggregate can pass. The job is slower than the Wine compatibility lane and can queue on Windows capacity, but its green result now describes the supported host rather than an approximation. + +Removing the Wine cache producer and local script deletes a separate install topology and its recurring compatibility failures. Native coverage and snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before becoming part of this required lane. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md new file mode 100644 index 0000000000..8728f0eb08 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 原生 Windows 拉取请求 CI + +Status: implemented + +[English](2026-08-08-native-windows-pull-request-ci.md) | 中文 + +## 问题 + +拉取请求必需的 Windows 判定必须为依赖操作系统的行为提供保障,而不能只覆盖由 `process.platform` 选择的工具链分支。Wine 通道在 Linux 内核与区分大小写的 ext4 之上执行 Windows Node 和 PE 二进制文件,要求采用 hoisted 依赖布局和由宿主侧创建的符号链接,也没有覆盖 NTFS、DACL、ConPTY、崩溃持久性与更广泛的观测性 Windows 清单。原生串行参考流程停用期间,常规 CI 没有任何真实的 Windows 内核信号。 + +## 决策 + +[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业在 GitHub 标准 `windows-2025` 镜像上使用原生 PowerShell 运行。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并运行 `pnpm run check:ci:windows-complete`。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项;其显示名称为 `windows node 24 / native complete`。 + +聚合作业继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 + +受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 + +## 曾考虑的替代方案 + +**在必需路径上保留 Wine。** 其热运行墙钟时间接近 Linux CI,也会选择 win32 工具链分支;但兼容性专属布局和内核缺口可能在受支持的原生行为已经损坏时仍报告绿灯。这项延迟收益不足以抵消该信号缺失的代价。 + +**逐字恢复 Wine 之前的工作流。** 旧定义确实捕获了正确的运行器边界,但也携带了当时的供应方式与拓扑假设。按照当前 action、pnpm 设置、门禁图和聚合依赖关系重新构建原生作业,可以避免重新引入过时机制。 + +**只在合并后运行原生 Windows。** 合并后的参考流程只能在可移植性回归进入 `master` 后进行诊断;当这些参考流程被停用或延迟时,无法在拉取请求阶段提供保护。 + +**使用组织自有的大型 Windows 运行器。** 更大规格的运行器镜像可以缩短墙钟时间,但必需的正确性路径将因此依赖仓库外部的运行器标签与分配能力。标准 `windows-2025` 是可移植恢复边界;大型运行器仍作为基准测试目标。 + +## 后果 + +在聚合作业通过之前,拉取请求会获得来自真实 NT 内核、NTFS、PowerShell、Windows 进程和原生插件的信号。该作业比 Wine 兼容性通道更慢,也可能因 Windows 容量而排队,但其绿灯结果描述的是受支持的宿主,而非近似环境。 + +移除 Wine 缓存生产者和本地脚本后,独立的安装拓扑及其反复出现的兼容性故障也随之消失。原生 Windows 覆盖率与快照仍是明确列出的缺口,不会仅由作业名称暗示已经纳入;二者都必须先建立各自经过测试的契约,才能成为该必需通道的一部分。 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index ff4fd4e6b2..c95b85e8e2 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is not one of them: it runs Windows Node under Wine on hosted Linux, so its steps are bash — see the [Wine lane Agent Note](../.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md). +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46ab8e892f..0e96d50e50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,27 +300,29 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # The required pull-request Windows signal: the two blocking win32 surfaces - # (workspace build, production site) execute with real, checksum-verified - # Windows Node under Wine on standard hosted Linux. The master - # serial-windows job below normally keeps the complete native-kernel - # inventory — including the observational portability gates this lane does - # not run — - # 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 + # One standard Windows box shares setup across the required build/site checks + # and the observational portability inventory. Serial worker bounds keep this + # recovery path portable; Linux owns duplicate lint, coverage, and snapshots. + # See the native-Windows pull-request CI decision: + # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md windows: if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - name: windows node 24 / wine blocking - timeout-minutes: 15 + runs-on: windows-2025 + name: windows node 24 / native complete + env: + DSH_GATE_CONCURRENCY: '1' + DSH_PUBLINT_CONCURRENCY: '1' steps: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm @@ -329,86 +331,15 @@ 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" + # Extracting the many-file pnpm store cache is slower than a clean install, + # and saving it adds more latency after the gates. + - name: Install (immutable) + shell: pwsh + run: pnpm install --frozen-lockfile - - uses: actions/cache/restore@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- - - # Master's wine-apt-cache job seeds the default-branch scope every pull - # request can read; a save from this job only reaches reruns of the - # same merge ref. - - name: Compose Wine apt cache key - id: wine-cache-key - run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ~/wine-debs - key: ${{ steps.wine-cache-key.outputs.key }} - - # 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: | - 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 - - - name: Run the Wine Windows gates - run: bash scripts/wine-windows-gates.sh - - - name: Shut down wineserver - if: always() - run: wineserver -k 2>/dev/null || true - - # Master seeds the Wine apt-archive cache in the default-branch scope, - # which every pull request's windows job can restore; saves from - # pull-request runs are scoped to their own merge ref and help nobody - # else. Runs in seconds when the image version already has a cache. - wine-apt-cache: - if: github.event_name == 'push' && github.ref == 'refs/heads/master' - name: wine apt cache - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Compose Wine apt cache key - id: wine-cache-key - run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - id: wine-cache - with: - path: ~/wine-debs - key: ${{ steps.wine-cache-key.outputs.key }} - - - name: Download the Wine dependency closure - if: steps.wine-cache.outputs.cache-hit != 'true' - run: | - 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/" - du -sh "$HOME/wine-debs" + - name: Run blocking and observational Windows gates concurrently + shell: pwsh + run: pnpm run check:ci:windows-complete # The hosted reference jobs below are temporarily disabled; the self-hosted # standby remains active. Each enabled host executes the complete, unsharded diff --git a/AGENTS.md b/AGENTS.md index 64f2e944a8..575538bca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,6 @@ 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 # 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) diff --git a/package.json b/package.json index 423cbf813c..193e83dc1c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "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/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index baeac7a8c0..b5d161dae7 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -26,6 +26,28 @@ describe('CI workflow', () => { }) } }) + + it('runs the required Windows contract on a native hosted runner', () => { + const workflow = loadWorkflow('.github/workflows/ci.yml') + if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.windows)) { + throw new TypeError('CI workflow must define the windows job') + } + + const windows = workflow.jobs.windows + if (!Array.isArray(windows.steps)) throw new TypeError('windows job must define steps') + const steps: unknown[] = windows.steps + const commandSteps = steps.filter((step): step is Record & { run: string } => ( + isRecord(step) && typeof step.run === 'string' + )) + + expect(windows['runs-on']).toBe('windows-2025') + expect(windows.name).toBe('windows node 24 / native complete') + expect(commandSteps).toHaveLength(3) + expect(commandSteps.every(step => step.shell === 'pwsh')).toBe(true) + expect(commandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') + expect(JSON.stringify(windows)).not.toMatch(/wine/i) + expect(workflow.jobs).not.toHaveProperty('wine-apt-cache') + }) }) describe('Issue lifecycle workflow', () => { diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh deleted file mode 100755 index b6d8ffffb0..0000000000 --- a/scripts/wine-windows-gates.sh +++ /dev/null @@ -1,247 +0,0 @@ -#!/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 -# 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 - printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2 - 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 - 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" - 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 - 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 - # The hoisted linker — used only by this lane — has an upstream rename - # race (pnpm/pnpm#12880): parallel linkers staging a nested package copy - # (observed on the tree's nested esbuild versions) rename their _tmp_* - # directory onto a path another racer already claimed, and the loser - # exits ERR_PNPM_ENOENT although an identical re-install succeeds. - # Exactly that signature earns up to two retries on a clean tree — the - # snapshot contains no node_modules, so wiping them restores the - # pre-install state; any other failure, or the race still standing after - # the final attempt, fails loud with the log tail. - local attempt - for attempt in 1 2 3; do - (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ - && return 0 - grep -q 'ERR_PNPM_ENOENT.*rename.*_tmp_' "$scratch/logs/install.log" || break - (( attempt < 3 )) || break - echo "wine-windows-gates: pnpm hoisted-linker rename race (pnpm/pnpm#12880) on install attempt $attempt; retrying on a clean tree" >&2 - find "$scratch/tree" -name node_modules -type d -prune -exec rm -rf {} + - done - 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=$! -# 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")"))" - -# ---- 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 build preserves the face order from package.json: compile and bundle the -# Host face before compiling and bundling the Client face. -# Both statuses are captured so one failure cannot hide the other's result. -build_gate() { - wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? - wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? - wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? - wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client -} -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 (Host tsc/tsdown, Client tsc/tsdown)' "$build_status" \ - "$scratch/logs/host-tsc.log" \ - "$scratch/logs/host-tsdown.log" \ - "$scratch/logs/client-tsc.log" \ - "$scratch/logs/client-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 ce073eeef75b781dcb5a8f2932118360b4ae3753 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:52:41 +0800 Subject: [PATCH 02/57] test(windows): enforce native coverage --- ...6-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../2026-08-08-native-windows-pull-request-ci.md | 6 ++++-- .../2026-08-08-native-windows-pull-request-ci.zh.md | 6 ++++-- .github/workflows/ci.yml | 3 ++- scripts/ci-workflow.spec.ts | 9 +++++++++ scripts/run-gates.spec.ts | 9 +++++++++ scripts/run-gates.ts | 3 ++- vitest.config.ts | 13 +------------ 8 files changed, 33 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 3b3d2dc1dd..d35844a5b4 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 926eec04cff63aaf25d1e85b3dcbc41464f25660 -2026-08-08-native-windows-pull-request-ci.zh.md: 8728f0eb08adcb1501f036bb19ff523ded406ccc +2026-08-08-native-windows-pull-request-ci.md: 0249730adcba307123af17bf89f71c90d5ea18e2 +2026-08-08-native-windows-pull-request-ci.zh.md: 0b3755d5757b6ba7721caec9b45e72ac29c40ad1 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 926eec04cf..0249730adc 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -8,11 +8,13 @@ English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) The required pull-request Windows verdict must protect behavior that depends on the operating system, not only toolchain branches selected by `process.platform`. The Wine lane executed Windows Node and PE binaries over a Linux kernel and case-sensitive ext4, required a hoisted dependency layout and host-created symlinks, and omitted NTFS, DACL, ConPTY, crash-durability, and the broader observational Windows inventory. With the native serial references disabled, ordinary CI had no real Windows-kernel signal. +The coverage audit found that PR #499 had restored deterministic native-Windows LSP coverage, but a later GUI branch replayed its three temporary source exclusions from stale branch state. The current LSP fixtures skip only genuinely POSIX primitives and otherwise exercise the supported Windows process, transport, and lifecycle paths, so excluding `connection.ts`, `index.ts`, and `instance.ts` hid supported behavior rather than a platform limitation. + ## Decision The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) runs on GitHub's standard `windows-2025` image under native PowerShell. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete`. The stable `windows` job id remains a dependency of `all checks passed`; its display name is `windows node 24 / native complete`. -The aggregate keeps workspace build and production-site failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. +The aggregate keeps workspace build, production-site, and 100%-per-file coverage failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. Coverage has a four-worker budget; one runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint and snapshot enforcement. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. @@ -30,4 +32,4 @@ Wine-only infrastructure is absent from the supported workflow: there is no apt- Pull requests receive a real NT kernel, NTFS, PowerShell, Windows process, and native addon signal before the aggregate can pass. The job is slower than the Wine compatibility lane and can queue on Windows capacity, but its green result now describes the supported host rather than an approximation. -Removing the Wine cache producer and local script deletes a separate install topology and its recurring compatibility failures. Native coverage and snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before becoming part of this required lane. +Removing the Wine cache producer and local script deletes a separate install topology and its recurring compatibility failures. Native coverage now runs through the same required job and enforces the repository's per-file threshold without Windows-only source exclusions for supported LSP behavior. Native snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before becoming part of this required lane. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 8728f0eb08..0b3755d575 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -8,11 +8,13 @@ Status: implemented 拉取请求必需的 Windows 判定必须为依赖操作系统的行为提供保障,而不能只覆盖由 `process.platform` 选择的工具链分支。Wine 通道在 Linux 内核与区分大小写的 ext4 之上执行 Windows Node 和 PE 二进制文件,要求采用 hoisted 依赖布局和由宿主侧创建的符号链接,也没有覆盖 NTFS、DACL、ConPTY、崩溃持久性与更广泛的观测性 Windows 清单。原生串行参考流程停用期间,常规 CI 没有任何真实的 Windows 内核信号。 +覆盖率审计发现,PR(Pull Request)#499 已恢复确定性的原生 Windows LSP 覆盖率,后续的 GUI 分支却回放了陈旧分支状态中的 3 个临时源码排除项。当前的 LSP fixture(测试前置数据)只跳过真正属于 POSIX 的原语,除此之外还会检验受支持的 Windows 进程、传输与生命周期路径;因此,排除 `connection.ts`、`index.ts` 和 `instance.ts` 所掩盖的是受支持的行为,而非平台限制。 + ## 决策 [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业在 GitHub 标准 `windows-2025` 镜像上使用原生 PowerShell 运行。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并运行 `pnpm run check:ci:windows-complete`。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项;其显示名称为 `windows node 24 / native complete`。 -聚合作业继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 +工作区构建、生产网站和逐文件 100% 覆盖率检查失败时,聚合作业会继续阻断;更广泛的静态检查、文档、包和构建产物可移植性清单则作为观测项报告。覆盖率检查的工作线程预算为 4 个;同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。重复执行的 lint 与快照强制检查仍由 Linux 负责。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 @@ -30,4 +32,4 @@ Status: implemented 在聚合作业通过之前,拉取请求会获得来自真实 NT 内核、NTFS、PowerShell、Windows 进程和原生插件的信号。该作业比 Wine 兼容性通道更慢,也可能因 Windows 容量而排队,但其绿灯结果描述的是受支持的宿主,而非近似环境。 -移除 Wine 缓存生产者和本地脚本后,独立的安装拓扑及其反复出现的兼容性故障也随之消失。原生 Windows 覆盖率与快照仍是明确列出的缺口,不会仅由作业名称暗示已经纳入;二者都必须先建立各自经过测试的契约,才能成为该必需通道的一部分。 +移除 Wine 缓存生产者和本地脚本后,独立的安装拓扑及其反复出现的兼容性故障也随之消失。原生 Windows 覆盖率现在通过同一项必需作业运行,并强制执行仓库的逐文件覆盖率阈值;该覆盖率门禁不会为受支持的 LSP 行为设置仅针对 Windows 的源码排除项。原生 Windows 快照仍是明确列出的缺口,不会仅由作业名称暗示已经纳入;必须先为其建立专门且经过测试的契约,才能成为该必需通道的一部分。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e96d50e50..9cbd9463e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,7 +302,7 @@ jobs: # One standard Windows box shares setup across the required build/site checks # and the observational portability inventory. Serial worker bounds keep this - # recovery path portable; Linux owns duplicate lint, coverage, and snapshots. + # recovery path portable; Linux owns duplicate lint and snapshots. # See the native-Windows pull-request CI decision: # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md windows: @@ -310,6 +310,7 @@ jobs: runs-on: windows-2025 name: windows node 24 / native complete env: + DSH_COVERAGE_MAX_WORKERS: '4' DSH_GATE_CONCURRENCY: '1' DSH_PUBLINT_CONCURRENCY: '1' steps: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index b5d161dae7..b7e8391602 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -42,12 +42,21 @@ describe('CI workflow', () => { expect(windows['runs-on']).toBe('windows-2025') expect(windows.name).toBe('windows node 24 / native complete') + expect(windows.env).toMatchObject({ DSH_COVERAGE_MAX_WORKERS: '4' }) expect(commandSteps).toHaveLength(3) expect(commandSteps.every(step => step.shell === 'pwsh')).toBe(true) expect(commandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') expect(JSON.stringify(windows)).not.toMatch(/wine/i) expect(workflow.jobs).not.toHaveProperty('wine-apt-cache') }) + + it('keeps supported LSP source under native Windows coverage', () => { + const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8') + + expect(config).not.toContain('packages/lsp/lsp-local/src/connection.ts') + expect(config).not.toContain('packages/lsp/lsp-local/src/index.ts') + expect(config).not.toContain('packages/lsp/lsp-local/src/instance.ts') + }) }) describe('Issue lifecycle workflow', () => { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index c7b8a7d2c9..384458ce31 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -83,6 +83,15 @@ describe('gate graph validation', () => { expect(ids).toContain('public-repository-links') }) + it('keeps native Windows coverage blocking while portability inventory remains observational', () => { + const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')) + const byId = new Map(gates.map(subject => [subject.id, subject])) + + expect(byId.get('coverage')?.allowFailure).not.toBe(true) + expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true) + expect(byId.get('duplication')?.allowFailure).toBe(true) + }) + it.each([ ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 77432e7dbe..29fcd24fbe 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -434,6 +434,7 @@ function ciWindowsCompleteGates(): Gate[] { return [ pnpmScript('build', 'build'), pnpmScript('windows-site', 'docs:build', { label: 'production site' }), + ...coverageGates(), ...observational, ] } @@ -441,7 +442,7 @@ function ciWindowsCompleteGates(): Gate[] { function ciWindowsObservationalGates(): Gate[] { return [ ...ciStaticGates({ ownsBuild: true }), - // Linux owns required lint, coverage, and snapshots; Windows omits those duplicates. + // Linux owns required lint and snapshots; Windows omits those duplicates. pnpmScript('duplication', 'duplication'), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { diff --git a/vitest.config.ts b/vitest.config.ts index 6eb1bda354..275054d5df 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,17 +37,7 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] -// These files retain 100% per-file coverage on POSIX, where their process-pipe and terminal timing -// tests are deterministic; Windows skips those cases and must not fail solely on their uncovered paths. -const windowsCoverageExclusions = process.platform === 'win32' - ? [ - 'packages/lsp/lsp-local/src/connection.ts', - 'packages/lsp/lsp-local/src/index.ts', - 'packages/lsp/lsp-local/src/instance.ts', - ] - : [] - -// Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites +// pwsh-local's run/start/lifecycle suites // self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts // green while CI runners ship pwsh and still enforce the full bar. The probe @@ -226,7 +216,6 @@ export default defineConfig({ 'packages/ui/commands/src/invariant.ts', 'packages/session-projection/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), - ...windowsCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). From 702e2d024a0f6957c785b6f597a28ee53da458aa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:29:43 +0800 Subject: [PATCH 03/57] fix(windows): canonicalize native watch paths --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 4 ++ ...08-08-native-windows-pull-request-ci.zh.md | 4 ++ .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 2 +- .../credentials-local/README.zh.md | 2 +- .../credentials-local/src/index.ts | 4 +- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 1 + packages/settings/settings-local/README.zh.md | 1 + packages/settings/settings-local/src/index.ts | 4 +- packages/ui/app-boot/tests/hmr-config.spec.ts | 46 +++++++++++++++++-- packages/util/paths/README.i18n.yaml | 4 +- packages/util/paths/README.md | 6 ++- packages/util/paths/README.zh.md | 6 ++- packages/util/paths/src/index.ts | 30 +++++++++++- packages/util/paths/tests/paths.spec.ts | 22 ++++++++- scripts/project-doc-site.spec.ts | 6 +-- vendor/README.md | 2 +- vendor/hmr/README.md | 6 +++ vendor/hmr/src/index.ts | 31 ++++++++----- 21 files changed, 157 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 3b3d2dc1dd..c9d06abacf 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 926eec04cff63aaf25d1e85b3dcbc41464f25660 -2026-08-08-native-windows-pull-request-ci.zh.md: 8728f0eb08adcb1501f036bb19ff523ded406ccc +2026-08-08-native-windows-pull-request-ci.md: 8b6766cfa8df48c6c61410c06a410340a0c9bb21 +2026-08-08-native-windows-pull-request-ci.zh.md: 29cb1f725f4212c13974d642388edaf1df16b759 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 926eec04cf..8b6766cfa8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,6 +14,8 @@ The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) run The aggregate keeps workspace build and production-site failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. +The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. + Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. ## Alternatives considered @@ -30,4 +32,6 @@ Wine-only infrastructure is absent from the supported workflow: there is no apt- Pull requests receive a real NT kernel, NTFS, PowerShell, Windows process, and native addon signal before the aggregate can pass. The job is slower than the Wine compatibility lane and can queue on Windows capacity, but its green result now describes the supported host rather than an approximation. +The native lane is also a portability inventory: its exact-head acceptance requires every blocking gate to pass and the final summary to contain no non-blocking failure. That distinction caught path contracts which a successful wrapper conclusion alone would have concealed. + Removing the Wine cache producer and local script deletes a separate install topology and its recurring compatibility failures. Native coverage and snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before becoming part of this required lane. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 8728f0eb08..29cb1f725f 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,6 +14,8 @@ Status: implemented 聚合作业继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 +首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 + 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 ## 曾考虑的替代方案 @@ -30,4 +32,6 @@ Status: implemented 在聚合作业通过之前,拉取请求会获得来自真实 NT 内核、NTFS、PowerShell、Windows 进程和原生插件的信号。该作业比 Wine 兼容性通道更慢,也可能因 Windows 容量而排队,但其绿灯结果描述的是受支持的宿主,而非近似环境。 +原生通道也是一份可移植性清单:其分支头精确验收要求每个阻断门禁都通过,且最终汇总中不包含任何非阻断失败。这一区分发现了路径契约问题;若只看包装层的成功结论,这些问题本会被掩盖。 + 移除 Wine 缓存生产者和本地脚本后,独立的安装拓扑及其反复出现的兼容性故障也随之消失。原生 Windows 覆盖率与快照仍是明确列出的缺口,不会仅由作业名称暗示已经纳入;二者都必须先建立各自经过测试的契约,才能成为该必需通道的一部分。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 07a3efd5c3..66ad77f648 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md -README.md: 8e95a890a8e38172cf8984653a01c59570f0061a -README.zh.md: 04ad07ae4e703ab0416d1d8f1bb6a6ff90adf337 +README.md: 035d558d8cbd51ce46af72d7bd9cd56bed85ebcd +README.zh.md: 11c772951dab3ce0cea3a13e739198d41028d598 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 8e95a890a8..035d558d8c 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -47,7 +47,7 @@ The provider creates the directory `0700` and creates or atomically replaces the ## Hot reload -External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. ## Security boundary diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 04ad07ae4e..11c772951d 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -47,7 +47,7 @@ OPENAI_API_KEY: sk-… ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 ## 安全边界 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index e781c10f3e..67b598d7e0 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -41,7 +41,7 @@ import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { Document, parseDocument, type YAMLError } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { environmentOf } from '@deepseek-ai/dsh-environment' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' @@ -270,7 +270,7 @@ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- same watcher discipline as settings-local by design: the serialized-refresh and quiesce-on-dispose shape is the reviewed lifecycle contract, not accidental repetition. */ - const watcher = chokidarWatch(this.spec.filename, { + const watcher = chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.spec.debounceMs, diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index ec26e4241f..188e4361bb 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md -README.md: d1f3d755f9073acdf6fcfc5d1de883d74cc023c4 -README.zh.md: 03c7ef7c35312d9e1e4a86b0df71412875af7d6c +README.md: a0bc94630f78aa7c101e3eb0795a7d07e585e347 +README.zh.md: 2d2d0828f2eadde9867ab91fe8fbd4c61dea4c11 diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index d1f3d755f9..a0bc94630f 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -24,6 +24,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension - **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments. - **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. - **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap. +- **The native watcher receives a canonical path.** Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix. File access and user-facing diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form event paths inside libuv. - **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal. - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. - **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index 03c7ef7c35..2d2d0828f2 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -24,6 +24,7 @@ - **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。 - **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 - **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。 +- **原生 watcher 接收规范化路径。** 在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀。文件访问和面向用户的诊断仍使用配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。 - **Dispose 在每种 watch 模式下都保证静止。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。 - **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 - **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。 diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 0ce07af40c..16bc641f05 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -14,7 +14,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' /** Plugin config: file location and hot-reload behavior. */ @@ -235,7 +235,7 @@ export class SettingsLocal extends Settings { // silently ignored or overwritten. yield* super[Service.init]() const watcher = this.spec.watch - ? chokidarWatch(this.spec.filename, { + ? chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.spec.debounceMs, diff --git a/packages/ui/app-boot/tests/hmr-config.spec.ts b/packages/ui/app-boot/tests/hmr-config.spec.ts index 1892a6e73a..fe6bb556b4 100644 --- a/packages/ui/app-boot/tests/hmr-config.spec.ts +++ b/packages/ui/app-boot/tests/hmr-config.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -8,12 +8,12 @@ import Loader from '@cordisjs/plugin-loader' import Timer from '@cordisjs/plugin-timer' import { describe, expect, it } from 'vitest' -async function bootHmr(dir: string): Promise { +async function bootHmr(dir: string, root: string[] = []): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(dir).href + '/' await ctx.plugin(Loader) await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await ctx.plugin(Hmr, { root, ignored: [], debounce: 0 }) return ctx } @@ -26,6 +26,46 @@ async function eventually(test: () => boolean, message: string): Promise { } describe('HMR exact config paths', () => { + it('observes module changes when its watch base is a filesystem alias', { timeout: 20_000 }, async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) + const alias = `${target}-alias` + const filename = join(alias, 'module.ts') + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(filename, 'export const generation = 0\n') + const ctx = await bootHmr(alias, ['.']) + const expected = pathToFileURL(filename).href + const observed: string[] = [] + ctx.on('hmr/change', (url) => { observed.push(url) }) + try { + const deadline = Date.now() + 10_000 + for (let generation = 1; !observed.includes(expected); generation += 1) { + if (Date.now() >= deadline) throw new Error('HMR did not observe a module change through the alias') + writeFileSync(filename, `export const generation = ${generation}\n`) + await new Promise(resolve => setTimeout(resolve, 20)) + } + } finally { + await ctx.fiber.dispose() + rmSync(alias, { force: true }) + rmSync(target, { recursive: true, force: true }) + } + }) + + it('collapses filesystem aliases before registering an exact watch', async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-')) + const alias = `${target}-alias` + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const ctx = await bootHmr(alias) + try { + await ctx.hmr.registerConfig('plugins.yml', () => {}) + await expect(ctx.hmr.registerConfig(join(realpathSync(target), 'plugins.yml'), () => {})) + .rejects.toThrow('config path already registered') + } finally { + await ctx.fiber.dispose() + rmSync(alias, { force: true }) + rmSync(target, { recursive: true, force: true }) + } + }) + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) const filename = join(dir, 'plugins.yml') diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml index f79f6b2d73..6bed54f5c3 100644 --- a/packages/util/paths/README.i18n.yaml +++ b/packages/util/paths/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/util/paths/README.md -README.md: 2b3272e019ef2f37386da9156b06a5c151836d8c -README.zh.md: 7fe0ec04117ae439ade653cefd1c8f5da094d8fd +README.md: 8d10ed855a37f1205f87420b3f36f45b10e65bd3 +README.zh.md: ed3ca377bd48252fe0ef3f95186dc6eb1fb6e6a0 diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index 2b3272e019..8d10ed855a 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -18,9 +18,13 @@ Shared filesystem path helpers for DeepSeek Harness user data. `expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched. +## Watch paths + +`canonicalizeWatchPath()` gives a native filesystem watcher one stable spelling of its target. It resolves the deepest existing ancestor through `fs.realpath()` and restores any missing suffix, so a file or directory may still be watched before it is created. In particular, Windows 8.3 aliases cannot be mixed with the long paths emitted by the native watcher backend. + This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another. ## Known Limitations and Deferred Work - **Expansion is deliberately narrow** — only bare `~`, `~/...`, and `~\...` use the current operating-system home; named-user forms such as `~alice/...`, environment variables, and shell expressions remain unchanged. -- **Helpers do not touch the filesystem** — callers still own directory creation, existence checks, permissions, and trust policy for the resulting path. +- **Canonicalization reads but never mutates** — `canonicalizeWatchPath()` performs `realpath` probes and propagates errors other than absence; callers still own directory creation, permissions, and trust policy for the resulting path. diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md index 7fe0ec0411..ed3ca377bd 100644 --- a/packages/util/paths/README.zh.md +++ b/packages/util/paths/README.zh.md @@ -18,9 +18,13 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 `expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。 +## 监听路径 + +`canonicalizeWatchPath()` 为原生文件系统 watcher 提供一种稳定的目标路径表示。它通过 `fs.realpath()` 解析层级最深的现有祖先路径,再拼回缺失的后缀,因此即使文件或目录尚未创建也仍可监听。尤其是,Windows 8.3 别名不能与原生 watcher 后端发出的长路径混用。 + 该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 ## 已知限制与暂缓事项 - **展开范围刻意保持狭窄**:只有单独的 `~`、`~/...` 和 `~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。 -- **辅助工具不会操作文件系统**:调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。 +- **规范化会读取,但绝不修改**:`canonicalizeWatchPath()` 会执行 `realpath` 探测,并传播除路径不存在以外的错误;调用方仍负责目录创建、权限,以及对结果路径应用信任策略。 diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index a8f9245c72..591158d7c0 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -4,8 +4,9 @@ * @module @deepseek-ai/dsh-paths */ +import { realpath } from 'node:fs/promises' import { homedir } from 'node:os' -import { join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' /** Directory name for the default DeepSeek Harness home under the OS home. */ export const DSH_HOME_DIR_NAME = '.dsh' @@ -16,6 +17,33 @@ export const DEFAULT_DSH_HOME_DISPLAY = `~/${DSH_HOME_DIR_NAME}` /** Environment variable that overrides the default DeepSeek Harness home. */ export const DSH_HOME_ENV = 'DSH_HOME' +/** + * Give a native filesystem watcher one canonical spelling of a path, even + * when its final components do not exist yet. The deepest existing ancestor + * is resolved through {@link realpath}; the missing suffix is then restored. + * This prevents Windows short-name aliases from being mixed with long paths + * emitted by the native watcher backend. + * @param path - Watch target or root, resolved against the current directory. + * @returns the target with its existing ancestor canonicalized. + * @throws when ancestor traversal encounters an error other than absence. + */ +export async function canonicalizeWatchPath(path: string): Promise { + let current = resolve(path) + const missing: string[] = [] + while (true) { + try { + return join(await realpath(current), ...missing.reverse()) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(current) + /* v8 ignore next -- a filesystem root exists, so traversal resolves before this guard */ + if (parent === current) throw error + missing.push(basename(current)) + current = parent + } + } +} + /** * Resolve the default DeepSeek Harness home using Node's platform path rules. * @returns the absolute default harness home path. diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 08336bbcce..df5555f910 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -1,9 +1,11 @@ -import { homedir } from 'node:os' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_DSH_HOME_DISPLAY, DSH_HOME_DIR_NAME, + canonicalizeWatchPath, defaultDshHome, dshHomeDisplay, dshHomePath, @@ -53,4 +55,22 @@ describe('dsh path helpers', () => { expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh') expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') }) + + it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-')) + const target = join(root, 'target') + const alias = join(root, 'alias') + try { + await mkdir(target) + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + await expect(canonicalizeWatchPath(join(alias, 'later', 'config.yml'))).resolves.toBe( + join(await realpath(target), 'later', 'config.yml'), + ) + const file = join(root, 'file') + await writeFile(file, 'not a directory') + await expect(canonicalizeWatchPath(join(file, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 89e417558c..3b39b83897 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { basename, join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { docsPages, type DocsPage } from '../website/docs.ts' import { @@ -148,7 +148,7 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - const name = absPath.split('/').pop() ?? '' + const name = basename(absPath) placed.push(name) return `./${name}` }, @@ -167,7 +167,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + placeImage: absPath => `./${basename(absPath)}`, })).toBe('![logo](./logo.svg#view)\n') }) diff --git a/vendor/README.md b/vendor/README.md index 4bf1d43f46..0a434605ae 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. -9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. diff --git a/vendor/hmr/README.md b/vendor/hmr/README.md index db5ac1a49a..eec6ff940f 100644 --- a/vendor/hmr/README.md +++ b/vendor/hmr/README.md @@ -7,6 +7,12 @@ module caches, and reloads only the plugin entries that depend on changed application files. Changes to framework-level dependencies fall back to `loader.exit()`, letting the host process restart. +Module watches canonicalize their existing base directory before opening +Chokidar. Exact config watches likewise canonicalize the deepest existing +ancestor, then restore any missing suffix. Callbacks and diagnostics retain the +requested absolute filename, while the native backend receives one filesystem +spelling even when Windows supplied an 8.3 alias. + ## Requirements - `@cordisjs/plugin-loader` diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 00864cd865..c1d379e36c 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -4,7 +4,7 @@ import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plug import type { Include } from '@cordisjs/plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { dirname, relative, resolve } from 'node:path' -import { stat } from 'node:fs/promises' +import { realpath, stat } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -61,13 +61,18 @@ interface ConfigRegistration { watcher: FSWatcher } -async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> { +async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> { let root = dirname(filename) let depth = 0 while (true) { try { if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) - return { root, depth } + const canonicalRoot = await realpath(root) + return { + filename: resolve(canonicalRoot, relative(root, filename)), + root: canonicalRoot, + depth, + } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error const parent = dirname(root) @@ -129,9 +134,11 @@ class Hmr extends Service { async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { if (!this.watcher) throw new Error('HMR is not active') filename = resolve(this.baseDir, filename) - if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`) + const target = await findWatchRoot(filename) + const watchFilename = target.filename + if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`) - const { root, depth } = await findWatchRoot(filename) + const { root, depth } = target const watcher = watch(root, { ...this.config, cwd: undefined, @@ -140,9 +147,10 @@ class Hmr extends Service { ignoreInitial: false, }) const registration = { watcher } - this.configs.set(filename, registration) + this.configs.set(watchFilename, registration) const onChange = (path: string) => { - if (resolve(path) !== filename) return + const observed = resolve(path) + if (observed !== filename && observed !== watchFilename) return this.refreshConfig(registration, filename, refresh) } watcher.on('add', onChange) @@ -167,12 +175,12 @@ class Hmr extends Service { try { await ready.promise return this.ctx.effect(() => async () => { - if (this.configs.get(filename) === registration) this.configs.delete(filename) + if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename) await watcher.close() await this.configRefreshes.get(registration)?.running }, 'hmr.registerConfig()') } catch (error) { - this.configs.delete(filename) + this.configs.delete(watchFilename) await watcher.close() throw error } @@ -205,10 +213,11 @@ class Hmr extends Service { } const match = picomatch(ignored) + const watchBaseDir = await realpath(this.baseDir) this.watcher = watch(root, { ...this.config, - cwd: this.baseDir, - ignored: path => match(relative(this.baseDir, path)), + cwd: watchBaseDir, + ignored: path => match(relative(watchBaseDir, path)), // The initial scan re-announces files the boot just consumed: an `add` // for a config file refreshes an include whose initial apply may still // be in flight, and a failing apply then rolls this plugin back while From bdbc6c3da4b2579973574a0bea54a4d851b32a8e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:57:31 +0800 Subject: [PATCH 04/57] fix(windows): preserve graceful CLI shutdown --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 + ...08-08-native-windows-pull-request-ci.zh.md | 2 + apps/cli/src/profile-boot.ts | 69 +++++++++++-------- apps/cli/tests/built-bin.e2e.ts | 37 +++++++--- 5 files changed, 76 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index c9d06abacf..b1b0079115 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 8b6766cfa8df48c6c61410c06a410340a0c9bb21 -2026-08-08-native-windows-pull-request-ci.zh.md: 29cb1f725f4212c13974d642388edaf1df16b759 +2026-08-08-native-windows-pull-request-ci.md: 80fccabdc9ddebba2204732677da31363b74a184 +2026-08-08-native-windows-pull-request-ci.zh.md: 9d00f4fad6eb817968609c3af1f278cc6794e7c7 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 8b6766cfa8..80fccabdc9 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -16,6 +16,8 @@ The aggregate keeps workspace build and production-site failures blocking while The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. +The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. + Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 29cb1f725f..9d00f4fad6 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -16,6 +16,8 @@ Status: implemented 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 +下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture(测试前置数据)通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 + 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 ## 曾考虑的替代方案 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 4730ec7073..b49bd37535 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' +import { FiberState, type Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import { boot, @@ -168,6 +168,10 @@ export interface RunProfileOptions { environment: EnvironmentSnapshot } +function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void { + if (!signal.aborted) throw error +} + /** * Boot one profile invocation end to end and leave process lifetime to the * mounted plugins (or to the one-shot runner when `task` is present). @@ -196,11 +200,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const app: { current?: Context } = {} const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) + const signalShutdown = new AbortController() + const interrupt = (code: number): void => { + signalShutdown.abort() + shutdown.interrupt(code) + } // Signals own teardown throughout the startup window, not only after boot() // settles: an inserted front door can publish readiness before sibling rows // finish mounting. - process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) }) - process.on('SIGINT', () => { shutdown.interrupt(130) }) + process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) + process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() }) @@ -243,33 +252,37 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con await options.prepare?.(hostCtx, composed.rows) }) app.current = ctx - // A surface can dispose the whole tree while startup was still in flight - // (early SIGTERM); the Loader service goes with it and there is nothing to - // keep live. - if (watchProfilePatch && ctx.get('loader') !== undefined) { - // Config-only HMR for the live profile patch layer: the web bundle - // disables the shared module-reload `hmr` row (its reload lifecycle is - // untested), so when the composition leaves no HMR service, mount a - // watch-only instance with no module roots — cordis.patch.yml edits stay - // live on every long-lived surface. A silent skip would break the - // documented hot-reload contract. HMR injects the timer service, which a - // bare custom profile may not mount either. - if (ctx.get('hmr') === undefined) { - if (ctx.get('timer') === undefined) { - await ctx.loader.create({ name: '@cordisjs/plugin-timer' }) + // A surface can dispose the whole tree while startup or this post-boot + // watcher setup is still in flight. Fiber state owns liveness; the local + // signal fact distinguishes that expected exit race from a real HMR error. + if (watchProfilePatch && !signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE) { + try { + // Config-only HMR for the live profile patch layer: the web bundle + // disables the shared module-reload `hmr` row (its reload lifecycle is + // untested), so when the composition leaves no HMR service, mount a + // watch-only instance with no module roots — cordis.patch.yml edits stay + // live on every long-lived surface. A silent skip would break the + // documented hot-reload contract. HMR injects the timer service, which a + // bare custom profile may not mount either. + if (ctx.get('hmr') === undefined) { + if (ctx.get('timer') === undefined) { + await ctx.loader.create({ name: '@cordisjs/plugin-timer' }) + } + await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) } - await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) + await watchUserPatches(ctx, { + binName: NAME, + filename: composed.profile.patchPath, + compose: composeLive, + }) + await watchUserPatches(ctx, { + binName: NAME, + filename: homePatchPath(), + compose: composeLive, + }) + } catch (error) { + suppressSignalShutdownError(signalShutdown.signal, error) } - await watchUserPatches(ctx, { - binName: NAME, - filename: composed.profile.patchPath, - compose: composeLive, - }) - await watchUserPatches(ctx, { - binName: NAME, - filename: homePatchPath(), - compose: composeLive, - }) } return { ctx, shutdown } } diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index a42780581f..05e248a8f6 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -49,6 +49,7 @@ interface ProfileLifecycleFixture { ready: string settled: string disposed: string + interrupt: string } /** @@ -61,16 +62,23 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { const ready = join(home, 'ready') const settled = join(home, 'settled') const disposed = join(home, 'disposed') + const interrupt = join(home, 'interrupt') const bundleDir = join(home, 'lifecycle-bundle') mkdirSync(bundleDir, { recursive: true }) writeFileSync(join(bundleDir, 'plugin.mjs'), [ - "import { writeFileSync } from 'node:fs'", + "import { existsSync, writeFileSync } from 'node:fs'", "import { join } from 'node:path'", "export const name = 'profile-lifecycle-fixture'", 'export function apply(ctx, config = {}) {', ' let active = true', ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.', - ' const heartbeat = setInterval(() => {}, 1000)', + ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.', + ' let interrupted = false', + ' const heartbeat = setInterval(() => {', + ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return', + ' interrupted = true', + " process.emit('SIGTERM')", + ' }, 20)', ' // Echo the mounted generation so the hot-reload e2e can assert both an', ' // applied override and its removal reverting to this bundle default.', " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", @@ -118,7 +126,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) { writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file))) } - return { home, ready, settled, disposed } + return { home, ready, settled, disposed, interrupt } } function startProfileLifecycle(fixture: ProfileLifecycleFixture) { @@ -131,10 +139,22 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { RAW_READY_FILE: fixture.ready, RAW_SETTLED_FILE: fixture.settled, RAW_DISPOSED_FILE: fixture.disposed, + RAW_INTERRUPT_FILE: fixture.interrupt, }, }) } +function requestProfileShutdown( + child: ReturnType, + fixture: ProfileLifecycleFixture, +): void { + if (process.platform === 'win32') { + writeFileSync(fixture.interrupt, 'interrupt') + return + } + child.kill('SIGTERM') +} + function createEnvironmentProbeProfile(home: string, project: string): void { const pluginFile = join(project, 'environment-probe.mjs') writeFileSync(pluginFile, [ @@ -152,7 +172,8 @@ function createEnvironmentProbeProfile(home: string, project: string): void { " if (chunk.type === 'text-delta') text += chunk.text", ' }', ' process.stdout.write(`${text}\\n`)', - " process.kill(process.pid, 'SIGTERM')", + " if (process.platform === 'win32') process.emit('SIGTERM')", + " else process.kill(process.pid, 'SIGTERM')", ' })', '}', '', @@ -321,9 +342,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = startProfileLifecycle(fixture) try { await waitForFile(fixture.ready) - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) const result = await child - expect(result.exitCode).toBe(0) + expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0) expect(result.signal).toBeUndefined() expect(existsSync(fixture.disposed)).toBe(true) } finally { @@ -373,9 +394,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ].join('\n')) await waitForFile(fixture.ready) expect(readFileSync(configFile, 'utf8')).toBe('home') - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) const result = await child - expect(result.exitCode).toBe(0) + expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0) expect(result.signal).toBeUndefined() expect(existsSync(fixture.disposed)).toBe(true) } finally { From ed1ea95176799eaee037621a409a86251652d8a7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:01:46 +0800 Subject: [PATCH 05/57] fix(windows): normalize native coverage tests --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 2 ++ .../2026-08-08-native-windows-pull-request-ci.zh.md | 2 ++ packages/typert/generator/src/analyzer.ts | 5 +++-- packages/typert/generator/tests/remote-model.spec.ts | 9 +++++++-- packages/typert/generator/tests/type-model.spec.ts | 9 +++++++-- scripts/change-scope.spec.ts | 4 ++-- scripts/install-lefthook.spec.ts | 2 +- scripts/oxlint-contract.spec.ts | 2 +- 9 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index e69e8b3e89..cb4123b330 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: ab4f9ec7a9c18b9cf3303ad705145f51ccc4f08d -2026-08-08-native-windows-pull-request-ci.zh.md: e569371647e948b245b1a0db5b7c54eb51238cbf +2026-08-08-native-windows-pull-request-ci.md: d62858106505e9b6ae79c6ad6c4854b1f3270ae0 +2026-08-08-native-windows-pull-request-ci.zh.md: c428e3be028e4201fc178142e54f5a3d02aac742 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index ab4f9ec7a9..d628581065 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,6 +18,8 @@ The aggregate keeps workspace build, production-site, and 100%-per-file coverage The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. +The coverage follow-up then exercised the serial heavy suites on the native host and removed their remaining path-spelling assumptions. Filesystem identity assertions compare native real paths instead of Git's slash convention with Node's temporary-directory spelling; quoted diagnostics are matched in their escaped form; TypeScript-owned file names are compared after separator normalization; and Typert passes a slash-normalized config name consistently across TypeScript's read and parse boundary so malformed Windows configs produce the owned analysis error instead of a compiler debug failure. The Oxlint subprocess contract also uses the same explicit twenty-second budget as its neighboring executable probes. These are portability repairs to supported tests and parser behavior, not platform skips or coverage exclusions. + Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index e569371647..c428e3be02 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,6 +18,8 @@ Status: implemented 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 +随后,覆盖率后续工作在原生宿主上运行了串行的高负载测试套件,并移除了其中残留的路径拼写假设。文件系统标识断言改为比较原生真实路径,不再直接比较遵循 Git 斜杠约定的路径与 Node 的临时目录拼写;带引号的诊断文本按 JSON 转义后的形式匹配;TypeScript 提供的文件名在统一分隔符后再比较;Typert 则让经过斜杠归一化的配置名称一致贯穿 TypeScript 的读取与解析边界,使格式错误的 Windows 配置产生 Typert 自有的分析错误,而非编译器的调试故障。Oxlint 子进程契约也采用与相邻可执行文件探测相同的显式 20 秒预算。这些都是针对受支持测试与解析器行为的可移植性修复,不是按平台跳过测试或设置覆盖率排除项。 + 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 ## 曾考虑的替代方案 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 6f495aa318..c932dd395f 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -2507,9 +2507,10 @@ function mergeWorkspaceModels(models: readonly WorkspaceModel[]): WorkspaceModel } function parseConfig(path: string): ParsedConfig { - const read = ts.readConfigFile(path, file => ts.sys.readFile(file)) + const compilerPath = path.split(sep).join('/') + const read = ts.readConfigFile(compilerPath, file => ts.sys.readFile(file)) if (read.error !== undefined) throw new TypertAnalysisError(formatDiagnostic(read.error)) - const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(path), undefined, path) + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(compilerPath), undefined, compilerPath) if (parsed.errors.length > 0) throw new TypertAnalysisError(parsed.errors.map(formatDiagnostic).join('\n')) return { path, parsed } } diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 0e62a56bf4..a41f0c0cd0 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -10,6 +10,10 @@ import { WorkspaceTypertGenerator } from '../src/workspace.ts' const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model') const temporaryRoots: string[] = [] +function normalizedPath(path: string): string { + return path.replaceAll('\\', '/') +} + interface RuntimeSchema { safeParse(value: unknown): { readonly success: boolean } } @@ -646,7 +650,8 @@ void navigated const navigation = 'ctx.remote.goals.create' const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 const definitions = languageService.getDefinitionAtPosition(consumerPath, position) - const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) + const generatedDefinition = definitions?.find(candidate => + normalizedPath(candidate.fileName) === normalizedPath(declarationPath)) if (generatedDefinition === undefined) { throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`) } @@ -661,7 +666,7 @@ void navigated pos: generatedDefinition.textSpan.start, }) languageService.dispose() - if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) { + if (definition === undefined || !normalizedPath(definition.fileName).endsWith('/packages/remote/src/index.ts')) { throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`) } const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8') diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index 40a91e3ef5..faf41969d3 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -18,6 +18,11 @@ import { WorkspaceTypertGenerator } from '../src/workspace.ts' const fixtureRoot = resolve(import.meta.dirname, 'fixtures/type-model') const temporaryRoots: string[] = [] + +function normalizedPath(path: string): string { + return path.replaceAll('\\', '/') +} + const parseConfigHost: ts.ParseConfigFileHost = { ...ts.sys, onUnRecoverableConfigFileDiagnostic(diagnostic) { @@ -733,8 +738,8 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { rootNames: packageConfig.fileNames, options: aggregateConfig.options, }) - expect(diagnosticProgram.getSourceFiles().map(source => source.fileName)) - .toContain(join(externalRoot, 'index.d.ts')) + expect(diagnosticProgram.getSourceFiles().map(source => normalizedPath(source.fileName))) + .toContain(normalizedPath(join(externalRoot, 'index.d.ts'))) const targets = new WorkspaceAnalyzer({ root }).analyze().faces .flatMap(face => face.graph.nodes) diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index a9ba98407b..b230c8de12 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -104,7 +104,7 @@ describe('change-scope', () => { const headSha = commit(root, 'feature.txt', 'feature\n') const fresh = jsonReport(root, 'origin/master') - expect(fresh.repositoryRoot).toBe(realpathSync(root)) + expect(realpathSync.native(fresh.repositoryRoot)).toBe(realpathSync.native(root)) expect(fresh.resolved).toEqual({ baseSha: git(root, ['rev-parse', 'origin/master']), headSha, @@ -122,7 +122,7 @@ describe('change-scope', () => { const { root } = fixture('worktree ') const report = jsonReport(root, 'HEAD') - expect(report.repositoryRoot).toBe(realpathSync(root)) + expect(realpathSync.native(report.repositoryRoot)).toBe(realpathSync.native(root)) expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] }) }) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index ca74b91404..35a5fa3216 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -587,7 +587,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { expect(result.status).toBe(1) expect(result.stderr).toContain('sibling dormant worktree config') - expect(result.stderr).toContain(linkedConfig) + expect(result.stderr).toContain(JSON.stringify(linkedConfig)) expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1) expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks) diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 7592780881..865eee26eb 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -208,7 +208,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }) + }, 20_000) it('accepts an ignored-only staged selection', () => { const result = runOxlint([ From f96be1db130c4084e33bcb6bf52b14f3567c7a7c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:36:31 +0800 Subject: [PATCH 06/57] test(windows): make native coverage fixtures portable --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 + ...08-08-native-windows-pull-request-ci.zh.md | 4 +- .../tests/jsonl.spec.ts | 13 ++++++- .../subagent-acp/tests/subagent-acp.spec.ts | 38 +++++++++++++------ vitest.config.ts | 2 + 6 files changed, 47 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 90939403e7..ea8b24b08f 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: e90bfad28298fb578d080e8bda5b9f029b419e7e -2026-08-08-native-windows-pull-request-ci.zh.md: eeec48e339998347b8ea0c51d254170e270b31f3 +2026-08-08-native-windows-pull-request-ci.md: c824111cbd3d5f4126b0a383c99d0600b3e33ecf +2026-08-08-native-windows-pull-request-ci.zh.md: 76e719d073669e4af551224ebf04bbf9284126a2 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index e90bfad282..c824111cbd 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -20,6 +20,8 @@ The first native run exposed two failures hidden by the compatibility lane. Docu The coverage follow-up then exercised the serial heavy suites on the native host and removed their remaining path-spelling assumptions. Filesystem identity assertions compare native real paths instead of Git's slash convention with Node's temporary-directory spelling; quoted diagnostics are matched in their escaped form; TypeScript-owned file names are compared after separator normalization; and Typert passes a slash-normalized config name consistently across TypeScript's read and parse boundary so malformed Windows configs produce the owned analysis error instead of a compiler debug failure. The Oxlint subprocess contract also uses the same explicit twenty-second budget as its neighboring executable probes. These are portability repairs to supported tests and parser behavior, not platform skips or coverage exclusions. +The blocking coverage gate exposed two more fixture contracts that had never run on the native lane. The JSONL materialization fault now asserts the structured filesystem error code because the Windows durable-directory implementation owns an `ENOTDIR` code without copying it into human prose. The ACP teardown ladder now uses Node children instead of assuming a POSIX shell and asserts Windows' force-termination outcome rather than POSIX signal names; POSIX still proves the `SIGTERM` and `SIGKILL` tiers. Those suites load native bindings or own real process trees, so the Windows thread pool runs them in the existing fork-isolated project while still merging their coverage into the same per-file threshold. + The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index eeec48e339..76e719d073 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -20,7 +20,9 @@ Status: implemented 随后,覆盖率后续工作在原生宿主上运行了串行的高负载测试套件,并移除了其中残留的路径拼写假设。文件系统标识断言改为比较原生真实路径,不再直接比较遵循 Git 斜杠约定的路径与 Node 的临时目录拼写;带引号的诊断文本按 JSON 转义后的形式匹配;TypeScript 提供的文件名在统一分隔符后再比较;Typert 则让经过斜杠归一化的配置名称一致贯穿 TypeScript 的读取与解析边界,使格式错误的 Windows 配置产生 Typert 自有的分析错误,而非编译器的调试故障。Oxlint 子进程契约也采用与相邻可执行文件探测相同的显式 20 秒预算。这些都是针对受支持测试与解析器行为的可移植性修复,不是按平台跳过测试或设置覆盖率排除项。 -下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture(测试前置数据)通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 +这项阻断覆盖率门禁又暴露出两项从未在原生通道上运行过的 fixture 契约。JSONL 实体化故障场景现在断言结构化文件系统错误码,因为 Windows 的持久目录实现拥有 `ENOTDIR` 错误码,却不会将其复制进人类可读文本。ACP(Agent Client Protocol)拆卸阶梯现在使用 Node 子进程,不再假定 POSIX shell,并断言 Windows 的强制终止结果而非 POSIX 信号名称;POSIX 仍会证明 `SIGTERM` 与 `SIGKILL` 两级。这些套件会加载原生绑定或拥有真实进程树,因此 Windows 线程池会让它们在现有的 fork 隔离项目中运行,同时仍将这些套件的覆盖率汇入同一项逐文件阈值。 + +下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture 通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 24ed94a3fb..6d97420c4f 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -62,6 +62,17 @@ async function expectFlushError(promise: Promise, message: RegExp): Pro throw new Error('expected flush to reject') } +async function expectFlushCode(promise: Promise, codes: readonly string[]): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect(codes).toContain((error as NodeJS.ErrnoException).code) + return + } + throw new Error('expected flush to reject') +} + async function freshRoot(): Promise { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-')) dirs.push(dir) @@ -1343,7 +1354,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } }) appendClosedTurn(s) }, { inject: ['sessions'] })) - await expect(ctx2.sessions.flush(s)).rejects.toThrow(/EEXIST|ENOTDIR/) + await expectFlushCode(ctx2.sessions.flush(s), ['EEXIST', 'ENOTDIR']) await ctx2.fiber.dispose() }) diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 6c6c238e74..122d781b6c 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' @@ -108,14 +109,19 @@ describe('child env layering (through the subprocess seam)', () => { // 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]"'], + argv: [ + process.execPath, + '--input-type=module', + '--eval', + 'process.stdout.write(JSON.stringify([process.env.ACP_TEST_AMBIENT_SECRET_TOKEN ?? "absent", process.env.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]') + expect(running.collected.stdout!.readFrom(0).text).toBe('["absent","explicit"]') } finally { delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN } @@ -139,42 +145,50 @@ 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], + const node = (source: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({ + argv: [process.execPath, '--input-type=module', '--eval', source], cwd: process.cwd(), stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, graceMs: 200, }) + const expectHostTermination = (outcome: SubprocessOutcome, posixSignal: NodeJS.Signals): void => { + if (process.platform === 'win32') { + expect(outcome.signal).toBeNull() + expect(outcome.exitCode).not.toBe(0) + } else { + expect(outcome.signal).toBe(posixSignal) + } + } it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { - const child = bash('read -r line; exit 0') + const child = node('process.stdin.resume(); process.stdin.on("end", () => process.exit(0))') await disposeAcpChild(child, 5_000) 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') + it('tier 2: an EOF-deaf child reaches the host terminate outcome', async () => { + const child = node('setInterval(() => {}, 60_000)') await disposeAcpChild(child, 100) const outcome = await child.done - expect(outcome.signal).toBe('SIGTERM') + expectHostTermination(outcome, 'SIGTERM') }) - it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => { - const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore') + it('tier 3: a TERM-trapping child reaches the host force-termination outcome', async () => { + const child = node('process.on("SIGTERM", () => {}); process.stdout.write("armed\\n"); setInterval(() => {}, 60_000)', '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) const outcome = await child.done - expect(outcome.signal).toBe('SIGKILL') + expectHostTermination(outcome, 'SIGKILL') }) it('observes a spawn-level rejection and returns without a process to reap', async () => { const child = spawnSubprocess({ - argv: ['bash', '-c', 'true'], + argv: [process.execPath, '--input-type=module', '--eval', ''], cwd: '/nonexistent-dir-dsh-acp-ladder-test', stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, graceMs: 200, diff --git a/vitest.config.ts b/vitest.config.ts index 275054d5df..5abec6c65e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -70,6 +70,8 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // 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/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts', + 'packages/subagent/subagent-acp/tests/subagent-acp.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', From b1ccca71efda71ca55a7577dadeedc289c385563 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:26:38 +0800 Subject: [PATCH 07/57] fix(windows): stabilize native coverage watchers --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 + ...08-08-native-windows-pull-request-ci.zh.md | 2 + packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 17 +++--- .../tests/skill-local-watcher.spec.ts | 53 +++++++++++++------ .../subagent/tests/list-children.spec.ts | 2 +- 9 files changed, 58 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index ea8b24b08f..562ef5fa9e 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: c824111cbd3d5f4126b0a383c99d0600b3e33ecf -2026-08-08-native-windows-pull-request-ci.zh.md: 76e719d073669e4af551224ebf04bbf9284126a2 +2026-08-08-native-windows-pull-request-ci.md: a798aa906c2e22bceb021d1a95c081e9fa534d7c +2026-08-08-native-windows-pull-request-ci.zh.md: 58fed00e1f324a8d61af179421c7bc5703c25678 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index c824111cbd..a798aa906c 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -22,6 +22,8 @@ The coverage follow-up then exercised the serial heavy suites on the native host The blocking coverage gate exposed two more fixture contracts that had never run on the native lane. The JSONL materialization fault now asserts the structured filesystem error code because the Windows durable-directory implementation owns an `ENOTDIR` code without copying it into human prose. The ACP teardown ladder now uses Node children instead of assuming a POSIX shell and asserts Windows' force-termination outcome rather than POSIX signal names; POSIX still proves the `SIGTERM` and `SIGKILL` tiers. Those suites load native bindings or own real process trees, so the Windows thread pool runs them in the existing fork-isolated project while still merging their coverage into the same per-file threshold. +After the branch incorporated a newer `master`, the next native coverage run found the last uncatalogued watcher path and a stress-test budget. `skill-local` opened existing Chokidar roots with the configured spelling, so `%TEMP%` could still reach libuv through `C:\\Users\\RUNNER~1` while events used the long directory name; its root and ancestor modes now share the canonical watch-path contract, while discovery retains the configured path. The newly added 10,000-session descendant walk also exceeded Vitest's default timeout under Windows coverage instrumentation, so that unchanged stack-safety workload has an explicit twenty-second stress-test budget rather than a smaller depth or a platform skip. + The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 76e719d073..58fed00e1f 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -22,6 +22,8 @@ Status: implemented 这项阻断覆盖率门禁又暴露出两项从未在原生通道上运行过的 fixture 契约。JSONL 实体化故障场景现在断言结构化文件系统错误码,因为 Windows 的持久目录实现拥有 `ENOTDIR` 错误码,却不会将其复制进人类可读文本。ACP(Agent Client Protocol)拆卸阶梯现在使用 Node 子进程,不再假定 POSIX shell,并断言 Windows 的强制终止结果而非 POSIX 信号名称;POSIX 仍会证明 `SIGTERM` 与 `SIGKILL` 两级。这些套件会加载原生绑定或拥有真实进程树,因此 Windows 线程池会让它们在现有的 fork 隔离项目中运行,同时仍将这些套件的覆盖率汇入同一项逐文件阈值。 +分支纳入更新的 `master` 后,下一次原生覆盖率运行发现了最后一条未纳入统一契约的 watcher 路径和一项压力测试预算。`skill-local` 曾以配置时的路径拼写打开现有 Chokidar 根,因此 `%TEMP%` 仍可能以 `C:\\Users\\RUNNER~1` 进入 libuv,而事件使用长目录名;现在它的根模式与祖先模式共用规范化监听路径契约,发现过程仍保留配置路径。新增的 10,000 会话后代遍历在 Windows 覆盖率插桩下还会超过 Vitest 默认超时,因此该栈安全工作负载保持原有规模并获得显式的 20 秒压力测试预算,而不是缩小深度或按平台跳过。 + 下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture 通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index 4dcb4dbc68..f8fde193d4 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md -README.md: f85cc2e6fd0c32cb88f28a2914a03e22b3a20657 -README.zh.md: 9ee09938737237df9e97e517c1dd44b511b7f596 +README.md: 6d3d97ea34308ccc920d0b42c5a220dd7a9d6d73 +README.zh.md: 9948369774bc08a4ddbd4e518b53134ddd7c6882 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index f85cc2e6fd..6d3d97ea34 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -44,7 +44,7 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read ## Catalog Change Detection -Existing skill roots are watched with Chokidar. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation. +Existing skill roots are watched with Chokidar. Before opening a native watcher, the provider realpaths the existing root or ancestor and restores the next missing segment; discovery and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation. A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 9ee0993873..9948369774 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -44,7 +44,7 @@ ## 目录变更检测 -现有 skill 根由 Chokidar 监视。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。 +现有 skill 根由 Chokidar 监视。打开原生 watcher 前,提供方会对现有根或祖先执行 realpath 解析,并拼回下一个缺失路径段;发现与诊断仍保留配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。 不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 71ed3be21d..f06538706a 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -19,7 +19,7 @@ import z from 'schemastery' import type Schema from 'schemastery' import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { BUNDLED_SKILL_RANK, isSkillName, @@ -525,7 +525,7 @@ class SkillWatchManager { readiness.resolve(undefined) }) for (const event of ['add', 'addDir', 'change', 'unlink', 'unlinkDir'] as const) { - watcher.on(event, (path) => { this.handleWatchEvent(state, event, path) }) + watcher.on(event, (path) => { this.handleWatchEvent(state, mode, event, path) }) } try { await readiness.promise @@ -540,12 +540,14 @@ class SkillWatchManager { private handleWatchEvent( state: RootWatchState, + mode: Extract, event: SkillWatchEvent, path: string, ): void { - if (this.closing || !isRelevantWatchEvent(state.root, event, resolve(path))) return + const target = resolve(path) + if (this.closing || !isRelevantWatchEvent({ ...state.root, path: mode.anchor }, event, target)) return this.queueInvalidation() - if (resolve(path) === state.root.path && event === 'unlinkDir') { + if (target === mode.anchor && event === 'unlinkDir') { state.unhealthy = true this.scheduleRewatch(state) } @@ -625,11 +627,12 @@ async function resolveRootWatchMode(root: string): Promise { try { const info = await stat(candidate) if (info.isDirectory()) { - if (candidate === root) return { kind: 'root', anchor: root } + const anchor = await canonicalizeWatchPath(candidate) + if (candidate === root) return { kind: 'root', anchor } const firstSegment = relative(candidate, root).split(sep)[0] /* v8 ignore next -- candidate is a strict ancestor of root. */ - if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor: root } - return { kind: 'ancestor', anchor: candidate, nextPath: join(candidate, firstSegment) } + if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor } + return { kind: 'ancestor', anchor, nextPath: join(anchor, firstSegment) } } } catch (error) { /* v8 ignore next -- Non-absence stat failures are platform/permission-specific and propagate as incomplete discovery. */ diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index bebf4378cf..5f949128f3 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import type { Stats } from 'node:fs' -import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +11,7 @@ interface FakeWatcherControl { emitter: EventEmitter closeCalls: number options: Record + path: string } interface FakeWatchFileControl { @@ -63,9 +64,9 @@ vi.mock('node:fs/promises', async (importOriginal) => { vi.mock('chokidar', () => ({ default: { - watch(_path: unknown, options: Record) { + watch(path: unknown, options: Record) { const emitter = new EventEmitter() as EventEmitter & { close(): Promise } - const control: FakeWatcherControl = { emitter, closeCalls: 0, options } + const control: FakeWatcherControl = { emitter, closeCalls: 0, options, path: String(path) } emitter.close = async () => { control.closeCalls += 1 if (watcherHarness.closeErrors > 0) { @@ -114,6 +115,26 @@ beforeEach(() => { }) describe('skill-local watcher failures', () => { + it('canonicalizes an existing root before opening its native watcher', async () => { + const target = await tempDir('skill-watch-canonical-target') + const aliasParent = await tempDir('skill-watch-canonical-alias') + const alias = join(aliasParent, 'alias') + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const root = join(alias, '.dsh/skills') + await writeSkill(root, 'canonical-skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(alias, '.dsh'), + agentsHome: join(alias, '.agents'), + watch: true, + }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['canonical-skill']) + expect(watcherHarness.watchers[0]?.path).toBe(await realpath(root)) + await fiber.dispose() + }) + it('ignores missing-path probes until the observed path actually changes', async () => { const home = await tempDir('skill-watch-missing-stable') const ctx = new Context() @@ -205,24 +226,22 @@ describe('skill-local watcher failures', () => { const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected a root watcher') - first.emitter.emit('change', join(root, 'notes.txt')) + first.emitter.emit('change', join(first.path, 'notes.txt')) first.emitter.emit('change', join(home, 'outside.md')) - first.emitter.emit('change', join(root, 'watched-skill/references.md')) - first.emitter.emit('change', join(root, '.system/SKILL.md')) + first.emitter.emit('change', join(first.path, 'watched-skill/references.md')) + first.emitter.emit('change', join(first.path, '.system/SKILL.md')) await settle() expect(invalidations).toBe(0) - first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) - first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md')) + first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md')) await settle() expect(invalidations).toBe(1) watcherHarness.closeErrors = 1 watcherHarness.startupErrors.push(new Error('runtime rewatch failed')) first.emitter.emit('error', new Error('runtime watch failed')) - await settle() - await settle() - expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2) + await vi.waitFor(() => { expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2) }) expect(invalidations).toBeGreaterThanOrEqual(2) expect(await ctx.skills.snapshot()).toMatchObject({ skills: [{ name: 'watched-skill' }], @@ -230,7 +249,7 @@ describe('skill-local watcher failures', () => { }) await fiber.dispose() - first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md')) first.emitter.emit('error', new Error('late error')) await settle() }) @@ -254,9 +273,11 @@ describe('skill-local watcher failures', () => { if (original === undefined) throw new Error('expected a root watcher') await rm(root, { recursive: true }) - original.emitter.emit('unlinkDir', root) + original.emitter.emit('unlinkDir', original.path) await vi.waitFor(() => { expect(original.closeCalls).toBeGreaterThan(0) }) - expect(watcherHarness.watchFiles.some(control => control.path === root)).toBe(true) + await vi.waitFor(() => { + expect(watcherHarness.watchFiles.some(control => control.path === original.path)).toBe(true) + }) await fiber.dispose() }) @@ -280,11 +301,11 @@ describe('skill-local watcher failures', () => { if (original === undefined) throw new Error('expected a root watcher') await rm(root, { recursive: true }) - original.emitter.emit('unlink', join(root, 'old-skill/SKILL.md')) + original.emitter.emit('unlink', join(original.path, 'old-skill/SKILL.md')) await settle() expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) - const missingRoot = watcherHarness.watchFiles.find(control => control.path === root) + const missingRoot = watcherHarness.watchFiles.find(control => control.path === original.path) expect(missingRoot).toBeDefined() await writeSkill(root, 'recreated-skill') missingRoot!.listener({} as Stats, {} as Stats) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index af37aba5f4..b15d1f321f 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -1055,7 +1055,7 @@ describe('SubagentService.listDescendants', () => { }) - it('walks a deeply nested ordinary-session chain without consuming the call stack', async () => { + it('walks a deeply nested ordinary-session chain without consuming the call stack', { timeout: 20_000 }, async () => { const { ctx, parent } = await setup([]) const depth = 10_000 let parentId = parent.id From 0aeca9c3cef39da0758b9c1af283c6205b853a5d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:19:45 +0800 Subject: [PATCH 08/57] fix(windows): make native coverage graph portable --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 ++-- ...26-08-08-native-windows-pull-request-ci.md | 4 ++++ ...08-08-native-windows-pull-request-ci.zh.md | 4 ++++ .../bash/pwsh-local/tests/executor.spec.ts | 12 ++++++++--- .../tests/markdown-incremental.spec.tsx | 2 +- .../tests/workspace-context.spec.ts | 2 +- .../credentials-local/src/index.ts | 13 +++++++++--- .../credentials-local/tests/local.spec.ts | 4 ++-- .../tests/review-fixes.spec.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 5 +++-- .../tests/api-proxy-workspace.spec.ts | 2 +- .../sdk/sdk-client/tests/sdk-client.spec.ts | 9 +++++++-- .../session-persistence-jsonl/src/win32.ts | 4 ++-- .../settings-local/tests/concurrency.spec.ts | 2 +- .../settings-local/tests/local.spec.ts | 20 ++++++++++--------- packages/skill/skill-local/README.i18n.yaml | 4 ++-- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 4 +++- .../tests/skill-local-watcher.spec.ts | 1 + .../storage-json/tests/json-backend.spec.ts | 14 ++++++++----- .../tests/real-product.spec.ts | 2 +- .../subagent-codex/tests/real-product.spec.ts | 9 ++++++--- .../subagent/tests/list-children.spec.ts | 2 +- .../tests/user-id.spec.ts | 8 +++----- packages/typert/loader/tests/loader.spec.ts | 6 +++--- .../app-boot/tests/repository-cache.spec.ts | 7 +++++-- .../atomic-write/tests/atomic-write.spec.ts | 4 ++-- packages/util/paths/src/index.ts | 9 +++++++-- scripts/client-tsconfig.spec.ts | 4 +++- 30 files changed, 107 insertions(+), 60 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 562ef5fa9e..698ddd0047 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: a798aa906c2e22bceb021d1a95c081e9fa534d7c -2026-08-08-native-windows-pull-request-ci.zh.md: 58fed00e1f324a8d61af179421c7bc5703c25678 +2026-08-08-native-windows-pull-request-ci.md: 0769a8dc582f48c1baddbb177e2e9921b809e391 +2026-08-08-native-windows-pull-request-ci.zh.md: 7431d917adf2bfd8cc0cf31e9c249b4f28e6caf0 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index a798aa906c..0769a8dc58 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -26,6 +26,10 @@ After the branch incorporated a newer `master`, the next native coverage run fou The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. +Running the complete instrumented graph instead of the earlier reduced inventory exposed the remaining cross-platform fixture contracts. Windows path identity now accounts for 8.3 aliases, native separators, Git checkout line endings, cross-drive relative paths, and file URLs before constructing loader symlinks. The JSONL durable-directory helper applies the extended-length namespace to probes and staging creation, real product tests invoke portable executable entries and tolerate bounded Windows handle release, and stress tests retain their workloads with explicit coverage budgets. A credential document or watch path whose deepest existing ancestor is a file now fails `ENOTDIR` on every host, while `skill-local` uses effect-owned persistent Chokidar handles so asynchronous libuv errors are contained instead of escaping the test process. + +POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. No supported product source is excluded from Windows coverage to accommodate these differences. + Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 58fed00e1f..7431d917ad 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -26,6 +26,10 @@ Status: implemented 下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture 通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 +运行完整的覆盖率插桩图而非此前缩减的清单后,剩余的跨平台 fixture 契约也显现出来。Windows 路径标识现在会在比较或构造 loader 符号链接前处理 8.3 别名、原生分隔符、Git 检出换行、跨盘符相对路径与文件 URL。JSONL 持久目录辅助函数会对探测与临时目录创建应用扩展长度命名空间;真实产品测试会调用可移植的可执行入口,并以有界重试容纳 Windows 句柄释放;压力测试则保留原工作负载并获得显式的覆盖率预算。如果凭据文档或监听路径最深的现有祖先是文件,所有宿主现在都会返回 `ENOTDIR`;`skill-local` 同时改用由 effect 拥有的持久 Chokidar 句柄,使异步 libuv 错误得到收束,不再逸出测试进程。 + +POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。没有任何受支持的产品源码为适应这些差异而从 Windows 覆盖率中排除。 + 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 ## 曾考虑的替代方案 diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index c7da16c44c..db15f009bd 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -9,7 +9,7 @@ * writes CRLF on Windows, so exact text assertions normalize line endings. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -33,7 +33,9 @@ const lf = (text: string): string => text.replace(/\r\n/g, '\n') /** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */ function samePath(actual: string, expected: string): boolean { - const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value) + const norm = (value: string) => ( + process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : value + ) return norm(actual) === norm(expected) } @@ -72,7 +74,11 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => it('falls through an empty configured path to platform resolution', () => { // SystemRoot points at a non-existent tree so the Windows PowerShell 5.1 // fallback candidate cannot exist either. - expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh') + expect(resolvePwshPath('', { + PATH: 'P:\\Store', + ProgramFiles: 'P:\\no-program-files', + SystemRoot: 'S:\\no-windows', + }, 'win32')).toBe('pwsh') }) it('returns pwsh on non-Windows platforms regardless of the environment', () => { diff --git a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx index b36ce8c674..4397aea124 100644 --- a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx @@ -58,7 +58,7 @@ const STREAM_DOC = [ describe('incremental streaming rendering', () => { for (const chunkSize of [1, 3, 7, 16]) { - it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => { + it(`matches a fresh render at every prefix (chunk=${chunkSize})`, { timeout: 20_000 }, () => { const live = render() for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) { const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 0af12596cc..ee8a9597f6 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -4122,7 +4122,7 @@ describe('dynamic nested workspace context injection', () => { await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('workspace instruction refresh failed: %o', failure) - }) + }, { timeout: 10_000 }) } finally { await ctx.fiber.dispose() } diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 67b598d7e0..f6aba278cc 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -97,16 +97,23 @@ const GROUP_OTHER_BITS = 0o077 * here — so the check is skipped rather than faked, and the file's protection * there is whatever the create and replace APIs express. * @param filename - absolute path of the document. - * @throws when the file exists with group or other permission bits set. + * @throws when the path hierarchy is invalid or the file exists with group or other permission bits set. */ async function assertOwnerOnly(filename: string): Promise { - /* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */ - if (process.platform === 'win32') return + /* v8 ignore start -- native Windows coverage exercises this path; POSIX covers mode enforcement */ + if (process.platform === 'win32') { + // Windows has no POSIX mode bits, but it reports a file-as-parent as + // ordinary ENOENT; canonicalization preserves the invalid-path failure. + await canonicalizeWatchPath(filename) + return + } + /* v8 ignore stop */ let mode: number try { mode = (await stat(filename)).mode } catch (error) { if (!isENOENT(error)) throw error + await canonicalizeWatchPath(filename) return } const offending = mode & GROUP_OTHER_BITS diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 43e42cd53f..7130d85db4 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -168,7 +168,7 @@ describe('layer ladder', () => { expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) }) - it('refuses a document other OS users can read', async () => { + it.skipIf(process.platform === 'win32')('refuses a document other OS users can read', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 }) @@ -274,7 +274,7 @@ describe('document writes', () => { const seen = updates(ctx) await ctx.credentials.set(KEY, 'sk-fresh') expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n') - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) expect(seen).toEqual([KEY]) }) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts index fcec7fceb9..b7839cf538 100644 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -78,7 +78,7 @@ describe('read-modify-write', () => { const home = join(dir, 'home') const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false }) await ctx.credentials.set(ALPHA, 'one') - expect((await stat(home)).mode & 0o777).toBe(0o700) + if (process.platform !== 'win32') expect((await stat(home)).mode & 0o777).toBe(0o700) }) }) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 3224487b60..b37ec9e66e 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' @@ -491,7 +491,8 @@ describe('dsh-agent-spine-demo bundle', () => { callId: event.data.message.source.callId, isError: result.isError, text: result.content.map(block => block.type === 'text' ? block.text : '').join('\n') - .replaceAll(root, '{{cwd}}'), + .replaceAll(root, '{{cwd}}') + .replaceAll(sep, '/'), }] } return [] diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index aa560bdf58..23e2bf5f11 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( - workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + workspaceRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { diff --git a/packages/sdk/sdk-client/tests/sdk-client.spec.ts b/packages/sdk/sdk-client/tests/sdk-client.spec.ts index 8b76af755e..a710a44c1d 100644 --- a/packages/sdk/sdk-client/tests/sdk-client.spec.ts +++ b/packages/sdk/sdk-client/tests/sdk-client.spec.ts @@ -173,7 +173,8 @@ describe('DeepSeekHarness', () => { it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => { // vitest workers forbid chdir, so derive a RELATIVE path from the real // process cwd to a temp worker dir; resolution is lexical either way. - const dir = await tempDir('sdk-client-relcwd-') + const dir = await mkdtemp(join(process.cwd(), '.dsh-sdk-client-relcwd-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) const recordFile = join(dir, 'init.jsonl') const inner = join(dir, 'worker') await mkdir(inner) @@ -332,7 +333,11 @@ describe('HarnessClient', () => { )) await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) await client.close() - expect((await stat(sigtermFile)).isFile()).toBe(true) + if (process.platform === 'win32') { + await expect(stat(sigtermFile)).rejects.toMatchObject({ code: 'ENOENT' }) + } else { + expect((await stat(sigtermFile)).isFile()).toBe(true) + } }) it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => { diff --git a/packages/session-persistence/session-persistence-jsonl/src/win32.ts b/packages/session-persistence/session-persistence-jsonl/src/win32.ts index 5b2b034574..9688eb6bcd 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/win32.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/win32.ts @@ -91,7 +91,7 @@ function isEEXIST(error: unknown): boolean { async function assertDirectory(path: string): Promise { try { - const info = await stat(path) + const info = await stat(toNamespacedPath(path)) if (info.isDirectory()) return true const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException error.code = 'ENOTDIR' @@ -141,7 +141,7 @@ export async function ensureDurableDirectoryWin32(target: string): Promise async function createLeafDirectoryWin32(parent: string, target: string): Promise { // Keep the staging component independent of the target basename so a legal // 255-byte target component does not make mkdtemp's sibling name too long. - const staging = await mkdtemp(join(parent, '.dsh-mkdir-')) + const staging = await mkdtemp(toNamespacedPath(join(parent, '.dsh-mkdir-'))) try { await publishNewFileWin32(staging, target) } catch (error) { diff --git a/packages/settings/settings-local/tests/concurrency.spec.ts b/packages/settings/settings-local/tests/concurrency.spec.ts index 1c6996434a..5df2179219 100644 --- a/packages/settings/settings-local/tests/concurrency.spec.ts +++ b/packages/settings/settings-local/tests/concurrency.spec.ts @@ -86,7 +86,7 @@ describe('writer lock', () => { expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n') }, 10_000) - it('surfaces a non-contention lock failure as the write error', async () => { + it.skipIf(process.platform === 'win32')('surfaces a non-contention lock failure as the write error', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') const ctx = await boot({ path, watch: false }) diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 7df16b48d3..bcf9bfc276 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' @@ -67,7 +67,7 @@ describe('boot and reads', () => { await expect(ctx.settings.prepareDocument()).resolves.toBe(path) expect(await readFile(path, 'utf8')).toBe('') - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) }) @@ -128,7 +128,7 @@ describe('boot and reads', () => { expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) }) - it('fails loud at boot when the document exists but is unreadable', async () => { + it.skipIf(process.platform === 'win32')('fails loud at boot when the document exists but is unreadable', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') await writeFile(path, 'ui-theme:\n theme: light\n') @@ -168,7 +168,7 @@ describe('persist', () => { const written = await readFile(path, 'utf8') expect(written).toContain('theme: light') - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) // Atomic replace leaves no temp artifact behind. expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) }) @@ -203,7 +203,7 @@ describe('persist', () => { expect(await readFile(victim, 'utf8')).toBe('precious') expect((await lstat(path)).isSymbolicLink()).toBe(false) - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(await readFile(path, 'utf8')).toContain('theme: light') }) @@ -337,16 +337,18 @@ describe('persist', () => { expect(written).toEqual({ 'ui-theme': { theme: 'light' } }) }) - it('rejects and leaves no temp residue when the directory turns unwritable', async () => { + it('rejects and recovers when the document path becomes a directory', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') + const backup = join(dir, 'settings.committed.yaml') await writeFile(path, 'ui-theme:\n theme: light\n') const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) - await chmod(dir, 0o500) - cleanups.push(() => chmod(dir, 0o700)) + await rename(path, backup) + await mkdir(path) await expect(scope.update({ theme: 'dark' })).rejects.toThrow() - await chmod(dir, 0o700) + await rm(path, { recursive: true }) + await rename(backup, path) expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) expect(scope.get().theme).toBe('light') // The failed persist must not poison the document write chain. diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index f8fde193d4..73bfe42ec1 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md -README.md: 6d3d97ea34308ccc920d0b42c5a220dd7a9d6d73 -README.zh.md: 9948369774bc08a4ddbd4e518b53134ddd7c6882 +README.md: dc2e97f89349a85ce548e5f6f1b01408eb32a293 +README.zh.md: 0c17d9fb77af3df2071613c2c6b8fc15086581a7 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 6d3d97ea34..dc2e97f893 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -48,7 +48,7 @@ Existing skill roots are watched with Chokidar. Before opening a native watcher, A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery. -The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks. +The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Existing-root watchers remain persistent until effect teardown so Chokidar owns asynchronous native error events; startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks. ## Skill Format diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 9948369774..0c17d9fb77 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -48,7 +48,7 @@ 不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。 -如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。 +如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。现有根的 watcher 会保持持久状态直至 effect 释放,使 Chokidar 能够接管异步原生错误事件;watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。 ## skill 格式 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index f06538706a..5b11e71f26 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -487,7 +487,9 @@ class SkillWatchManager { private async openRootWatcher(state: RootWatchState, mode: Extract): Promise { const watcher = chokidar.watch(mode.anchor, { - persistent: false, + // Chokidar owns late native fs.watch errors only for persistent watchers; + // this provider's effect explicitly closes every handle at teardown. + persistent: true, ignoreInitial: true, depth: 1, followSymlinks: this.config.followSymlinks, diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 5f949128f3..8b2a62e842 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -132,6 +132,7 @@ describe('skill-local watcher failures', () => { expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['canonical-skill']) expect(watcherHarness.watchers[0]?.path).toBe(await realpath(root)) + expect(watcherHarness.watchers[0]?.options.persistent).toBe(true) await fiber.dispose() }) diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index 2f2fff90fc..b8d37eabb9 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' @@ -88,19 +88,23 @@ describe('json backend specifics', () => { const unit = await backend.kv.open(descriptor) await unit.putRecord('t', 'k', { v: 'committed' }) await unit.setGlobal({ g: 'committed' }) - // Make every publish fail: revoke write permission on the root. - await chmod(root, 0o500) + const path = join(root, 'shape.json') + const backup = join(root, 'shape.committed.json') + // A directory at the publish target rejects atomic replacement on every host. + await rename(path, backup) + await mkdir(path) await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow() await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow() await expect(unit.deleteRecord('t', 'k')).rejects.toThrow() await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow() - await chmod(root, 0o700) + await rm(path, { recursive: true }) + await rename(backup, path) const snapshot = await unit.loadAll() expect(snapshot.tables['t']).toEqual({ k: { v: 'committed' } }) expect(snapshot.global).toEqual({ g: 'committed' }) // The next successful publish must not carry rejected writes to disk. await unit.putRecord('t', 'k3', { v: 'later' }) - const text = await readFile(join(root, 'shape.json'), 'utf8') + const text = await readFile(path, 'utf8') expect(text).not.toContain('rejected') await backend.close() }) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index f76b4038f6..240cfe6b0a 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -90,7 +90,7 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) } observedSdkMessages.length = 0 }) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 5f73adaf7e..badab946e6 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -27,6 +27,7 @@ import { const execFileAsync = promisify(execFile) const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexEntry = join(packageRoot, 'node_modules', '@openai', 'codex', 'bin', 'codex.js') const codexPackage = JSON.parse(readFileSync( join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), 'utf8', @@ -40,7 +41,7 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) } }) @@ -147,7 +148,7 @@ describe('real @openai/codex 0.146.0 product', () => { { kind: 'complete', text: sentinel }, ]) expect(codexPackage.version).toBe('0.146.0') - const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + const version = await execFileAsync(process.execPath, [codexEntry, '--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('codex-cli 0.146.0') @@ -178,7 +179,9 @@ describe('real @openai/codex 0.146.0 product', () => { kind: 'functionCall', name: 'exec_command', arguments: { - cmd: 'touch approval-side-effect', + cmd: process.platform === 'win32' + ? 'cmd /c type nul > approval-side-effect' + : 'touch approval-side-effect', sandbox_permissions: 'require_escalated', justification: 'exercise the unattended approval boundary', }, diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index b15d1f321f..fa2db5fd04 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -1078,7 +1078,7 @@ describe('SubagentService.listDescendants', () => { }]) }) - it('discovers continuable descendants below ordinary and one-shot intermediates', async () => { + it('discovers continuable descendants below ordinary and one-shot intermediates', { timeout: 20_000 }, async () => { const { ctx, parent } = await setup([textResponse('one shot')]) // An ordinary fork has no descriptor: omitted itself, subtree still walked. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) diff --git a/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts b/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts index f4d9505546..600f0927a6 100644 --- a/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -17,7 +17,6 @@ function tempHome(): string { afterEach(() => { for (const dir of dirs.splice(0)) { - chmodSync(dir, 0o700) rmSync(dir, { recursive: true, force: true }) } }) @@ -69,11 +68,10 @@ describe('getOrCreateAnonymousUserId', () => { expect(id).toBe(winner) }) - it('returns a usable id when the home is unwritable, without persisting', () => { + it('returns a usable id when the home cannot contain files, without persisting', () => { const home = tempHome() const blocked = join(home, 'blocked') - mkdirSync(blocked) - chmodSync(blocked, 0o500) + writeFileSync(blocked, 'occupied\n') const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: blocked } }) expect(id).toMatch(UUID) expect(existsSync(join(blocked, USER_ID_FILE_NAME))).toBe(false) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 750cc92e57..af5d451351 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' @@ -114,9 +114,9 @@ async function boot(): Promise { async function linkZod(base: string): Promise { const { symlink } = await import('node:fs/promises') const target = join(base, 'node_modules', 'zod') - const source = new URL(import.meta.resolve('zod/package.json')).pathname.replace(/\/package\.json$/, '') + const source = fileURLToPath(new URL('.', import.meta.resolve('zod/package.json'))) await mkdir(join(base, 'node_modules'), { recursive: true }) - await symlink(source, target, 'dir') + await symlink(source, target, process.platform === 'win32' ? 'junction' : 'dir') } function mountTypertLoader(ctx: Context, config: typertLoader.Config = {}): ReturnType { diff --git a/packages/ui/app-boot/tests/repository-cache.spec.ts b/packages/ui/app-boot/tests/repository-cache.spec.ts index b7d80470b8..1ba73cf1df 100644 --- a/packages/ui/app-boot/tests/repository-cache.spec.ts +++ b/packages/ui/app-boot/tests/repository-cache.spec.ts @@ -11,6 +11,9 @@ import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@ const execFileAsync = promisify(execFile) const roots: string[] = [] +/** Normalize Git's platform checkout line endings for source-content assertions. */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + async function temporaryRoot(name: string): Promise { const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`)) roots.push(root) @@ -151,8 +154,8 @@ describe('RepositoryCache', () => { const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') - await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')) - .resolves.toBe('repository skill source\n') + expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))) + .toBe('repository skill source\n') await expect(readFile(join(installed, 'package.json'), 'utf8')) .resolves.toContain('repository-plugin-fixture') }) diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index 2bc9d3ab6a..a917fd13d9 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -14,7 +14,7 @@ describe('writeFileAtomic', () => { const target = join(dir, 'nested', 'deep', 'doc.yaml') await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) expect(await readFile(target, 'utf8')).toBe('a: 1\n') - expect((await stat(target)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600) }) it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { @@ -23,7 +23,7 @@ describe('writeFileAtomic', () => { await writeFile(target, 'old', { mode: 0o644 }) await writeFileAtomic(target, 'new', { mode: 0o600 }) expect(await readFile(target, 'utf8')).toBe('new') - expect((await stat(target)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600) }) it('replaces a symlinked target itself without writing through to the referent', async () => { diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index 591158d7c0..dc8e3677d0 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-paths */ -import { realpath } from 'node:fs/promises' +import { opendir, realpath } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' @@ -32,7 +32,12 @@ export async function canonicalizeWatchPath(path: string): Promise { const missing: string[] = [] while (true) { try { - return join(await realpath(current), ...missing.reverse()) + const canonical = await realpath(current) + if (missing.length > 0) { + const directory = await opendir(canonical) + await directory.close() + } + return join(canonical, ...missing.reverse()) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error const parent = dirname(current) diff --git a/scripts/client-tsconfig.spec.ts b/scripts/client-tsconfig.spec.ts index ed445aa80f..8e82e52353 100644 --- a/scripts/client-tsconfig.spec.ts +++ b/scripts/client-tsconfig.spec.ts @@ -1,7 +1,7 @@ /** Regression coverage for source declarations owned by the client test aggregate. */ import { existsSync, readdirSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' import { describe, expect, it } from 'vitest' @@ -14,6 +14,7 @@ function clientCssDeclarations(): string[] { .filter(entry => entry.isDirectory()) .map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts')) .filter(existsSync) + .map(file => file.replaceAll(sep, '/')) .sort() } @@ -26,6 +27,7 @@ describe('client TypeScript aggregate', () => { } const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root) const loaded = parsed.fileNames + .map(file => file.replaceAll(sep, '/')) .filter(file => file.endsWith('/src/css-modules.d.ts')) .sort() expect(loaded).toEqual(clientCssDeclarations()) From 438becccd06221e3f807effe2dbd510aefaf7ede Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:26:00 +0800 Subject: [PATCH 09/57] test(windows): cover default PowerShell roots --- packages/bash/pwsh-local/tests/executor.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index db15f009bd..542b15ae4a 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -86,6 +86,13 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh') }) + it('uses stable Windows roots when the environment omits both overrides', () => { + expect(candidatePwshPaths({})).toEqual([ + join('C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join('C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]) + }) + it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => { const candidates = candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', From 0aefa18636a8d21fd8c42af2e35c834c78cfb692 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:56:37 +0800 Subject: [PATCH 10/57] fix(windows): preserve native drive-root probes --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 2 ++ ...08-08-native-windows-pull-request-ci.zh.md | 2 ++ .../session-persistence-jsonl/src/win32.ts | 5 ++- .../tests/win32.spec.ts | 32 +++++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 698ddd0047..b69ebc94ae 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 0769a8dc582f48c1baddbb177e2e9921b809e391 -2026-08-08-native-windows-pull-request-ci.zh.md: 7431d917adf2bfd8cc0cf31e9c249b4f28e6caf0 +2026-08-08-native-windows-pull-request-ci.md: a540a73e5979c436fb8a085516554f5ae7249939 +2026-08-08-native-windows-pull-request-ci.zh.md: 34ca87f21bcd5b72b2ecbc43e66043caee299e48 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 0769a8dc58..a540a73e59 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -28,6 +28,8 @@ The next exact-head run exposed one remaining observational built-bin failure: i Running the complete instrumented graph instead of the earlier reduced inventory exposed the remaining cross-platform fixture contracts. Windows path identity now accounts for 8.3 aliases, native separators, Git checkout line endings, cross-drive relative paths, and file URLs before constructing loader symlinks. The JSONL durable-directory helper applies the extended-length namespace to probes and staging creation, real product tests invoke portable executable entries and tolerate bounded Windows handle release, and stress tests retain their workloads with explicit coverage budgets. A credential document or watch path whose deepest existing ancestor is a file now fails `ENOTDIR` on every host, while `skill-local` uses effect-owned persistent Chokidar handles so asynchronous libuv errors are contained instead of escaping the test process. +The final root-probe failure came from applying the extended-length namespace to the drive root as well as long descendants. Node rejected the bare root probe as `EISDIR`, cascading through every JSONL fixture and assembled binary that materialized a session. The Windows durable-directory helper now probes the short drive root in its native spelling and namespaces only descendants; an injected Win32-path unit test locks both spellings while native coverage exercises the real filesystem. + POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. No supported product source is excluded from Windows coverage to accommodate these differences. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 7431d917ad..34ca87f21b 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -28,6 +28,8 @@ Status: implemented 运行完整的覆盖率插桩图而非此前缩减的清单后,剩余的跨平台 fixture 契约也显现出来。Windows 路径标识现在会在比较或构造 loader 符号链接前处理 8.3 别名、原生分隔符、Git 检出换行、跨盘符相对路径与文件 URL。JSONL 持久目录辅助函数会对探测与临时目录创建应用扩展长度命名空间;真实产品测试会调用可移植的可执行入口,并以有界重试容纳 Windows 句柄释放;压力测试则保留原工作负载并获得显式的覆盖率预算。如果凭据文档或监听路径最深的现有祖先是文件,所有宿主现在都会返回 `ENOTDIR`;`skill-local` 同时改用由 effect 拥有的持久 Chokidar 句柄,使异步 libuv 错误得到收束,不再逸出测试进程。 +最后一个根路径探测失败源于扩展长度命名空间既应用到长后代路径,也应用到了驱动器根目录。Node 将裸根目录探测拒绝为 `EISDIR`,从而连锁影响所有会物化会话的 JSONL fixture 和组装后二进制。Windows 持久目录辅助函数现在以原生写法探测本来就很短的驱动器根目录,仅对后代路径添加命名空间;注入 Win32 路径语义的单元测试固定两种写法,原生覆盖率则验证真实文件系统。 + POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。没有任何受支持的产品源码为适应这些差异而从 Windows 覆盖率中排除。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 diff --git a/packages/session-persistence/session-persistence-jsonl/src/win32.ts b/packages/session-persistence/session-persistence-jsonl/src/win32.ts index 9688eb6bcd..ccc86025a8 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/win32.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/win32.ts @@ -91,7 +91,10 @@ function isEEXIST(error: unknown): boolean { async function assertDirectory(path: string): Promise { try { - const info = await stat(toNamespacedPath(path)) + // A bare drive root is already short, and Node rejects its extended-length + // spelling as EISDIR. Descendants retain the namespace for long-path probes. + const probe = path === parse(path).root ? path : toNamespacedPath(path) + const info = await stat(probe) if (info.isDirectory()) return true const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException error.code = 'ENOTDIR' diff --git a/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts index 647ff8b292..3b6cfc4f78 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts @@ -92,11 +92,43 @@ async function importWithFilesystemMove(): Promise { vi.doUnmock('koffi') + vi.doUnmock('node:fs/promises') + vi.doUnmock('node:path') vi.resetModules() for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) describe('Windows durable namespace helpers', () => { + it('keeps drive-root probes native while namespacing descendants', async () => { + const probes: string[] = [] + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: async (path: string) => { + probes.push(path) + return { isDirectory: () => true } + }, + } + }) + vi.doMock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + join: (...paths: string[]) => actual.win32.join(...paths), + parse: (path: string) => actual.win32.parse(path), + resolve: (...paths: string[]) => actual.win32.resolve(...paths), + toNamespacedPath: (path: string) => actual.win32.toNamespacedPath(path), + } + }) + const { ensureDurableDirectoryWin32 } = await import('../src/win32.ts') + + await ensureDurableDirectoryWin32('C:\\existing') + + expect(probes).toEqual(['C:\\', '\\\\?\\C:\\existing']) + }) + it('publishes a new file with write-through MoveFileExW semantics', async () => { const { publishNewFileWin32 } = await importWithFilesystemMove() const root = await tempRoot() From 3a518021f6f16bc5e4e7d5591ba4c6b9ca5f6796 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:56:00 +0800 Subject: [PATCH 11/57] fix(windows): drain native coverage lifecycles --- .../ui-conversation/tests/queue-dock.spec.tsx | 6 ++- .../ui-primitives/tests/markdown.spec.tsx | 2 +- .../tests/workspace-context.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 21 +++++++++ .../tests/real-product.spec.ts | 4 +- vendor/README.md | 1 + vendor/include/src/index.ts | 44 +++++++++++++++++-- 7 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 826abaf846..8de5a65cab 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -164,7 +164,11 @@ describe('QueueDock', () => { expect(view.getByText('remove me')).toBeTruthy() expect(view.getByText('second')).toBeTruthy() - act(() => { finishUpdate?.() }) + expect(updateQueue).toHaveBeenCalledOnce() + await act(async () => { + finishUpdate?.() + await Promise.resolve() + }) await waitFor(() => { expect(header).toHaveProperty('disabled', false) expect(header.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 89ffd32346..6b35712bd2 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -445,7 +445,7 @@ describe('MarkdownText', () => { const startedAt = performance.now() const { container } = render() - expect(performance.now() - startedAt).toBeLessThan(1_000) + expect(performance.now() - startedAt).toBeLessThan(3_000) expect(container.querySelector('.katex')).toBeNull() }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index ee8a9597f6..153a759cc7 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -4099,7 +4099,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('warns when an asynchronous file-result projection fails', async () => { + it('warns when an asynchronous file-result projection fails', { timeout: 20_000 }, async () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 7922592d01..88f16687fa 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -22,6 +22,23 @@ import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' import * as DirectoryPickerAuto from '../src/index.ts' +const renameControl = vi.hoisted(() => ({ attempts: 0, remainingFailures: 0 })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async rename(oldPath: string, newPath: string): Promise { + renameControl.attempts++ + if (renameControl.remainingFailures > 0) { + renameControl.remainingFailures-- + throw Object.assign(new Error(`transient rename failure for ${newPath}`), { code: 'EPERM' }) + } + await actual.rename(oldPath, newPath) + }, + } +}) + const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto' const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native' const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse' @@ -41,6 +58,8 @@ afterEach(async () => { } root = undefined fakeBin = undefined + renameControl.attempts = 0 + renameControl.remainingFailures = 0 }) /** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ @@ -163,9 +182,11 @@ describe('real Loader composition', () => { const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! await ctx.loader.remove(backendEntry.id) const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! + renameControl.remainingFailures = 1 await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) // Same self-dispose persistence as above: let the write land before teardown. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') + expect(renameControl.attempts).toBe(2) }) }) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 240cfe6b0a..00e56c23b8 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -3,9 +3,9 @@ import { mkdirSync, mkdtempSync, readFileSync, - rmSync, writeFileSync, } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -90,7 +90,7 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } observedSdkMessages.length = 0 }) diff --git a/vendor/README.md b/vendor/README.md index 0a434605ae..9e288d61ce 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -44,6 +44,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. 14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. +15. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, contained asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with an injected transient rename failure. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 5eece997c9..59d34c24a3 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -2,6 +2,7 @@ import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader' import { Context, Service } from 'cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' +import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath, pathToFileURL } from 'node:url' import * as yaml from 'js-yaml' @@ -31,6 +32,14 @@ const writable: Record = { const supported = new Set(Object.keys(writable)) +const WRITE_RETRY_LIMIT = 10 +const WRITE_RETRY_DELAY_MS = 50 + +function retryableWriteError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM' +} + /** * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling @@ -171,6 +180,8 @@ export class Include extends EntryTree { private content?: string private data?: EntryOptions[] private writeTask?: NodeJS.Timeout | undefined + private pendingWrite?: EntryOptions[] + private writeQueue: Promise = Promise.resolve() private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { @@ -272,6 +283,7 @@ export class Include extends EntryTree { async stop() { await this.root.stop() + await this.flushWrite() } /** @@ -311,17 +323,43 @@ export class Include extends EntryTree { this.content = JSON.stringify(config, null, 2) } await writeFile(this.filename + '.tmp', this.content!) - await rename(this.filename + '.tmp', this.filename) + for (let retry = 0; ; retry++) { + try { + await rename(this.filename + '.tmp', this.filename) + return + } catch (error) { + if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error + await delay((retry + 1) * WRITE_RETRY_DELAY_MS) + } + } } private writeFile(config: EntryOptions[]) { clearTimeout(this.writeTask) + this.pendingWrite = config this.writeTask = setTimeout(() => { - this.writeTask = undefined - this._writeFile(config) + void this.flushWrite() }, 0) } + private flushWrite(): Promise { + clearTimeout(this.writeTask) + this.writeTask = undefined + const config = this.pendingWrite + this.pendingWrite = undefined + if (config === undefined) return this.writeQueue + const run = this.writeQueue.then( + () => this._writeFile(config), + () => this._writeFile(config), + ) + this.writeQueue = run + void run.catch((error) => { + this.ctx.root.logger?.('loader').warn('failed to write config file %C', this.filename) + this.ctx.root.logger?.('loader').warn(error) + }) + return run + } + /** Schedule a write of the current root entry data. */ write() { this.context.emit('loader/config-update') From 176e4c6bbb70797cd0a59e05b6c8b664cadaa16d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:56:39 +0800 Subject: [PATCH 12/57] fix(subagent-codex): verify 0.147 on native Windows --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 6 +- ...ude-code-and-codex-subagent-backends.zh.md | 6 +- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 4 ++ ...08-08-native-windows-pull-request-ci.zh.md | 4 ++ .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 6 +- packages/subagent/subagent-codex/README.zh.md | 6 +- packages/subagent/subagent-codex/package.json | 2 +- packages/subagent/subagent-codex/src/wire.ts | 2 +- .../subagent-codex/tests/real-deepseek.e2e.ts | 4 +- .../subagent-codex/tests/real-product.spec.ts | 8 +-- .../subagent-codex/tests/responses-fixture.ts | 2 +- .../tests/subagent-codex.spec.ts | 12 ++-- pnpm-lock.yaml | 58 +++++++++---------- 16 files changed, 70 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 4673ffaab7..d81ee98e02 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: e81d1fb14f719331c503dba539d6a5ec0f1eed4f -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 3c78e8d5a4ca1a86942971721ad05c631901f231 +2026-08-04-claude-code-and-codex-subagent-backends.md: 2063b99a7b0ca34f434b3e56628f3ce765d90d81 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 735ad459c8b1f32051c366140faff7b57e763854 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index e81d1fb14f..2063b99a7b 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -39,11 +39,11 @@ Before publication, the provider validates a non-empty text-only task, starts th `turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. -For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. +For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. -Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. +Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. ## Claude Code provider @@ -61,7 +61,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped user configuration, verifies both fixed foreground-only tools in one context, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. +The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 3c78e8d5a4..735ad459c8 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -39,11 +39,11 @@ fixed tool → shared subagent service → product provider → official product `turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 -对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 +对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 -Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 +Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 ## Claude Code 提供方 @@ -61,7 +61,7 @@ Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示形态的用户配置,在同一个上下文中验证两个固定且只支持前台执行的工具,并且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index b69ebc94ae..05a73c2053 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: a540a73e5979c436fb8a085516554f5ae7249939 -2026-08-08-native-windows-pull-request-ci.zh.md: 34ca87f21bcd5b72b2ecbc43e66043caee299e48 +2026-08-08-native-windows-pull-request-ci.md: 5aafb8b9c7557157135dfa6ecedffed42eb76183 +2026-08-08-native-windows-pull-request-ci.zh.md: 4c71f3eeda86fdc537c22629962b0eccbb994b65 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index a540a73e59..5aafb8b9c7 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -30,6 +30,10 @@ Running the complete instrumented graph instead of the earlier reduced inventory The final root-probe failure came from applying the extended-length namespace to the drive root as well as long descendants. Node rejected the bare root probe as `EISDIR`, cascading through every JSONL fixture and assembled binary that materialized a session. The Windows durable-directory helper now probes the short drive root in its native spelling and namespaces only descendants; an injected Win32-path unit test locks both spellings while native coverage exercises the real filesystem. +The next complete coverage run reached six independent late failures rather than one shared cascade. React queue-action coverage now resolves its mocked request inside an awaited `act()` before observing the settled render. The unclosed-Markdown workload keeps all 6,400 candidates under an explicit three-second coverage budget, and the asynchronous workspace-projection warning test gives its outer case a twenty-second budget larger than its ten-second poll. Real Claude Code teardown uses asynchronous recursive removal with ten bounded retries after every managed handle reports exit, accommodating Windows' delayed handle release without weakening the quiescence assertions. + +Two product boundaries required foundation repairs. Include's debounced config persistence previously launched an unobserved promise from a timer; a transient Windows `EPERM` while replacing `cordis.yml` could therefore lose the disabled row and escape as an unhandled rejection. The vendored writer now serializes writes, retries only transient access/busy failures with bounded backoff, observes every rejection, and drains the latest write at teardown; the real Loader composition injects one `EPERM` and proves the durable retry. Codex 0.146 advertised `exec_command` to the loopback Responses model on Windows but rejected the returned call in its own router, the same upstream failure class tracked in [openai/codex#31665](https://github.com/openai/codex/issues/31665). Development evidence is pinned to the current stable 0.147.0 release: regenerated upstream schemas preserve the provider-owned handshake, thread/turn, approval, user-input, and elicitation contract, while the real-product suite again proves command rejection without a side effect and whole-tree exit. + POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. No supported product source is excluded from Windows coverage to accommodate these differences. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 34ca87f21b..4c71f3eeda 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -30,6 +30,10 @@ Status: implemented 最后一个根路径探测失败源于扩展长度命名空间既应用到长后代路径,也应用到了驱动器根目录。Node 将裸根目录探测拒绝为 `EISDIR`,从而连锁影响所有会物化会话的 JSONL fixture 和组装后二进制。Windows 持久目录辅助函数现在以原生写法探测本来就很短的驱动器根目录,仅对后代路径添加命名空间;注入 Win32 路径语义的单元测试固定两种写法,原生覆盖率则验证真实文件系统。 +下一次完整覆盖率运行触及的是 6 项相互独立的末端故障,不再是同一问题的连锁结果。React 队列动作覆盖现在会在 awaited `act()` 中解析模拟请求,再观察渲染完成后的状态。未闭合 Markdown 工作负载保留全部 6,400 个候选项,并采用显式的 3 秒覆盖率预算;异步工作区投影告警测试则为外层用例设置 20 秒预算,大于其 10 秒轮询预算。真实 Claude Code 拆卸会在所有受管句柄均报告退出后,采用带 10 次有界重试的异步递归删除,以容纳 Windows 延迟释放句柄的行为,同时不削弱完全停稳断言。 + +另有两个产品边界需要基础性修复。Include 的防抖配置持久化此前会从计时器启动一个无人观察的 Promise;Windows 在替换 `cordis.yml` 时若瞬时返回 `EPERM`,既可能丢失已禁用行,也会让 rejection 以未处理形式逸出。现在,vendored writer 会串行化写入,只对瞬时的访问/忙碌错误执行有界退避重试,观察每个 rejection,并在拆卸时排空最新写入;真实 Loader 组合测试会注入一次 `EPERM` 并证明持久化重试。Codex 0.146 在 Windows 上会把 `exec_command` 提供给回环 Responses 模型,却在自身路由器中拒绝模型返回的调用;这与 [openai/codex#31665](https://github.com/openai/codex/issues/31665) 跟踪的上游故障属于同一类。开发证据现锁定当前稳定版 0.147.0:重新生成的上游 schema 保留了提供方拥有的握手、线程/轮次、审批、用户输入和 elicitation 契约;真实产品测试则再次证明命令会在不产生副作用的情况下被拒绝,且整棵进程树退出。 + POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。没有任何受支持的产品源码为适应这些差异而从 Windows 覆盖率中排除。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 97c2b9f705..6bb735b3e5 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md -README.md: c25ee90edf8972da66448fe84cb659b0aec79e6f -README.zh.md: 10c8fcc47a9ab04bca983857bd44ede265c23435 +README.md: b0f0548ed7dd61c81111eb8a141b7a994066ed5b +README.zh.md: 8fe84e1428a77d9842a58c230185afb004345d58 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index c25ee90edf..b0f0548ed7 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -10,7 +10,7 @@ This package registers the fixed `codex` subagent provider. Each accepted run st The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. -For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. +For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and this version produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. @@ -47,7 +47,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the keyless real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. A credentialed e2e starts the production provider and real Codex, then obtains a unique answer from the fixed official DeepSeek service through a loopback-only test bridge from Responses to Chat Completions; that bridge is not production functionality or native Codex support for DeepSeek's Chat Completions API. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`: the keyless real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. A credentialed e2e starts the production provider and real Codex, then obtains a unique answer from the fixed official DeepSeek service through a loopback-only test bridge from Responses to Chat Completions; that bridge is not production functionality or native Codex support for DeepSeek's Chat Completions API. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. ## Model Experience @@ -83,7 +83,7 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. -- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. +- **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. - **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 10c8fcc47a..8fe84e1428 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -10,7 +10,7 @@ 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 -对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 +对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且本版本不会产生 `refusal`。`dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 @@ -47,7 +47,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:无密钥真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。带密钥 e2e 会启动生产提供方和真实 Codex,再通过一个仅限回环、将 Responses 转为 Chat Completions 的测试桥接层,从固定的 DeepSeek 官方服务获得唯一答案;该桥接层既不属于生产功能,也不代表 Codex 原生支持 DeepSeek 的 Chat Completions API。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`:无密钥真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用的 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。带密钥 e2e 会启动生产提供方和真实 Codex,再通过一个仅限回环、将 Responses 转为 Chat Completions 的测试桥接层,从固定的 DeepSeek 官方服务获得唯一答案;该桥接层既不属于生产功能,也不代表 Codex 原生支持 DeepSeek 的 Chat Completions API。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 ## 模型体验 @@ -83,7 +83,7 @@ Codex 子任务会在一个全新的临时线程中,以单个轮次接收这 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 -- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 +- **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 - **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 873cf1d77c..30788aa2e5 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@openai/codex": "0.146.0", + "@openai/codex": "0.147.0", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 51be212841..bc00ff0acf 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -1,5 +1,5 @@ /** - * Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC + * Minimal Codex app-server 0.147.0 protocol adapter. The shared JSON-RPC * transport owns framing and request correlation; this module owns only the * product methods, current thread/turn association, unattended approval * responses, and terminal-answer selection. diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index 29c5536bc0..5c39ebfb3d 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -109,8 +109,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { env: { ...process.env, ...env }, }) - expect(codexPackage.version).toBe('0.146.0') - expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + expect(codexPackage.version).toBe('0.147.0') + expect(version.stdout.trim()).toBe('codex-cli 0.147.0') const parent = { id: 'deepseek-e2e-parent', diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index badab946e6..9c3de65229 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -140,18 +140,18 @@ function responseInputTexts(body: Record): string[] { }) } -describe('real @openai/codex 0.146.0 product', () => { +describe('real @openai/codex 0.147.0 product', () => { it('passes the exact task and fake authentication to local Responses and returns exact text', async () => { - const sentinel = 'REAL_CODEX_SENTINEL_0_146_0' + const sentinel = 'REAL_CODEX_SENTINEL_0_147_0' const task = 'Return the fixture sentinel exactly.' const { harness, fixture } = await realHarness([ { kind: 'complete', text: sentinel }, ]) - expect(codexPackage.version).toBe('0.146.0') + expect(codexPackage.version).toBe('0.147.0') const version = await execFileAsync(process.execPath, [codexEntry, '--version'], { env: { ...process.env, ...harness.env }, }) - expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + expect(version.stdout.trim()).toBe('codex-cli 0.147.0') const run = await harness.ctx.subagents.start('codex', { prompt: [{ type: 'text', text: task }], diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index cac49b9158..2c7e5534d0 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -86,7 +86,7 @@ function responseObject(text: string): Record { } /** - * Build the minimal Responses SSE event sequence consumed by Codex 0.146.0. + * Build the minimal Responses SSE event sequence consumed by Codex 0.147.0. * @param text - exact assistant answer. * @returns ordered response lifecycle events. */ diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index de89aa4854..9318c59787 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -199,7 +199,7 @@ async function initializeWire(): Promise<{ wire.start() const initializing = wire.initialize(new AbortController().signal) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await initializing expect(await child.peer.nextMethod('initialized')).toEqual({ jsonrpc: '2.0', @@ -219,7 +219,7 @@ async function publishRun( ) { const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides)) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) @@ -380,7 +380,7 @@ describe('CodexAppServerWire', () => { requestAttestation: false, }, }) - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await initializing await child.peer.nextMethod('initialized') @@ -855,7 +855,7 @@ describe('run lifecycle and quiescence', () => { void starting.then(() => { published = true }) const initialize = await child.peer.nextMethod('initialize') expect(published).toBe(false) - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') expect(published).toBe(false) @@ -962,7 +962,7 @@ describe('run lifecycle and quiescence', () => { runSpec(child), ) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) @@ -1032,7 +1032,7 @@ describe('run lifecycle and quiescence', () => { signal: new AbortController().signal, }) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccbc67c764..b11aa900cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5604,8 +5604,8 @@ importers: specifier: workspace:^ version: link:../../util/timeout '@openai/codex': - specifier: 0.146.0 - version: 0.146.0 + specifier: 0.147.0 + version: 0.147.0 cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -8594,43 +8594,43 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} - '@openai/codex@0.146.0': - resolution: {integrity: sha512-yG3sPWNda/2YAIQIDq9MrrjoCTIQ7rxYM5IasrG3VBcuhCLTkgeg/JzqmJq1V98RE4MJ5jCxDXXQlOjrditFRw==} + '@openai/codex@0.147.0': + resolution: {integrity: sha512-EQLEXecAG2ptxI7UpBMo2TR/ga5596/c/OsYF/0LoUDh5JANZ7IoGqlzBEWbuEVQ76JePIbtTW/ihCkp1a7Z3w==} engines: {node: '>=16'} hasBin: true - '@openai/codex@0.146.0-darwin-arm64': - resolution: {integrity: sha512-nb61yX4r5L6Z0dlC4o3u0GAK1YCd4TUvjaB382bajDoh84V+uv2hTBIVZ++fgXWV9yoeuNrNnNcn7GoTGOe2Tg==} + '@openai/codex@0.147.0-darwin-arm64': + resolution: {integrity: sha512-BEUVkiOW7kLcRyrMLfAr/h9wF8sRVJyZDy6OHtVn6QGDXiv3BvAZVTY1Pu9xF7KdIdkYXbp4uayN0aDQQaAUJw==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@openai/codex@0.146.0-darwin-x64': - resolution: {integrity: sha512-hTQR5jy/ObfTf1MDnuJCZJAe+SljKE8DDwQWN6lDFgjsPhMQz852U2tILt8Ei+G5GkQSzemHYKl2AYPwW0Y5xw==} + '@openai/codex@0.147.0-darwin-x64': + resolution: {integrity: sha512-Tb8McE5SvJIH0Vs5R6sq7u+quiC931yan2KOOl6km1OdZ82+Wi7eF5XrSFPs5CF7xCgoIK4Vs+byMbT5hN+ZUw==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@openai/codex@0.146.0-linux-arm64': - resolution: {integrity: sha512-qiYDxkkEFnXG7joadJW6Q+XcgyDXCpGdpa9nk/c+i0gEomur1j7bHvx12NfWWCF/y8Tqri6ay+FLuC2MjdehtA==} + '@openai/codex@0.147.0-linux-arm64': + resolution: {integrity: sha512-SLC1JXw2TYfr/c3HhrJubyyLelq7vTOLWVmiThFA+z0+WgzCPmaseJ/kzDD3Gge/TO7fCnnj7UcPmC0d2c8XAg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@openai/codex@0.146.0-linux-x64': - resolution: {integrity: sha512-fswvyGprAPCMiOEue/7MKMk7pCjh9kZIJfJX5i9atmfnmGYbYCcUhZsEH9LEP0+0t5xyPqDbfNXY7NSxIVuXxA==} + '@openai/codex@0.147.0-linux-x64': + resolution: {integrity: sha512-0W9MBxPpWW0cSkNqrTDN2jR7rzzT7oNMhQY5446lT2Lw5cz5yhDTck4Va9rjkQEm+HlFzP/dmEMSZbXfJsINmw==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@openai/codex@0.146.0-win32-arm64': - resolution: {integrity: sha512-EW6zdjDe+SLX2Iw+xymJ5+Pz2+DGexdstfFHXh4Ub+TfJsQPiMjGfZfNaoWgdJ2FsqSIzVKu2+G0KCMGYz2W8g==} + '@openai/codex@0.147.0-win32-arm64': + resolution: {integrity: sha512-e2ZstJ8zT8Rm1nvR7CUVO+Gr3cTChE41+VfOzGhynzDXEoW0wfbjUQbc2bWbh1arG94LMm4y3dqBtUIbSrfeGA==} engines: {node: '>=16'} cpu: [arm64] os: [win32] - '@openai/codex@0.146.0-win32-x64': - resolution: {integrity: sha512-b3lxMYeR0+IhstNo4JjX1P9cPc1xwVcCVkPd1lD1wpWPJ0SBhpIkPczwbu3ZRkJcdyl342+rgyf4DUrbZLdrGA==} + '@openai/codex@0.147.0-win32-x64': + resolution: {integrity: sha512-oT7Ss5fAPf2fiWE9QNURqZcQGAAawSVxmIUdgPzckq4KFZAM+pRz9JbM4Rr498CjtbNgTOjWvDJ+DXvIBSfOPA==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -13889,31 +13889,31 @@ snapshots: '@nodable/entities@2.2.0': {} - '@openai/codex@0.146.0': + '@openai/codex@0.147.0': optionalDependencies: - '@openai/codex-darwin-arm64': '@openai/codex@0.146.0-darwin-arm64' - '@openai/codex-darwin-x64': '@openai/codex@0.146.0-darwin-x64' - '@openai/codex-linux-arm64': '@openai/codex@0.146.0-linux-arm64' - '@openai/codex-linux-x64': '@openai/codex@0.146.0-linux-x64' - '@openai/codex-win32-arm64': '@openai/codex@0.146.0-win32-arm64' - '@openai/codex-win32-x64': '@openai/codex@0.146.0-win32-x64' + '@openai/codex-darwin-arm64': '@openai/codex@0.147.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.147.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.147.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.147.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.147.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.147.0-win32-x64' - '@openai/codex@0.146.0-darwin-arm64': + '@openai/codex@0.147.0-darwin-arm64': optional: true - '@openai/codex@0.146.0-darwin-x64': + '@openai/codex@0.147.0-darwin-x64': optional: true - '@openai/codex@0.146.0-linux-arm64': + '@openai/codex@0.147.0-linux-arm64': optional: true - '@openai/codex@0.146.0-linux-x64': + '@openai/codex@0.147.0-linux-x64': optional: true - '@openai/codex@0.146.0-win32-arm64': + '@openai/codex@0.147.0-win32-arm64': optional: true - '@openai/codex@0.146.0-win32-x64': + '@openai/codex@0.147.0-win32-x64': optional: true '@opentelemetry/api-logs@0.220.0': From 9e724dcad777c08613c46f8803ac4caba88dd3d3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:48:46 +0800 Subject: [PATCH 13/57] test(windows): close final native coverage gaps --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 4 +- ...08-08-native-windows-pull-request-ci.zh.md | 4 +- .../bash/pwsh-local/tests/executor.spec.ts | 5 ++- .../tests/workspace-context.spec.ts | 7 ++-- .../tests/loader-composition.spec.ts | 8 +++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +- packages/lsp/lsp-local/tests/provider.spec.ts | 8 ++-- packages/lsp/tool-lsp/tests/render.spec.ts | 4 +- .../subagent-codex/tests/real-product.spec.ts | 30 +++++++++---- .../subagent-codex/tests/responses-fixture.ts | 42 +++++++++++++++++-- 11 files changed, 88 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 05a73c2053..2cdf3e8bfb 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 5aafb8b9c7557157135dfa6ecedffed42eb76183 -2026-08-08-native-windows-pull-request-ci.zh.md: 4c71f3eeda86fdc537c22629962b0eccbb994b65 +2026-08-08-native-windows-pull-request-ci.md: 8b657e3bffa7279560f3f87e5c35e7f462f2cac1 +2026-08-08-native-windows-pull-request-ci.zh.md: 6f23dd1df1ef31a8952425bdc6ed00a720d162dd diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 5aafb8b9c7..8b657e3bff 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -32,7 +32,9 @@ The final root-probe failure came from applying the extended-length namespace to The next complete coverage run reached six independent late failures rather than one shared cascade. React queue-action coverage now resolves its mocked request inside an awaited `act()` before observing the settled render. The unclosed-Markdown workload keeps all 6,400 candidates under an explicit three-second coverage budget, and the asynchronous workspace-projection warning test gives its outer case a twenty-second budget larger than its ten-second poll. Real Claude Code teardown uses asynchronous recursive removal with ten bounded retries after every managed handle reports exit, accommodating Windows' delayed handle release without weakening the quiescence assertions. -Two product boundaries required foundation repairs. Include's debounced config persistence previously launched an unobserved promise from a timer; a transient Windows `EPERM` while replacing `cordis.yml` could therefore lose the disabled row and escape as an unhandled rejection. The vendored writer now serializes writes, retries only transient access/busy failures with bounded backoff, observes every rejection, and drains the latest write at teardown; the real Loader composition injects one `EPERM` and proves the durable retry. Codex 0.146 advertised `exec_command` to the loopback Responses model on Windows but rejected the returned call in its own router, the same upstream failure class tracked in [openai/codex#31665](https://github.com/openai/codex/issues/31665). Development evidence is pinned to the current stable 0.147.0 release: regenerated upstream schemas preserve the provider-owned handshake, thread/turn, approval, user-input, and elicitation contract, while the real-product suite again proves command rejection without a side effect and whole-tree exit. +Two product boundaries required foundation repairs. Include's debounced config persistence previously launched an unobserved promise from a timer; a transient Windows `EPERM` while replacing `cordis.yml` could therefore lose the disabled row and escape as an unhandled rejection. The vendored writer now serializes writes, retries only transient access/busy failures with bounded backoff, observes every rejection, and drains the latest write at teardown; the real Loader composition injects one `EPERM` and proves the durable retry. Codex 0.146 advertised `exec_command` to the loopback Responses model on Windows but rejected the returned call in its own router, the same upstream failure class tracked in [openai/codex#31665](https://github.com/openai/codex/issues/31665). Development evidence is pinned to the current stable 0.147.0 release: regenerated upstream schemas preserve the provider-owned handshake, thread/turn, approval, user-input, and elicitation contract. Because Codex can advertise the legacy `shell_command` instead when unified exec is unavailable on the host, the loopback model now selects an advertised command tool and supplies that tool's argument shape rather than injecting `exec_command` unconditionally. The real-product suite therefore proves unattended rejection without a side effect and whole-tree exit through the product's actual default tool inventory on each host. + +The subsequent exact hosted run isolated seven other fixture contracts. The PowerShell background-output case now waits for process completion before draining and comparing the final delta, while the pi-ai idle-watchdog case retains a bounded one-second close deadline that accommodates the delayed Windows socket notification. The asynchronous workspace projection seeds its in-memory filesystem at the host-resolved root. The Include retry acceptance asserts the injected failure and eventual persistence rather than an incidental total rename count, which may include another valid serialized write. LSP's bare-command fixture supplies a `.cmd` executable through `PATHEXT` on Windows, and URI rendering expectations distinguish the execution world's path convention from the test host's separators. None of these changes skips a supported path or weakens the asserted outcome. POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. No supported product source is excluded from Windows coverage to accommodate these differences. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 4c71f3eeda..6f23dd1df1 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -32,7 +32,9 @@ Status: implemented 下一次完整覆盖率运行触及的是 6 项相互独立的末端故障,不再是同一问题的连锁结果。React 队列动作覆盖现在会在 awaited `act()` 中解析模拟请求,再观察渲染完成后的状态。未闭合 Markdown 工作负载保留全部 6,400 个候选项,并采用显式的 3 秒覆盖率预算;异步工作区投影告警测试则为外层用例设置 20 秒预算,大于其 10 秒轮询预算。真实 Claude Code 拆卸会在所有受管句柄均报告退出后,采用带 10 次有界重试的异步递归删除,以容纳 Windows 延迟释放句柄的行为,同时不削弱完全停稳断言。 -另有两个产品边界需要基础性修复。Include 的防抖配置持久化此前会从计时器启动一个无人观察的 Promise;Windows 在替换 `cordis.yml` 时若瞬时返回 `EPERM`,既可能丢失已禁用行,也会让 rejection 以未处理形式逸出。现在,vendored writer 会串行化写入,只对瞬时的访问/忙碌错误执行有界退避重试,观察每个 rejection,并在拆卸时排空最新写入;真实 Loader 组合测试会注入一次 `EPERM` 并证明持久化重试。Codex 0.146 在 Windows 上会把 `exec_command` 提供给回环 Responses 模型,却在自身路由器中拒绝模型返回的调用;这与 [openai/codex#31665](https://github.com/openai/codex/issues/31665) 跟踪的上游故障属于同一类。开发证据现锁定当前稳定版 0.147.0:重新生成的上游 schema 保留了提供方拥有的握手、线程/轮次、审批、用户输入和 elicitation 契约;真实产品测试则再次证明命令会在不产生副作用的情况下被拒绝,且整棵进程树退出。 +另有两个产品边界需要基础性修复。Include 的防抖配置持久化此前会从计时器启动一个无人观察的 Promise;Windows 在替换 `cordis.yml` 时若瞬时返回 `EPERM`,既可能丢失已禁用行,也会让 rejection 以未处理形式逸出。现在,vendored writer 会串行化写入,只对瞬时的访问/忙碌错误执行有界退避重试,观察每个 rejection,并在拆卸时排空最新写入;真实 Loader 组合测试会注入一次 `EPERM` 并证明持久化重试。Codex 0.146 在 Windows 上会把 `exec_command` 提供给回环 Responses 模型,却在自身路由器中拒绝模型返回的调用;这与 [openai/codex#31665](https://github.com/openai/codex/issues/31665) 跟踪的上游故障属于同一类。开发证据现锁定当前稳定版 0.147.0:重新生成的上游 schema 保留了提供方拥有的握手、线程/轮次、审批、用户输入和 elicitation 契约。当宿主无法使用 unified exec 时,Codex 可能改为提供旧版 `shell_command`;因此,回环模型现在会选择实际提供的命令工具,并使用该工具对应的参数形态,而不再无条件注入 `exec_command`。真实产品测试由此会通过各宿主的实际默认工具清单,证明无人值守拒绝不产生副作用,且整棵进程树退出。 + +随后的分支头精确托管运行又隔离出另外 7 项 fixture 契约。PowerShell 后台输出场景现在会等待进程完成,再排空并比较最后一段增量;pi-ai 空闲 watchdog 场景则保留 1 秒的有界关闭期限,以容纳 Windows 延迟送达的 socket 通知。异步工作区投影会在宿主解析后的根目录上填充内存文件系统。Include 重试验收现在断言注入的故障与最终持久化结果,而不再断言可能包含另一项合法串行写入的偶然 rename 总次数。LSP 的裸命令 fixture 会在 Windows 上通过 `PATHEXT` 提供 `.cmd` 可执行文件,URI 渲染预期也会区分执行环境的路径约定与测试宿主的分隔符。上述修改既没有跳过受支持路径,也没有削弱结果断言。 POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。没有任何受支持的产品源码为适应这些差异而从 Windows 覆盖率中排除。 diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index 780491d696..5c5c7fa35e 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -314,9 +314,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' env: { BG_VAR: 'bg-env' }, dshEnv: { DSH_BG_VAR: 'bg-dsh-env' }, })) - const output = await readUntil(proc, '[bg-env][bg-dsh-env]') - expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n') + const partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]') await proc.done + const output = partialOutput + lf(proc.readOutput().delta) + expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n') expect(proc.exitCode).toBe(0) }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 3f52b19920..59c5b39d10 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -4113,11 +4113,12 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(RecordingFileSystem) await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem - const agent = stubAgent('/') + const root = resolve('/') + const agent = stubAgent(root) const failure = new Error('projection failed') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - fs.entries.set('/.git', { type: 'directory' }) - fs.entries.set('/AGENTS.md', { type: 'file', content: 'workspace rule' }) + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'workspace rule' }) vi.spyOn(agent.inbox, 'prepend').mockImplementationOnce(() => { throw failure }) ctx.emit('tools/result', stubToolExecution({ diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 88f16687fa..4420e54da4 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -22,7 +22,7 @@ import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' import * as DirectoryPickerAuto from '../src/index.ts' -const renameControl = vi.hoisted(() => ({ attempts: 0, remainingFailures: 0 })) +const renameControl = vi.hoisted(() => ({ attempts: 0, injectedFailures: 0, remainingFailures: 0 })) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -32,6 +32,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { renameControl.attempts++ if (renameControl.remainingFailures > 0) { renameControl.remainingFailures-- + renameControl.injectedFailures++ throw Object.assign(new Error(`transient rename failure for ${newPath}`), { code: 'EPERM' }) } await actual.rename(oldPath, newPath) @@ -59,6 +60,7 @@ afterEach(async () => { root = undefined fakeBin = undefined renameControl.attempts = 0 + renameControl.injectedFailures = 0 renameControl.remainingFailures = 0 }) @@ -187,6 +189,8 @@ describe('real Loader composition', () => { expect(entryNames(ctx)).not.toContain(NATIVE) // Same self-dispose persistence as above: let the write land before teardown. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') - expect(renameControl.attempts).toBe(2) + expect(renameControl.injectedFailures).toBe(1) + expect(renameControl.remainingFailures).toBe(0) + expect(renameControl.attempts).toBeGreaterThanOrEqual(2) }) }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index d2e101505d..a8a06a167d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -272,7 +272,7 @@ describe('PiAiAdapter provider routing', () => { await Promise.race([ server.responseClosed, new Promise((_resolve, reject) => { - setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 100) + setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 1_000) }), ]) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 88b2b0144f..24edca4795 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -38,9 +38,9 @@ describe('lsp-local provider resolution', () => { // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it. const bin = join(root, 'bin') await mkdir(bin) - const exe = join(bin, 'fake-lsp') - await writeFile(exe, '#!/bin/sh\nexit 0\n') - await chmod(exe, 0o755) + const exe = join(bin, process.platform === 'win32' ? 'fake-lsp.cmd' : 'fake-lsp') + await writeFile(exe, process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n') + if (process.platform !== 'win32') await chmod(exe, 0o755) const ctx = new Context() await ctx.plugin(Lsp) @@ -49,7 +49,7 @@ describe('lsp-local provider resolution', () => { await expect(ctx.plugin(LspLocal, config('onpath', { command: 'fake-lsp', args: [], - env: { PATH: bin }, + env: { PATH: bin, ...process.platform === 'win32' ? { PATHEXT: '.CMD' } : {} }, extensionToLanguage: { '.ts': 'typescript' }, }))).resolves.toBeDefined() await ctx.fiber.dispose() diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts index 2d181f25b3..51fb6a5083 100644 --- a/packages/lsp/tool-lsp/tests/render.spec.ts +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -55,7 +55,7 @@ describe('renderUri', () => { it('returns an absolute path for a file: URI outside the workspace', () => { const outside = resolve(WS, '..', 'other', 'lib', 'b.ts') const uri = pathToFileURL(outside).href - expect(renderUri(uri, WS_URI)).toBe(outside) + expect(renderUri(uri, WS_URI)).toBe(outside.replaceAll('\\', '/')) }) it('renders the workspace root itself as "."', () => { @@ -83,7 +83,7 @@ describe('renderUri', () => { }) it('preserves backslashes as ordinary POSIX filename characters', () => { - expect(renderUri('file:///home/u/proj/dir%5Cname/a.ts', WS_URI)).toBe('dir\\name/a.ts') + expect(renderUri('file:///home/u/proj/dir%5Cname/a.ts', 'file:///home/u/proj')).toBe('dir\\name/a.ts') }) it('keeps malformed or mismatched URI coordinates verbatim', () => { diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 9c3de65229..c39093b041 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -174,18 +174,32 @@ describe('real @openai/codex 0.147.0 product', () => { }, 60_000) it('cancels a real app-server command approval without executing the command', async () => { - const { harness, fixture } = await realHarness([ + const command = process.platform === 'win32' + ? 'cmd /c type nul > approval-side-effect' + : 'touch approval-side-effect' + const commandCalls = [ { - kind: 'functionCall', name: 'exec_command', arguments: { - cmd: process.platform === 'win32' - ? 'cmd /c type nul > approval-side-effect' - : 'touch approval-side-effect', + cmd: command, sandbox_permissions: 'require_escalated', justification: 'exercise the unattended approval boundary', }, }, + { + name: 'shell_command', + arguments: { + command, + sandbox_permissions: 'require_escalated', + justification: 'exercise the unattended approval boundary', + }, + }, + ] as const + const { harness, fixture } = await realHarness([ + { + kind: 'advertisedFunctionCall', + choices: commandCalls, + }, ]) const sideEffect = join(harness.workspace, 'approval-side-effect') const run = await harness.ctx.subagents.start('codex', { @@ -202,9 +216,9 @@ describe('real @openai/codex 0.147.0 product', () => { expect(existsSync(sideEffect)).toBe(false) expect(fixture.requests).toHaveLength(1) const tools = fixture.requests[0]!.body.tools as Array> - expect(tools).toEqual(expect.arrayContaining([ - expect.objectContaining({ type: 'function', name: 'exec_command' }), - ])) + expect(commandCalls.some(call => tools.some(tool => ( + tool.type === 'function' && tool.name === call.name + )))).toBe(true) expect(fixture.requests.every(requestEntry => requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', )).toBe(true) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index 2c7e5534d0..2b6e5868ae 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -22,6 +22,13 @@ export type ResponsesBehavior = readonly name: string readonly arguments: Record } + | { + readonly kind: 'advertisedFunctionCall' + readonly choices: readonly { + readonly name: string + readonly arguments: Record + }[] + } | { readonly kind: 'hold' } /** Running package-private Responses fixture. */ @@ -218,6 +225,18 @@ function closeServer(server: Server): Promise { }) } +function advertisedFunctionNames(body: Record): Set { + if (!Array.isArray(body.tools)) return new Set() + return new Set(body.tools.flatMap((tool): string[] => ( + tool !== null + && typeof tool === 'object' + && (tool as Record).type === 'function' + && typeof (tool as Record).name === 'string' + ? [(tool as Record).name as string] + : [] + ))) +} + /** * Start a loopback-only Responses SSE fixture. * @param script - one behavior per expected Responses request. @@ -234,11 +253,12 @@ export async function startResponsesFixture( openResponses.add(response) response.on('close', () => { openResponses.delete(response) }) void readRequest(request).then((body) => { + const parsedBody = JSON.parse(body) as Record requests.push({ method: request.method, path: request.url, headers: request.headers, - body: JSON.parse(body) as Record, + body: parsedBody, }) started.resolve(undefined) const behavior = behaviors.shift() @@ -247,6 +267,14 @@ export async function startResponsesFixture( response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } })) return } + const advertisedCall = behavior.kind === 'advertisedFunctionCall' + ? behavior.choices.find(choice => advertisedFunctionNames(parsedBody).has(choice.name)) + : undefined + if (behavior.kind === 'advertisedFunctionCall' && advertisedCall === undefined) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } })) + return + } response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', @@ -254,9 +282,15 @@ export async function startResponsesFixture( 'x-request-id': 'req_fixture', }) if (behavior.kind === 'hold') return - const events = behavior.kind === 'complete' - ? completeResponsesEvents(behavior.text) - : functionCallEvents(behavior.name, behavior.arguments) + let events: Record[] + if (behavior.kind === 'complete') { + events = completeResponsesEvents(behavior.text) + } else { + const call = behavior.kind === 'functionCall' + ? behavior + : advertisedCall! + events = functionCallEvents(call.name, call.arguments) + } for (const event of events) { response.write(`data: ${JSON.stringify(event)}\n\n`) } From 317c3bd17846bd2a89171a239c231568b3390ec7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:32:17 +0800 Subject: [PATCH 14/57] fix(client): stabilize syntax highlighting under load --- ...08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- ...026-08-08-native-windows-pull-request-ci.md | 2 ++ ...-08-08-native-windows-pull-request-ci.zh.md | 2 ++ .../ui-primitives/src/markdown/highlight.ts | 18 ++++++++++++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 2cdf3e8bfb..529d3b58ce 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 8b657e3bffa7279560f3f87e5c35e7f462f2cac1 -2026-08-08-native-windows-pull-request-ci.zh.md: 6f23dd1df1ef31a8952425bdc6ed00a720d162dd +2026-08-08-native-windows-pull-request-ci.md: 4fa293c7701264cfaa557c64b9ac897d8e0e5c0f +2026-08-08-native-windows-pull-request-ci.zh.md: 575a4acda1d0ba6b2e97cccffaf107e113608ad8 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 8b657e3bff..4fa293c770 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -36,6 +36,8 @@ Two product boundaries required foundation repairs. Include's debounced config p The subsequent exact hosted run isolated seven other fixture contracts. The PowerShell background-output case now waits for process completion before draining and comparing the final delta, while the pi-ai idle-watchdog case retains a bounded one-second close deadline that accommodates the delayed Windows socket notification. The asynchronous workspace projection seeds its in-memory filesystem at the host-resolved root. The Include retry acceptance asserts the injected failure and eventual persistence rather than an incidental total rename count, which may include another valid serialized write. LSP's bare-command fixture supplies a `.cmd` executable through `PATHEXT` on Windows, and URI rendering expectations distinguish the execution world's path convention from the test host's separators. None of these changes skips a supported path or weakens the asserted outcome. +That run also made syntax highlighting sensitive to runner contention rather than source text. Shiki's JavaScript engine deferred TextMate regexes longer than 3,000 characters until their first match, while Shiki counted that compilation against its 500 ms per-line tokenization budget. A busy Windows coverage worker could therefore stop the first TypeScript line after the `const` match and return the remainder under the same keyword style. The client now uses Shiki's default regex translation with lazy compilation disabled: scanner construction compiles each pattern eagerly, while the existing 500 ms budget still bounds scanning of user content. The token-boundary and Markdown DOM fixtures continue to require the complete highlighted result rather than accepting the partial stream. + POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. No supported product source is excluded from Windows coverage to accommodate these differences. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 6f23dd1df1..575a4acda1 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -36,6 +36,8 @@ Status: implemented 随后的分支头精确托管运行又隔离出另外 7 项 fixture 契约。PowerShell 后台输出场景现在会等待进程完成,再排空并比较最后一段增量;pi-ai 空闲 watchdog 场景则保留 1 秒的有界关闭期限,以容纳 Windows 延迟送达的 socket 通知。异步工作区投影会在宿主解析后的根目录上填充内存文件系统。Include 重试验收现在断言注入的故障与最终持久化结果,而不再断言可能包含另一项合法串行写入的偶然 rename 总次数。LSP 的裸命令 fixture 会在 Windows 上通过 `PATHEXT` 提供 `.cmd` 可执行文件,URI 渲染预期也会区分执行环境的路径约定与测试宿主的分隔符。上述修改既没有跳过受支持路径,也没有削弱结果断言。 +该次运行还暴露出语法高亮会受运行器资源争用影响,而不只取决于源文本。Shiki 的 JavaScript 引擎会把超过 3,000 个字符的 TextMate 正则推迟到首次匹配时再编译,Shiki 同时把这段编译时间计入每行 500 毫秒的 tokenization(词元化)预算。繁忙的 Windows 覆盖率工作线程因此可能在首次 TypeScript 行匹配到 `const` 后提前停止,并让剩余内容沿用同一关键字样式。客户端现在仍使用 Shiki 的默认正则转换,但关闭延迟编译:scanner(扫描器)创建时会立即编译各项模式,而既有的 500 毫秒预算仍会约束对用户内容的扫描。词元边界与 Markdown DOM fixture 会继续要求完整高亮结果,不接受这类部分结果流。 + POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。没有任何受支持的产品源码为适应这些差异而从 Windows 覆盖率中排除。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index d0727c833a..b9271c101b 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -19,7 +19,7 @@ */ import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core' -import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' +import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from 'shiki/engine/javascript' import langTs from '@shikijs/langs/typescript' import langBash from '@shikijs/langs/shellscript' import langJson from '@shikijs/langs/json' @@ -140,6 +140,20 @@ const cssVariablesTheme = createCssVariablesTheme({ fontStyle: true, }) +/** + * The client regex engine compiles each TextMate pattern when its scanner is + * created. Shiki otherwise defers patterns longer than 3,000 characters until + * their first match; that compilation counts against Shiki's 500 ms per-line + * budget and can return a partial token stream under host contention. Eager + * compilation leaves the same budget in place for scanning user content. + */ +const regexEngine = createJavaScriptRegexEngine({ + forgiving: true, + regexConstructor: pattern => defaultJavaScriptRegexConstructor(pattern, { + lazyCompileLength: Number.POSITIVE_INFINITY, + }), +}) + let singleton: HighlighterCore | undefined /** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */ @@ -147,7 +161,7 @@ function highlighter(): HighlighterCore { singleton ??= createHighlighterCoreSync({ themes: [cssVariablesTheme], langs: LANGS, - engine: createJavaScriptRegexEngine({ forgiving: true }), + engine: regexEngine, }) return singleton } From 94799abfb03b346c898ebfc5b4c6a6a98983550a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:29:09 +0800 Subject: [PATCH 15/57] test(windows): stabilize native coverage --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 8 ++++-- ...08-08-native-windows-pull-request-ci.zh.md | 8 ++++-- .github/workflows/ci.yml | 2 +- .../ui-primitives/src/markdown/highlight.ts | 26 ++++++++++++++++--- .../agent-spine-demo/tests/agent-core.spec.ts | 11 +++++++- packages/scaffold/server/tests/server.spec.ts | 4 +-- scripts/ci-workflow.spec.ts | 2 +- scripts/translation-pairing-merge.spec.ts | 4 +-- vitest.config.ts | 2 +- 10 files changed, 54 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 529d3b58ce..08da0e0a91 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 4fa293c7701264cfaa557c64b9ac897d8e0e5c0f -2026-08-08-native-windows-pull-request-ci.zh.md: 575a4acda1d0ba6b2e97cccffaf107e113608ad8 +2026-08-08-native-windows-pull-request-ci.md: a9e3b1b3ee2c5af55eccb8d14b0315a8fd5eea66 +2026-08-08-native-windows-pull-request-ci.zh.md: 2ebac333d3c2a1de51083f915c41e7dba8138332 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 4fa293c770..a9e3b1b3ee 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,7 +14,7 @@ The coverage audit found that PR #499 had restored deterministic native-Windows The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) runs on GitHub's standard `windows-2025` image under native PowerShell. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete`. The stable `windows` job id remains a dependency of `all checks passed`; its display name is `windows node 24 / native complete`. -The aggregate keeps workspace build, production-site, and 100%-per-file coverage failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. Coverage has a four-worker budget; one runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint and snapshot enforcement. +The aggregate keeps workspace build, production-site, and 100%-per-file coverage failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. Coverage has a one-worker budget, and gate concurrency remains one so the instrumented and exempt-heavy suites do not overlap; one runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint and snapshot enforcement. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. @@ -36,7 +36,11 @@ Two product boundaries required foundation repairs. Include's debounced config p The subsequent exact hosted run isolated seven other fixture contracts. The PowerShell background-output case now waits for process completion before draining and comparing the final delta, while the pi-ai idle-watchdog case retains a bounded one-second close deadline that accommodates the delayed Windows socket notification. The asynchronous workspace projection seeds its in-memory filesystem at the host-resolved root. The Include retry acceptance asserts the injected failure and eventual persistence rather than an incidental total rename count, which may include another valid serialized write. LSP's bare-command fixture supplies a `.cmd` executable through `PATHEXT` on Windows, and URI rendering expectations distinguish the execution world's path convention from the test host's separators. None of these changes skips a supported path or weakens the asserted outcome. -That run also made syntax highlighting sensitive to runner contention rather than source text. Shiki's JavaScript engine deferred TextMate regexes longer than 3,000 characters until their first match, while Shiki counted that compilation against its 500 ms per-line tokenization budget. A busy Windows coverage worker could therefore stop the first TypeScript line after the `const` match and return the remainder under the same keyword style. The client now uses Shiki's default regex translation with lazy compilation disabled: scanner construction compiles each pattern eagerly, while the existing 500 ms budget still bounds scanning of user content. The token-boundary and Markdown DOM fixtures continue to require the complete highlighted result rather than accepting the partial stream. +That run also made syntax highlighting sensitive to runner contention rather than source text. Shiki's JavaScript engine deferred TextMate regexes longer than 3,000 characters until their first match, while Shiki counted that compilation against its 500 ms per-line tokenization budget. A busy Windows coverage worker could therefore stop the first TypeScript line after the `const` match and return the remainder under the same keyword style. The client now uses Shiki's default regex translation with lazy compilation disabled and tokenizes one representative sample for each boot grammar without a startup cutoff while constructing the singleton. Scanner creation and pattern compilation therefore finish before user content enters the unchanged 500 ms per-line budget. The token-boundary and Markdown DOM fixtures continue to require the complete highlighted result rather than accepting the partial stream. + +The same exact hosted run showed that three concurrent instrumented Vitest workers were an unsafe budget for the standard Windows image: otherwise independent Git-merge and JSON-RPC HTTP integration cases reached the default five-second ceiling together. The native lane now gives Vitest one worker, while the real Git subprocess suite and the two real HTTP composition cases have explicit fifteen-second integration budgets without changing their workloads or assertions. The translation merge fixture also preserves `import.meta.resolve('tsx/esm')` as a `file:` URL when passing it to Node's `--import`; converting it to a drive-letter path had failed before the driver could print its owned recovery guidance. After the latest package regrouping, the fork-isolated JSONL suite's inventory follows its new `packages/session/` location rather than silently returning that process-bound suite to the shared thread pool. + +The project-skill composition fixture had one separate eventual-consistency race: on a contended host the agent could begin its next model step after `write` returned but before Chokidar invalidated the skill catalog, moving the replacement catalog behind the subsequent `skill` call. The fixture now holds that post-write tool boundary until the real registry observes `hot-skill`, then retains its strict request-order and durable-transcript assertions. Production code remains asynchronous; the test explicitly waits for the watcher contract it intends to exercise instead of relying on scheduler timing or accepting a different request index. POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. No supported product source is excluded from Windows coverage to accommodate these differences. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 575a4acda1..2ebac333d3 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,7 +14,7 @@ Status: implemented [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业在 GitHub 标准 `windows-2025` 镜像上使用原生 PowerShell 运行。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并运行 `pnpm run check:ci:windows-complete`。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项;其显示名称为 `windows node 24 / native complete`。 -工作区构建、生产网站和逐文件 100% 覆盖率检查失败时,聚合作业会继续阻断;更广泛的静态检查、文档、包和构建产物可移植性清单则作为观测项报告。覆盖率检查的工作线程预算为 4 个;同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。重复执行的 lint 与快照强制检查仍由 Linux 负责。 +工作区构建、生产网站和逐文件 100% 覆盖率检查失败时,聚合作业会继续阻断;更广泛的静态检查、文档、包和构建产物可移植性清单则作为观测项报告。覆盖率检查的工作线程预算为 1 个,门禁并发数也保持为 1,因此插桩套件不会与免覆盖率的高负载套件重叠执行;同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。重复执行的 lint 与快照强制检查仍由 Linux 负责。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 @@ -36,7 +36,11 @@ Status: implemented 随后的分支头精确托管运行又隔离出另外 7 项 fixture 契约。PowerShell 后台输出场景现在会等待进程完成,再排空并比较最后一段增量;pi-ai 空闲 watchdog 场景则保留 1 秒的有界关闭期限,以容纳 Windows 延迟送达的 socket 通知。异步工作区投影会在宿主解析后的根目录上填充内存文件系统。Include 重试验收现在断言注入的故障与最终持久化结果,而不再断言可能包含另一项合法串行写入的偶然 rename 总次数。LSP 的裸命令 fixture 会在 Windows 上通过 `PATHEXT` 提供 `.cmd` 可执行文件,URI 渲染预期也会区分执行环境的路径约定与测试宿主的分隔符。上述修改既没有跳过受支持路径,也没有削弱结果断言。 -该次运行还暴露出语法高亮会受运行器资源争用影响,而不只取决于源文本。Shiki 的 JavaScript 引擎会把超过 3,000 个字符的 TextMate 正则推迟到首次匹配时再编译,Shiki 同时把这段编译时间计入每行 500 毫秒的 tokenization(词元化)预算。繁忙的 Windows 覆盖率工作线程因此可能在首次 TypeScript 行匹配到 `const` 后提前停止,并让剩余内容沿用同一关键字样式。客户端现在仍使用 Shiki 的默认正则转换,但关闭延迟编译:scanner(扫描器)创建时会立即编译各项模式,而既有的 500 毫秒预算仍会约束对用户内容的扫描。词元边界与 Markdown DOM fixture 会继续要求完整高亮结果,不接受这类部分结果流。 +该次运行还暴露出语法高亮会受运行器资源争用影响,而不只取决于源文本。Shiki 的 JavaScript 引擎会把超过 3,000 个字符的 TextMate 正则推迟到首次匹配时再编译,Shiki 同时把这段编译时间计入每行 500 毫秒的 tokenization(词元化)预算。繁忙的 Windows 覆盖率工作线程因此可能在首次 TypeScript 行匹配到 `const` 后提前停止,并让剩余内容沿用同一关键字样式。客户端现在仍使用 Shiki 的默认正则转换,但会关闭延迟编译,并在构造单例时以不设启动期截止时间的方式,为每项启动时语法 tokenization 一段代表性样例。因此,scanner(扫描器)创建与模式编译会在用户内容进入仍为每行 500 毫秒的预算前完成。词元边界与 Markdown DOM fixture 会继续要求完整高亮结果,不接受这类部分结果流。 + +同一次分支头精确托管运行还表明,在标准 Windows 镜像上并发使用 3 个插桩 Vitest 工作线程并不安全:彼此独立的 Git merge 集成用例与 JSON-RPC HTTP 集成用例会同时触及默认的 5 秒上限。原生通道现在只为 Vitest 提供 1 个工作线程;真实 Git 子进程套件与两项真实 HTTP 组合用例则获得显式的 15 秒集成预算,其工作负载与断言均未改变。translation merge fixture 在把 `import.meta.resolve('tsx/esm')` 传给 Node 的 `--import` 时,也会保留其 `file:` URL;此前把它转换为盘符路径会在驱动程序输出自有恢复指引前就失败。纳入最新的 package regrouping(包重组)后,采用 fork 隔离的 JSONL 套件清单会跟随它在 `packages/session/` 下的新位置,而不会悄然把这一进程绑定套件送回共享线程池。 + +项目 skill 组合 fixture 另有一项最终一致性竞态:宿主资源紧张时,agent 可能在 `write` 返回后、Chokidar 使 skill 目录缓存失效前就开始下一次模型步骤,导致替换目录消息落到后续 `skill` 调用之后。现在,fixture 会在写入后的工具边界等待真实注册表观察到 `hot-skill`,然后继续严格断言请求顺序与持久转录。生产代码仍保持异步;测试会显式等待其本来要验证的 watcher 契约,而不是依赖调度时序或接受另一个请求索引。 POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。没有任何受支持的产品源码为适应这些差异而从 Windows 覆盖率中排除。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cbd9463e5..f303eca3d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,7 +310,7 @@ jobs: runs-on: windows-2025 name: windows node 24 / native complete env: - DSH_COVERAGE_MAX_WORKERS: '4' + DSH_COVERAGE_MAX_WORKERS: '1' DSH_GATE_CONCURRENCY: '1' DSH_PUBLINT_CONCURRENCY: '1' steps: diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index b9271c101b..ef552997a0 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -156,13 +156,33 @@ const regexEngine = createJavaScriptRegexEngine({ let singleton: HighlighterCore | undefined -/** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */ -function highlighter(): HighlighterCore { - singleton ??= createHighlighterCoreSync({ +/** Representative paths through every boot grammar, compiled before user content is timed. */ +const BOOT_GRAMMAR_WARMUPS = [ + { lang: 'typescript', code: 'const answer: number = 42' }, + { lang: 'shellscript', code: 'printf \'%s\\n\' "$HOME"' }, + { lang: 'json', code: '{"ready":true}' }, +] as const + +/** Construct and pre-tokenize the boot grammars outside the user-content scan budget. */ +function createHighlighter(): HighlighterCore { + const instance = createHighlighterCoreSync({ themes: [cssVariablesTheme], langs: LANGS, engine: regexEngine, }) + for (const sample of BOOT_GRAMMAR_WARMUPS) { + instance.codeToTokens(sample.code, { + lang: sample.lang, + theme: 'css-variables', + tokenizeTimeLimit: 0, + }) + } + return instance +} + +/** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */ +function highlighter(): HighlighterCore { + singleton ??= createHighlighter() return singleton } diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index b37ec9e66e..03594c4954 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -416,7 +416,7 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) - it('snapshots a created project skill through catalog refresh and progressive loading', async () => { + it('snapshots a created project skill through catalog refresh and progressive loading', { timeout: 15_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-')) const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-home-')) try { @@ -449,6 +449,15 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(LocalFileSystem, { cwd: root }) await ctx.plugin(ToolFs) + ctx.on('tools/post-execute', async (exec, _result, next) => { + const decision = await next() + if (exec.callId === 'write-skill') { + await vi.waitFor(async () => { + expect((await ctx.skills.list({ cwd: root })).map(skill => skill.name)).toContain('hot-skill') + }, { timeout: 5_000 }) + } + return decision + }) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ sessionId: SessionId('skill-refresh-session'), diff --git a/packages/scaffold/server/tests/server.spec.ts b/packages/scaffold/server/tests/server.spec.ts index c9d1944781..fd83058115 100644 --- a/packages/scaffold/server/tests/server.spec.ts +++ b/packages/scaffold/server/tests/server.spec.ts @@ -109,7 +109,7 @@ async function settleSubagent( } describe('HarnessSdkServer', () => { - it('creates a harness agent and calls the configured OpenAI-compatible endpoint', async () => { + it('creates a harness agent and calls the configured OpenAI-compatible endpoint', { timeout: 15_000 }, async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-')) const llmServer = await mockCompletionServer() vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') @@ -295,7 +295,7 @@ describe('HarnessSdkServer', () => { } }) - it('creates an SDK session without an optional system prompt', async () => { + it('creates an SDK session without an optional system prompt', { timeout: 15_000 }, async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-')) const llmServer = await mockCompletionServer() vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 1a63d8e42d..0d3cb23cd4 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -42,7 +42,7 @@ describe('CI workflow', () => { expect(windows['runs-on']).toBe('windows-2025') expect(windows.name).toBe('windows node 24 / native complete') - expect(windows.env).toMatchObject({ DSH_COVERAGE_MAX_WORKERS: '4' }) + expect(windows.env).toMatchObject({ DSH_COVERAGE_MAX_WORKERS: '1' }) expect(commandSteps).toHaveLength(3) expect(commandSteps.every(step => step.shell === 'pwsh')).toBe(true) expect(commandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 118a2023b9..cd329d3412 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/scripts/translation-pairing-merge.spec.ts @@ -19,7 +19,7 @@ import { const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url)) const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url)) const workspaceRoot = fileURLToPath(new URL('../', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx/esm')) +const tsxLoader = import.meta.resolve('tsx/esm') const fixtures: string[] = [] interface Fixture { @@ -225,7 +225,7 @@ function expectMergedPair(fixture: Fixture): void { ) } -describe('translation pairing merge composition', () => { +describe('translation pairing merge composition', { timeout: 15_000 }, () => { it('rejects a pairing-record path outside the repository', () => { const fixture = createFixture(false) diff --git a/vitest.config.ts b/vitest.config.ts index 8830317238..207ca576cc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -70,7 +70,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // 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/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts', + 'packages/session/session-persistence-jsonl/tests/jsonl.spec.ts', 'packages/subagent/subagent-acp/tests/subagent-acp.spec.ts', 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', From bfb456e0c68d2c2ce36cb3ed91036bcbfc16f195 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:12:58 +0800 Subject: [PATCH 16/57] test(windows): close native coverage gaps --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 ++-- ...26-08-08-native-windows-pull-request-ci.md | 4 +++- ...08-08-native-windows-pull-request-ci.zh.md | 4 +++- .../credentials-local/src/index.ts | 2 ++ .../tool-bash-persistent/tests/tools.spec.ts | 24 +++++++++++++++++++ .../settings-local/tests/local.spec.ts | 7 ++++++ .../tests/sqlite-backend.spec.ts | 7 ++++++ .../atomic-write/tests/atomic-write.spec.ts | 16 ++++++++++++- 8 files changed, 63 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 08da0e0a91..ddb1e7a1e4 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: a9e3b1b3ee2c5af55eccb8d14b0315a8fd5eea66 -2026-08-08-native-windows-pull-request-ci.zh.md: 2ebac333d3c2a1de51083f915c41e7dba8138332 +2026-08-08-native-windows-pull-request-ci.md: 419d1138bbd3ab9296b38ed136e03146ceca686d +2026-08-08-native-windows-pull-request-ci.zh.md: 5353b21c5a69e6ad847065d124d1bddb4d793e56 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index a9e3b1b3ee..419d1138bb 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -42,7 +42,9 @@ The same exact hosted run showed that three concurrent instrumented Vitest worke The project-skill composition fixture had one separate eventual-consistency race: on a contended host the agent could begin its next model step after `write` returned but before Chokidar invalidated the skill catalog, moving the replacement catalog behind the subsequent `skill` call. The fixture now holds that post-write tool boundary until the real registry observes `hot-skill`, then retains its strict request-order and durable-transcript assertions. Production code remains asynchronous; the test explicitly waits for the watcher contract it intends to exercise instead of relying on scheduler timing or accepting a different request index. -POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. No supported product source is excluded from Windows coverage to accommodate these differences. +The next exact-head run passed all 10,933 instrumented tests but correctly failed the per-file threshold at 99.95%, exposing five branches that Linux happened to cover. Deterministic cross-platform fixtures now exercise backward PTY scrollback pagination, a settings document that names a directory, an invalid SQLite filename, and an atomic-writer lock beneath a regular-file parent. The credentials provider's remaining `stat` and mode-enforcement arm is intrinsically POSIX, so it carries the same narrow annotated peer ignore used by the durable JSONL and storage backends; its behavior test remains enforced on POSIX. The threshold and source-file inventory remain unchanged. + +POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. Only intrinsically POSIX source arms carry narrow, explained denominator ignores; no source file or platform-independent branch is excluded from Windows coverage to accommodate these differences. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 2ebac333d3..5353b21c5a 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -42,7 +42,9 @@ Status: implemented 项目 skill 组合 fixture 另有一项最终一致性竞态:宿主资源紧张时,agent 可能在 `write` 返回后、Chokidar 使 skill 目录缓存失效前就开始下一次模型步骤,导致替换目录消息落到后续 `skill` 调用之后。现在,fixture 会在写入后的工具边界等待真实注册表观察到 `hot-skill`,然后继续严格断言请求顺序与持久转录。生产代码仍保持异步;测试会显式等待其本来要验证的 watcher 契约,而不是依赖调度时序或接受另一个请求索引。 -POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。没有任何受支持的产品源码为适应这些差异而从 Windows 覆盖率中排除。 +下一次分支头精确运行通过了全部 10,933 项插桩测试,但逐文件阈值仍在 99.95% 正确失败,从而暴露出 5 个此前恰由 Linux 覆盖的分支。新增的确定性跨平台 fixture 会分别覆盖 PTY 向后翻页拼接 scrollback、以目录作为 settings 文档、非法 SQLite 文件名,以及 regular file(普通文件)父级之下的原子写入锁。credentials provider 剩余的 `stat` 与模式位强制分支本质上只属于 POSIX,因此采用与持久 JSONL、storage backend 相同的窄范围、带说明的对等分支忽略;其行为测试仍会在 POSIX 上强制执行。阈值与源码文件清单均未改变。 + +POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。只有本质上属于 POSIX 的源码分支带有窄范围且说明明确的分母忽略;没有任何源码文件或平台无关分支为适应这些差异而从 Windows 覆盖率中排除。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index f6aba278cc..0b6b2272ce 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -108,6 +108,7 @@ async function assertOwnerOnly(filename: string): Promise { return } /* v8 ignore stop */ + /* v8 ignore start -- Windows has no POSIX mode enforcement; POSIX behavior tests enforce this peer. */ let mode: number try { mode = (await stat(filename)).mode @@ -122,6 +123,7 @@ async function assertOwnerOnly(filename: string): Promise { `credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});` + ` run "chmod 600 ${filename}" before starting again`, ) + /* v8 ignore stop */ } /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 7b789cbd1f..73eeeeb69e 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -96,6 +96,7 @@ type StubMode = | 'send-error' | 'prompt-after-idle' | 'empty-page-after-latest' + | 'paged-scrollback' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -211,6 +212,19 @@ class StubPtySession implements PtyBackendSession { return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false } } const lines = this.scrollback.split('\n') + if (this.mode === 'paged-scrollback') { + const offset = request.offset ?? 0 + const end = lines.length - offset + const start = Math.max(0, end - 3) + const returnedLines = end - start + return { + text: lines.slice(start, end).join('\n'), + totalLines: lines.length, + lineBegin: offset, + lineEnd: offset + returnedLines, + truncated: this.historyTruncated, + } + } return { text: this.scrollback, totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length, @@ -402,6 +416,16 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub') }) + it('assembles retained output across backward scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'paged-scrollback' + session.scrollback = '' + + expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub') + }) + it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index bcf9bfc276..967091c89b 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -137,6 +137,13 @@ describe('boot and reads', () => { await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i) }) + it('fails loud when the document path names a directory', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await mkdir(path) + await expect(boot({ path, watch: false })).rejects.toThrow(/EISDIR|directory/i) + }) + it('fails loud on an unsupported extension', async () => { const dir = await tempDir() await expect(boot({ path: join(dir, 'settings.toml'), watch: false })) diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index 8e64fb40f2..37cf7ba122 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -217,6 +217,13 @@ describe('sqlite backend specifics', () => { await chmod(dir, 0o700) }) + it('propagates an invalid database filename before opening SQLite', async () => { + const path = await freshDbPath() + const backend = backendAt(`${path}\0invalid`) + await expect(backend.kv.open(DESCRIPTOR)).rejects.toThrow(/null bytes/i) + await backend.close() + }) + it('preserves the mode of an existing database file', async () => { if (process.platform === 'win32') return const path = await freshDbPath() diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index a917fd13d9..e71e5b7abd 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -2,7 +2,7 @@ import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } fr import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { writeFileAtomic } from '../src/index.ts' +import { withFileLock, writeFileAtomic } from '../src/index.ts' async function scratch(): Promise { return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) @@ -46,3 +46,17 @@ describe('writeFileAtomic', () => { expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) }) }) + +describe('withFileLock', () => { + it('rejects an invalid parent hierarchy before running the operation', async () => { + const dir = await scratch() + const parent = join(dir, 'not-a-directory') + await writeFile(parent, 'occupied') + let called = false + + await expect(withFileLock(join(parent, 'document'), async () => { + called = true + })).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i) + expect(called).toBe(false) + }) +}) From e7e75e79722576e291b90d481a9d634320d55cb9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:50:06 +0800 Subject: [PATCH 17/57] test(windows): cover final native branches --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 2 ++ ...08-08-native-windows-pull-request-ci.zh.md | 2 ++ .../credentials-local/tests/watcher.spec.ts | 35 +++++++++++++++++++ .../tool-bash-persistent/tests/tools.spec.ts | 2 +- 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index ddb1e7a1e4..512779f7a7 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 419d1138bbd3ab9296b38ed136e03146ceca686d -2026-08-08-native-windows-pull-request-ci.zh.md: 5353b21c5a69e6ad847065d124d1bddb4d793e56 +2026-08-08-native-windows-pull-request-ci.md: 0d85517e877694a09adf454a9acad3da78ebf0e1 +2026-08-08-native-windows-pull-request-ci.zh.md: 4c7dfe6f1ec290a1dee4cb91f7e78ea7a52f32bb diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 419d1138bb..0d85517e87 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -44,6 +44,8 @@ The project-skill composition fixture had one separate eventual-consistency race The next exact-head run passed all 10,933 instrumented tests but correctly failed the per-file threshold at 99.95%, exposing five branches that Linux happened to cover. Deterministic cross-platform fixtures now exercise backward PTY scrollback pagination, a settings document that names a directory, an invalid SQLite filename, and an atomic-writer lock beneath a regular-file parent. The credentials provider's remaining `stat` and mode-enforcement arm is intrinsically POSIX, so it carries the same narrow annotated peer ignore used by the durable JSONL and storage backends; its behavior test remains enforced on POSIX. The threshold and source-file inventory remain unchanged. +The follow-up exact-head run passed all 10,937 instrumented tests and narrowed the threshold result to 99.99%. Its two remaining lines showed that the first PTY fixture had reached the page-offset helper but only supplied two pages, and that Windows path canonicalization rejected the real invalid-path fixture before `readFile` reached the reload-policy branch. The PTY fixture now supplies three backward pages, while the watcher fixture injects one non-absence read failure after the real permission check; both retain the observable output or last-good-snapshot assertions they exist to prove. + POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. Only intrinsically POSIX source arms carry narrow, explained denominator ignores; no source file or platform-independent branch is excluded from Windows coverage to accommodate these differences. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 5353b21c5a..4c7dfe6f1e 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -44,6 +44,8 @@ Status: implemented 下一次分支头精确运行通过了全部 10,933 项插桩测试,但逐文件阈值仍在 99.95% 正确失败,从而暴露出 5 个此前恰由 Linux 覆盖的分支。新增的确定性跨平台 fixture 会分别覆盖 PTY 向后翻页拼接 scrollback、以目录作为 settings 文档、非法 SQLite 文件名,以及 regular file(普通文件)父级之下的原子写入锁。credentials provider 剩余的 `stat` 与模式位强制分支本质上只属于 POSIX,因此采用与持久 JSONL、storage backend 相同的窄范围、带说明的对等分支忽略;其行为测试仍会在 POSIX 上强制执行。阈值与源码文件清单均未改变。 +后续分支头精确运行通过了全部 10,937 项插桩测试,并把阈值结果收窄到 99.99%。剩余的两行表明,首版 PTY fixture 已到达页偏移 helper,却只提供了两页数据;Windows 路径规范化还会在 `readFile` 到达 reload policy(重载策略)分支前,先拒绝真实的非法路径 fixture。PTY fixture 现在会提供 3 个向后翻页页面;watcher fixture 则会在真实权限检查之后注入一次非“文件不存在”的读取失败。两项 fixture 仍保留其本来要证明的可观察输出或 last-good snapshot(最后有效快照)断言。 + POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。只有本质上属于 POSIX 的源码分支带有窄范围且说明明确的分母忽略;没有任何源码文件或平台无关分支为适应这些差异而从 Windows 覆盖率中排除。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 8c216e6976..e49f1421b4 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -6,6 +6,25 @@ import { join } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '../src/index.ts' +const fsHarness = vi.hoisted(() => ({ + nextReadError: undefined as NodeJS.ErrnoException | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readFile: (async (path: unknown, ...rest: never[]) => { + const error = fsHarness.nextReadError + if (error !== undefined) { + fsHarness.nextReadError = undefined + throw error + } + return (actual.readFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.readFile, + } +}) + /** Credential documents are seeded owner-only, exactly as the provider creates them. */ function writeCredentials(file: string, text: string): Promise { return writeFile(file, text, { mode: 0o600 }) @@ -48,6 +67,7 @@ const KEY = credentialRef('DSH_CRED_PIPE') const cleanups: Array<() => Promise> = [] afterEach(async () => { + fsHarness.nextReadError = undefined while (cleanups.length > 0) await cleanups.pop()!() ;(await fakeInstances()).length = 0 }) @@ -107,6 +127,21 @@ describe('watcher pipeline', () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) }) + it('keeps the last good snapshot when the read fails after its permission check', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeCredentials(path, 'DSH_CRED_PIPE: good\n') + const ctx = await boot({ path, debounceMs: 5 }) + fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' }) + + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(() => { + expect(fsHarness.nextReadError).toBeUndefined() + }) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) + }) + it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 73eeeeb69e..bcff35b2a4 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -421,7 +421,7 @@ describe('tool-bash-persistent', () => { await call(ctx, owner, 'warm up') const session = stub.sessions[0]! session.mode = 'paged-scrollback' - session.scrollback = '' + session.scrollback = 'older one\nolder two\nolder three\nolder four\n' expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub') }) From 91cae30fc5aa67684fa5315b883c38113a009774 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:20:52 +0800 Subject: [PATCH 18/57] test(windows): separate pwsh integration budgets --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 2 ++ .../process/2026-08-08-native-windows-pull-request-ci.zh.md | 2 ++ packages/bash/pwsh-local/tests/executor.spec.ts | 6 +++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 512779f7a7..618e9e2221 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 0d85517e877694a09adf454a9acad3da78ebf0e1 -2026-08-08-native-windows-pull-request-ci.zh.md: 4c7dfe6f1ec290a1dee4cb91f7e78ea7a52f32bb +2026-08-08-native-windows-pull-request-ci.md: 16d1aaea0df14e1d8804564090860e20da7054c0 +2026-08-08-native-windows-pull-request-ci.zh.md: e99cb4ab0fec62d8ba84955a570db58e7dbf88c2 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 0d85517e87..16d1aaea0d 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -46,6 +46,8 @@ The next exact-head run passed all 10,933 instrumented tests but correctly faile The follow-up exact-head run passed all 10,937 instrumented tests and narrowed the threshold result to 99.99%. Its two remaining lines showed that the first PTY fixture had reached the page-offset helper but only supplied two pages, and that Windows path canonicalization rejected the real invalid-path fixture before `readFile` reached the reload-policy branch. The PTY fixture now supplies three backward pages, while the watcher fixture injects one non-absence read failure after the real permission check; both retain the observable output or last-good-snapshot assertions they exist to prove. +The next run reached the repaired branches but one real PowerShell executor composition case timed out at Vitest's five-second ceiling before the coverage report. That fixture had configured the product timeout to the same five seconds as the test timeout, leaving no budget for the executor to return either its owned result or its owned timeout classification under instrumentation. The command now has a ten-second product budget and the integration test a fifteen-second ceiling; its exit code, output, and resolved-timeout assertions are unchanged. + POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. Only intrinsically POSIX source arms carry narrow, explained denominator ignores; no source file or platform-independent branch is excluded from Windows coverage to accommodate these differences. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 4c7dfe6f1e..e99cb4ab0f 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -46,6 +46,8 @@ Status: implemented 后续分支头精确运行通过了全部 10,937 项插桩测试,并把阈值结果收窄到 99.99%。剩余的两行表明,首版 PTY fixture 已到达页偏移 helper,却只提供了两页数据;Windows 路径规范化还会在 `readFile` 到达 reload policy(重载策略)分支前,先拒绝真实的非法路径 fixture。PTY fixture 现在会提供 3 个向后翻页页面;watcher fixture 则会在真实权限检查之后注入一次非“文件不存在”的读取失败。两项 fixture 仍保留其本来要证明的可观察输出或 last-good snapshot(最后有效快照)断言。 +再下一次运行已到达修复后的分支,但一项真实 PowerShell executor 组合用例会在生成覆盖率报告前,触及 Vitest 的 5 秒上限。该 fixture 把产品超时和测试超时都配置成了同样的 5 秒;插桩环境下,executor 因而没有余量返回其自有结果或自有超时分类。现在,命令的产品预算为 10 秒,集成测试上限为 15 秒;退出码、输出和解析后超时值的断言均未改变。 + POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。只有本质上属于 POSIX 的源码分支带有窄范围且说明明确的分母忽略;没有任何源码文件或平台无关分支为适应这些差异而从 Windows 覆盖率中排除。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index 5c5c7fa35e..ef5e972fc0 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -167,12 +167,12 @@ describe('spawn construction (pure, every platform)', () => { }) describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { - it('resolves with output and the effective timeout', async () => { - const { bash } = await setup({ timeoutMs: 5_000 }) + it('resolves with output and the effective timeout', { timeout: 15_000 }, async () => { + const { bash } = await setup({ timeoutMs: 10_000 }) const result = await bash.run(bash.resolve({ command: 'Write-Output hi' })) expect(result.exitCode).toBe(0) expect(lf(result.stdout.text)).toBe('hi\n') - expect(result.timeoutMs).toBe(5_000) + expect(result.timeoutMs).toBe(10_000) }) it('uses config cwd, overridable per call', async () => { From 2ce8d88a4df9af0bed736d48ae17d855f5d9ed61 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:03:18 +0800 Subject: [PATCH 19/57] test(windows): eliminate final coverage races --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 2 ++ ...08-08-native-windows-pull-request-ci.zh.md | 2 ++ .../e2b/subprocess-e2b/tests/terminal.spec.ts | 15 ++++++----- .../llm/llm-pi-ai/tests/discovery.spec.ts | 26 ++++++++++++++++--- .../tool-bash-persistent/tests/tools.spec.ts | 13 ++++++++-- 6 files changed, 47 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 618e9e2221..65875fa9f0 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 16d1aaea0df14e1d8804564090860e20da7054c0 -2026-08-08-native-windows-pull-request-ci.zh.md: e99cb4ab0fec62d8ba84955a570db58e7dbf88c2 +2026-08-08-native-windows-pull-request-ci.md: 12334e8b4a3397dd3fb13597097d39ef7e793835 +2026-08-08-native-windows-pull-request-ci.zh.md: 728c0852bd14994a0ed6051edd981d3a63eec908 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 16d1aaea0d..12334e8b4a 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -48,6 +48,8 @@ The follow-up exact-head run passed all 10,937 instrumented tests and narrowed t The next run reached the repaired branches but one real PowerShell executor composition case timed out at Vitest's five-second ceiling before the coverage report. That fixture had configured the product timeout to the same five seconds as the test timeout, leaving no budget for the executor to return either its owned result or its owned timeout classification under instrumentation. The command now has a ten-second product budget and the integration test a fifteen-second ceiling; its exit code, output, and resolved-timeout assertions are unchanged. +The following exact-head run passed all 10,938 instrumented tests and isolated four remaining locations whose existing fixtures depended on host scheduling. The E2B service fixture now injects and observes an immediate automatic terminal-release rejection before proving disposal retries it. The pi-ai discovery fixture drives cancellation from a controlled response body read instead of racing a local socket timer, and the persistent-bash fixture makes an incremental PTY delta the only recoverable output before asserting the rendered fallback. These cases exercise the supported branches directly on every host; the coverage inventory and denominator remain unchanged. + POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. Only intrinsically POSIX source arms carry narrow, explained denominator ignores; no source file or platform-independent branch is excluded from Windows coverage to accommodate these differences. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index e99cb4ab0f..728c0852bd 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -48,6 +48,8 @@ Status: implemented 再下一次运行已到达修复后的分支,但一项真实 PowerShell executor 组合用例会在生成覆盖率报告前,触及 Vitest 的 5 秒上限。该 fixture 把产品超时和测试超时都配置成了同样的 5 秒;插桩环境下,executor 因而没有余量返回其自有结果或自有超时分类。现在,命令的产品预算为 10 秒,集成测试上限为 15 秒;退出码、输出和解析后超时值的断言均未改变。 +随后的分支头精确运行通过了全部 10,938 项插桩测试,并隔离出 4 个现有 fixture 依赖宿主调度的剩余位置。E2B 服务 fixture 现在会注入并观察一次立即发生的终端自动释放拒绝,再证明服务释放会重试该终端。pi-ai 发现 fixture 改为从受控响应 body 的读取过程触发取消,不再与本地 socket 定时器竞速;persistent-bash fixture 则让 PTY 增量片段成为唯一可恢复输出,再断言渲染后的回退结果。这些用例会在每种宿主上直接执行受支持分支;覆盖率清单与分母均未改变。 + POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。只有本质上属于 POSIX 的源码分支带有窄范围且说明明确的分母忽略;没有任何源码文件或平台无关分支为适应这些差异而从 Windows 覆盖率中排除。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index 5472b2cfa7..26762fcda8 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -911,16 +911,17 @@ describe('E2B subprocess terminal service', () => { it('contains a failed automatic terminal release until service disposal retries it', async () => { const { fiber, fake } = await service() - fake.clearOnTerm = false - fake.clearOnKill = false - const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec({ graceMs: 1 })) + fake.groups = [] + const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec()) + const terminate = vi.spyOn(terminal, 'terminate') + .mockRejectedValueOnce(new Error('automatic release failed')) fake.handle.succeed(0) await terminal.done - await new Promise(resolve => setTimeout(resolve, 10)) - expect(fake.commands).toContain('kill -KILL -- -123') + await vi.waitFor(() => { expect(terminate).toHaveBeenCalledTimes(1) }) + await new Promise(resolve => setTimeout(resolve, 0)) - fake.groups = [] await fiber.dispose() - await expect(terminal.terminate()).resolves.toBeUndefined() + expect(terminate).toHaveBeenCalledTimes(2) + expect(fake.handle.disconnects).toBe(1) }) }) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 85221e7ca2..02b859d5f2 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -275,10 +275,28 @@ describe('draft-provider model discovery', () => { it('reports cancellation during the body read as an abort, not a raw reason', async () => { const ctx = await harness() const controller = new AbortController() - // Chunked, so the headers arrive and the cancellation lands mid-body. - const slow = await listingServer({ chunks: ['{"data":[', '{"id":"a"}'], holdOpenMs: 400 }) - const probe = ctx.llm.discoverModels('llm-pi-ai', { baseURL: slow.url, signal: controller.signal }) - setTimeout(() => { controller.abort('test cancellation') }, 40) + const bodyRead = Promise.withResolvers() + vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => { + const signal = init?.signal + if (signal === undefined || signal === null) throw new Error('expected a discovery signal') + return new Response(new ReadableStream({ + pull(stream) { + bodyRead.resolve(undefined) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { + stream.error(signal.reason) + resolve() + }, { once: true }) + }) + }, + })) + }) + const probe = ctx.llm.discoverModels('llm-pi-ai', { + baseURL: 'https://slow.example/v1', + signal: controller.signal, + }) + await bodyRead.promise + controller.abort('test cancellation') await expect(probe).rejects.toMatchObject({ code: 'ABORTED' }) }) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index bcff35b2a4..28a950eacf 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -95,6 +95,7 @@ type StubMode = | 'spawn-error' | 'send-error' | 'prompt-after-idle' + | 'incremental-fallback' | 'empty-page-after-latest' | 'paged-scrollback' @@ -166,6 +167,10 @@ class StubPtySession implements PtyBackendSession { this.pendingText = '' const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0] const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0] + if (this.mode === 'incremental-fallback') { + const incremental = `${start ?? ''}\nincrement\n${this.motd}` + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) + } if (this.mode === 'torn-status') { const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` this.scrollback += output @@ -251,10 +256,10 @@ class StubPtySession implements PtyBackendSession { return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } } - private operation(done: Promise>): PtySendOperation { + private operation(done: Promise>, delta = ''): PtySendOperation { return { done, - readOutput: () => ({ delta: '', truncated: false }), + readOutput: () => ({ delta, truncated: false }), cancel: () => false, } } @@ -331,6 +336,10 @@ describe('tool-bash-persistent', () => { session.mode = 'idle-then-normal' expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from') + session.mode = 'incremental-fallback' + session.scrollback = '' + expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment') + session.mode = 'prompt-only' const promptFallback = text(await call(ctx, owner, 'bad {')) expect(promptFallback).toContain('bash: synt') From dd2082c82c9a40d1eca33062e55547aa54f625e5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:16:25 +0800 Subject: [PATCH 20/57] test(windows): preserve E2B cleanup coverage --- ...08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../2026-08-08-native-windows-pull-request-ci.md | 2 +- ...026-08-08-native-windows-pull-request-ci.zh.md | 2 +- .../e2b/subprocess-e2b/tests/terminal.spec.ts | 15 +++++++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 65875fa9f0..cabf14745d 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 12334e8b4a3397dd3fb13597097d39ef7e793835 -2026-08-08-native-windows-pull-request-ci.zh.md: 728c0852bd14994a0ed6051edd981d3a63eec908 +2026-08-08-native-windows-pull-request-ci.md: b43653125aa9c142ec010ddea9928961df854ccb +2026-08-08-native-windows-pull-request-ci.zh.md: 61aa9ad914f46575d925184f53a74cd4e89b145e diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 12334e8b4a..b43653125a 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -48,7 +48,7 @@ The follow-up exact-head run passed all 10,937 instrumented tests and narrowed t The next run reached the repaired branches but one real PowerShell executor composition case timed out at Vitest's five-second ceiling before the coverage report. That fixture had configured the product timeout to the same five seconds as the test timeout, leaving no budget for the executor to return either its owned result or its owned timeout classification under instrumentation. The command now has a ten-second product budget and the integration test a fifteen-second ceiling; its exit code, output, and resolved-timeout assertions are unchanged. -The following exact-head run passed all 10,938 instrumented tests and isolated four remaining locations whose existing fixtures depended on host scheduling. The E2B service fixture now injects and observes an immediate automatic terminal-release rejection before proving disposal retries it. The pi-ai discovery fixture drives cancellation from a controlled response body read instead of racing a local socket timer, and the persistent-bash fixture makes an incremental PTY delta the only recoverable output before asserting the rendered fallback. These cases exercise the supported branches directly on every host; the coverage inventory and denominator remain unchanged. +The following exact-head run passed all 10,938 instrumented tests and isolated four remaining locations whose existing fixtures depended on host scheduling. The E2B service retains its real surviving-group cleanup fixture and separately injects and observes an immediate automatic terminal-release rejection before proving disposal retries it. The pi-ai discovery fixture drives cancellation from a controlled response body read instead of racing a local socket timer, and the persistent-bash fixture makes an incremental PTY delta the only recoverable output before asserting the rendered fallback. These cases exercise the supported branches directly on every host; the coverage inventory and denominator remain unchanged. POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. Only intrinsically POSIX source arms carry narrow, explained denominator ignores; no source file or platform-independent branch is excluded from Windows coverage to accommodate these differences. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 728c0852bd..61aa9ad914 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -48,7 +48,7 @@ Status: implemented 再下一次运行已到达修复后的分支,但一项真实 PowerShell executor 组合用例会在生成覆盖率报告前,触及 Vitest 的 5 秒上限。该 fixture 把产品超时和测试超时都配置成了同样的 5 秒;插桩环境下,executor 因而没有余量返回其自有结果或自有超时分类。现在,命令的产品预算为 10 秒,集成测试上限为 15 秒;退出码、输出和解析后超时值的断言均未改变。 -随后的分支头精确运行通过了全部 10,938 项插桩测试,并隔离出 4 个现有 fixture 依赖宿主调度的剩余位置。E2B 服务 fixture 现在会注入并观察一次立即发生的终端自动释放拒绝,再证明服务释放会重试该终端。pi-ai 发现 fixture 改为从受控响应 body 的读取过程触发取消,不再与本地 socket 定时器竞速;persistent-bash fixture 则让 PTY 增量片段成为唯一可恢复输出,再断言渲染后的回退结果。这些用例会在每种宿主上直接执行受支持分支;覆盖率清单与分母均未改变。 +随后的分支头精确运行通过了全部 10,938 项插桩测试,并隔离出 4 个现有 fixture 依赖宿主调度的剩余位置。E2B 服务会保留真实的存活进程组清理 fixture,并另行注入并观察一次立即发生的终端自动释放拒绝,再证明服务释放会重试该终端。pi-ai 发现 fixture 改为从受控响应 body 的读取过程触发取消,不再与本地 socket 定时器竞速;persistent-bash fixture 则让 PTY 增量片段成为唯一可恢复输出,再断言渲染后的回退结果。这些用例会在每种宿主上直接执行受支持分支;覆盖率清单与分母均未改变。 POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。只有本质上属于 POSIX 的源码分支带有窄范围且说明明确的分母忽略;没有任何源码文件或平台无关分支为适应这些差异而从 Windows 覆盖率中排除。 diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index 26762fcda8..a142817870 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -910,6 +910,21 @@ describe('E2B subprocess terminal service', () => { }) it('contains a failed automatic terminal release until service disposal retries it', async () => { + const { fiber, fake } = await service() + fake.clearOnTerm = false + fake.clearOnKill = false + const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec({ graceMs: 1 })) + fake.handle.succeed(0) + await terminal.done + await new Promise(resolve => setTimeout(resolve, 10)) + expect(fake.commands).toContain('kill -KILL -- -123') + + fake.groups = [] + await fiber.dispose() + await expect(terminal.terminate()).resolves.toBeUndefined() + }) + + it('contains an immediate automatic terminal release rejection before disposal retries it', async () => { const { fiber, fake } = await service() fake.groups = [] const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec()) From ffb1f187ee4b49e3d7f190424ecc3ea0ff3f2552 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:21:41 +0800 Subject: [PATCH 21/57] ci: keep Wine required beside native Windows --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +- ...7-21-serial-cross-platform-ci-reference.md | 6 +- ...1-serial-cross-platform-ci-reference.zh.md | 6 +- ...ortable-required-pull-request-ci.i18n.yaml | 4 +- ...07-23-portable-required-pull-request-ci.md | 8 +- ...23-portable-required-pull-request-ci.zh.md | 8 +- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +- ...m-action-setup-for-symmetric-ci-caching.md | 4 +- ...ction-setup-for-symmetric-ci-caching.zh.md | 4 +- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 28 +- ...08-08-native-windows-pull-request-ci.zh.md | 28 +- .github/AGENTS.md | 2 +- .github/workflows/ci.yml | 126 ++++++++- AGENTS.md | 1 + package.json | 1 + scripts/ci-workflow.spec.ts | 40 ++- scripts/wine-windows-gates.sh | 247 ++++++++++++++++++ 18 files changed, 451 insertions(+), 74 deletions(-) create mode 100755 scripts/wine-windows-gates.sh 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 dcb6e18e88..67b965e4aa 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 @@ -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-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: dfd762ad4d4eec61bc3a17d96865ccc4ac936ab3 -2026-07-21-serial-cross-platform-ci-reference.zh.md: 580340db2fbfc14458a4bbfbfa157db75e74e027 +2026-07-21-serial-cross-platform-ci-reference.md: dd41cdb51bd226b19bc6aca498c7713a3cc9a6b7 +2026-07-21-serial-cross-platform-ci-reference.zh.md: c72872d77f5d821b0fc203bda8bc1944e6929d73 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 dfd762ad4d..dd41cdb51b 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 @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## 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 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. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. 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 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. @@ -28,13 +28,13 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. 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. The required pull-request Windows job also runs on standard `windows-2025` under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard 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. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses standard `windows-2025` under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard 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. ## Alternatives considered - **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression. - **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check. -- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts. +- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Wine-hosted Windows contracts, and the independent native job supplies the complete Windows result. - **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal. - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism. - **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs. 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 580340db2f..c72872d77f 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 @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[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` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 契约;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `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 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 @@ -28,13 +28,13 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.md),拉取请求必需的 Windows 作业也在标准 `windows-2025` 上运行;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)使用标准 `windows-2025`;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 - **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。 -- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。 +- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和由 Wine 承载的 Windows 契约,而独立原生作业提供完整的 Windows 结果。 - **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。 - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 - **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。 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 768e4298c7..d5368b8b45 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 .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 11de2b1dcbbdbdd7f9423131cd84c2940ac9218d -2026-07-23-portable-required-pull-request-ci.zh.md: 02f5d758e1259d6a8e7cba70e439775bd1df2b28 +2026-07-23-portable-required-pull-request-ci.md: b7c637408afa32611c4e70073ddec9aaabfba7f5 +2026-07-23-portable-required-pull-request-ci.zh.md: 78cf1cce55920f7153f6a9ca6673dd172174d8dc 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 11de2b1dcb..b7c637408a 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,9 +12,9 @@ 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 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs on standard `windows-2025` under native Windows for the blocking surfaces and broader observational inventory ([native Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial references remain the complete unsharded cross-platform definitions. 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 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial references remain the complete unsharded cross-platform definitions. 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 / native complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. +The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. @@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) ## Consequences -Ordinary pull requests spend enterprise capacity on the Linux critical path while the native Windows job uses standard Windows allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. +Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine job keeps the required Windows verdict on standard Linux allocation. The independent native job uses standard Windows allocation without delaying or changing the aggregate. A live exact-head run distinguishes the commands branch protection consumes from the separate diagnostic contract; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. -Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work. +Standard compatibility, required Wine, and diagnostic native Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work. 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 02f5d758e1..78cf1cce55 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,9 +12,9 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `windows-2025` 上以原生 Windows 环境运行,覆盖阻断性检查范围和更广泛的观测性清单([原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 -两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / native complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 +两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而原生 Windows 作业使用标准 Windows 运行器容量。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 +普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 作业让必需的 Windows 判定继续使用标准 Linux 运行器容量。独立原生作业使用标准 Windows 运行器容量,不会延迟或改变聚合流程。一次实际的分支头精确运行会区分分支保护采用的命令与单独的诊断契约;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 -企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。 +企业级运行器分配能力下降时,标准兼容性作业、必需的 Wine 作业与诊断性原生 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。 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 224544c058..86a1e15688 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 .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: 6fba2ce3b508d9d6fc879460a63c0e12673bd866 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: d9da8a4abe13772f3cc282392c03cdea54726c01 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 3d35d532216c1644f0d65039574453752e9be7ac +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 9e74a0149c9dcd17a88e9ff74a4b29fdc8916f32 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 6fba2ce3b5..3d35d53221 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 @@ -13,8 +13,8 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i `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): the three enterprise-runner PR jobs 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): the required native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required 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): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered 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 d9da8a4abe..9e74a0149c 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 @@ -13,8 +13,8 @@ Status: implemented `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` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 -- **无缓存或持久化**(不使用 store 缓存 action):必需的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 +- **无缓存或持久化**(不使用 store 缓存 action):独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index b1b0079115..8cc2763444 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 80fccabdc9ddebba2204732677da31363b74a184 -2026-08-08-native-windows-pull-request-ci.zh.md: 9d00f4fad6eb817968609c3af1f278cc6794e7c7 +2026-08-08-native-windows-pull-request-ci.md: e98de723dfe118a297a2e9074d94d52dd6dddc47 +2026-08-08-native-windows-pull-request-ci.zh.md: 02ad94b095c8fd1361c4b113d486f8091bb9cf5a diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 80fccabdc9..e98de723df 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -1,4 +1,4 @@ -# Agent Note: Native Windows pull-request CI +# Agent Note: Dual Wine and native Windows pull-request CI Status: implemented @@ -6,34 +6,36 @@ English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) ## Problem -The required pull-request Windows verdict must protect behavior that depends on the operating system, not only toolchain branches selected by `process.platform`. The Wine lane executed Windows Node and PE binaries over a Linux kernel and case-sensitive ext4, required a hoisted dependency layout and host-created symlinks, and omitted NTFS, DACL, ConPTY, crash-durability, and the broader observational Windows inventory. With the native serial references disabled, ordinary CI had no real Windows-kernel signal. +The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. The Wine lane provides that critical-path signal but executes over a Linux kernel and case-sensitive ext4, requires a hoisted dependency layout and host-created symlinks, and cannot prove NTFS, DACL, ConPTY, crash-durability, or native process behavior. With the native serial references disabled, ordinary CI also needs an automatic real Windows-kernel result on every pull-request head even when that result is not part of branch protection. ## Decision -The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) runs on GitHub's standard `windows-2025` image under native PowerShell. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete`. The stable `windows` job id remains a dependency of `all checks passed`; its display name is `windows node 24 / native complete`. +The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that run the workspace build and production site. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -The aggregate keeps workspace build and production-site failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. +Every pull request also starts an independent `windows-native` job named `windows node 24 / native complete` on GitHub's standard `windows-2025` image. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. The job is deliberately absent from `all-checks-passed.needs`: the aggregate neither waits for it nor changes conclusion because of it, while the native job retains its own unmasked success or failure result. + +The native gate keeps workspace build and production-site failures blocking inside its own job while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. -Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. - ## Alternatives considered -**Keep Wine on the required path.** Its warm wall clock was close to Linux CI and it selected win32 toolchain branches, but the compatibility-specific layout and kernel gaps could report green while supported native behavior was broken. Latency no longer outweighs that missing signal. +**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the longest hosted job and for Windows capacity. The independent result keeps that signal automatic without changing the existing required path. -**Restore the pre-Wine workflow verbatim.** The old definition captured the right runner boundary but also carried then-current provisioning and topology assumptions. Reconstructing the native job against the current actions, pnpm setup, gate graph, and aggregate dependency avoids reviving obsolete machinery. +**Run only Wine on pull requests.** Wine reaches the blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. -**Run native Windows only after merge.** A post-merge reference diagnoses portability regressions after they enter `master`; it cannot protect a pull request while those references are disabled or delayed. +**Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism. -**Use an organization-owned larger Windows runner.** Larger images can reduce wall clock, but a required correctness path would then depend on repository-external labels and allocation. Standard `windows-2025` is the portable recovery boundary; larger runners remain benchmark targets. +**Run native Windows only after merge.** A post-merge reference diagnoses portability regressions after they enter `master`; it does not give reviewers an exact-head native result. + +**Use an organization-owned larger Windows runner.** Larger images can reduce wall clock, but the diagnostic path would then depend on repository-external labels and allocation. Standard `windows-2025` is portable; larger runners remain benchmark targets. ## Consequences -Pull requests receive a real NT kernel, NTFS, PowerShell, Windows process, and native addon signal before the aggregate can pass. The job is slower than the Wine compatibility lane and can queue on Windows capacity, but its green result now describes the supported host rather than an approximation. +Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result. -The native lane is also a portability inventory: its exact-head acceptance requires every blocking gate to pass and the final summary to contain no non-blocking failure. That distinction caught path contracts which a successful wrapper conclusion alone would have concealed. +Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, and native addon signal. The native job is slower than Wine and duplicates setup plus the two blocking builds, but it also executes the portability inventory that exposed path, watcher, and lifecycle defects hidden by the compatibility lane. -Removing the Wine cache producer and local script deletes a separate install topology and its recurring compatibility failures. Native coverage and snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before becoming part of this required lane. +Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native job uses the immutable workspace on Windows. A failure unique to either job must be classified against that boundary rather than weakened or silently skipped. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 9d00f4fad6..02ad94b095 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 原生 Windows 拉取请求 CI +# Agent Note: Wine 与原生 Windows 双通道拉取请求 CI Status: implemented @@ -6,34 +6,36 @@ Status: implemented ## 问题 -拉取请求必需的 Windows 判定必须为依赖操作系统的行为提供保障,而不能只覆盖由 `process.platform` 选择的工具链分支。Wine 通道在 Linux 内核与区分大小写的 ext4 之上执行 Windows Node 和 PE 二进制文件,要求采用 hoisted 依赖布局和由宿主侧创建的符号链接,也没有覆盖 NTFS、DACL、ConPTY、崩溃持久性与更广泛的观测性 Windows 清单。原生串行参考流程停用期间,常规 CI 没有任何真实的 Windows 内核信号。 +拉取请求必需的 Windows 判定既需要快速的 win32 工具链信号,也不能让聚合流程等待稀缺的 Windows 容量。Wine 通道提供这项关键路径信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,要求采用 hoisted 依赖布局和由宿主侧创建的符号链接,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,即使真实 Windows 内核结果不属于分支保护,常规 CI 也需要针对每个拉取请求分支头自动产出该结果。 ## 决策 -[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业在 GitHub 标准 `windows-2025` 镜像上使用原生 PowerShell 运行。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并运行 `pnpm run check:ci:windows-complete`。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项;其显示名称为 `windows node 24 / native complete`。 +[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -聚合作业继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 +每个拉取请求还会在 GitHub 标准 `windows-2025` 镜像上启动一个独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。该作业被刻意排除在 `all-checks-passed.needs` 之外:聚合流程既不等待它,也不会因它改变结论;原生作业则保留自身未被掩盖的成功或失败结果。 + +原生门禁在其自身作业内继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture(测试前置数据)通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 -受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 - ## 曾考虑的替代方案 -**在必需路径上保留 Wine。** 其热运行墙钟时间接近 Linux CI,也会选择 win32 工具链分支;但兼容性专属布局和内核缺口可能在受支持的原生行为已经损坏时仍报告绿灯。这项延迟收益不足以抵消该信号缺失的代价。 +**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最长的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。 -**逐字恢复 Wine 之前的工作流。** 旧定义确实捕获了正确的运行器边界,但也携带了当时的供应方式与拓扑假设。按照当前 action、pnpm 设置、门禁图和聚合依赖关系重新构建原生作业,可以避免重新引入过时机制。 +**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性的 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件契约已经损坏,也可能报告绿灯。 -**只在合并后运行原生 Windows。** 合并后的参考流程只能在可移植性回归进入 `master` 后进行诊断;当这些参考流程被停用或延迟时,无法在拉取请求阶段提供保护。 +**将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留普通独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。 -**使用组织自有的大型 Windows 运行器。** 更大规格的运行器镜像可以缩短墙钟时间,但必需的正确性路径将因此依赖仓库外部的运行器标签与分配能力。标准 `windows-2025` 是可移植恢复边界;大型运行器仍作为基准测试目标。 +**只在合并后运行原生 Windows。** 合并后的参考流程只能在可移植性回归进入 `master` 后进行诊断;它无法向评审者提供分支头精确的原生结果。 + +**使用组织自有的大型 Windows 运行器。** 更大规格的运行器镜像可以缩短墙钟时间,但诊断路径将因此依赖仓库外部的运行器标签与分配能力。标准 `windows-2025` 具备可移植性;大型运行器仍作为基准测试目标。 ## 后果 -在聚合作业通过之前,拉取请求会获得来自真实 NT 内核、NTFS、PowerShell、Windows 进程和原生插件的信号。该作业比 Wine 兼容性通道更慢,也可能因 Windows 容量而排队,但其绿灯结果描述的是受支持的宿主,而非近似环境。 +Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。 -原生通道也是一份可移植性清单:其分支头精确验收要求每个阻断门禁都通过,且最终汇总中不包含任何非阻断失败。这一区分发现了路径契约问题;若只看包装层的成功结论,这些问题本会被掩盖。 +尽管如此,每个拉取请求都会获得来自真实 NT 内核、NTFS、PowerShell、Windows 进程和原生插件的信号。原生作业比 Wine 更慢,并重复执行设置流程和两项阻断构建,但它也会运行那份可移植性清单;兼容性通道隐藏的路径、watcher 与生命周期缺陷正是由该清单暴露。 -移除 Wine 缓存生产者和本地脚本后,独立的安装拓扑及其反复出现的兼容性故障也随之消失。原生 Windows 覆盖率与快照仍是明确列出的缺口,不会仅由作业名称暗示已经纳入;二者都必须先建立各自经过测试的契约,才能成为该必需通道的一部分。 +维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在 Windows 上使用不可变工作区。任一作业独有的失败都必须依据该边界分类,不得削弱或静默跳过。 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index c95b85e8e2..c18f5b5948 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is the deliberate exception: it runs Windows Node under Wine on hosted Linux and blocks `all checks passed`; `windows-native` runs automatically on `windows-2025` but reports independently — see the [dual-lane Agent Note](../.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e96d50e50..c6e015da60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,12 +300,121 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # One standard Windows box shares setup across the required build/site checks - # and the observational portability inventory. Serial worker bounds keep this - # recovery path portable; Linux owns duplicate lint, coverage, and snapshots. - # See the native-Windows pull-request CI decision: + # The required pull-request Windows signal: the two blocking win32 surfaces + # (workspace build, production site) execute with real, checksum-verified + # Windows Node under Wine on standard hosted Linux. The independent + # windows-native job below keeps the complete native-kernel inventory — + # including the observational portability gates this lane does not run — + # 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. + # Current topology and fidelity limits live in # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md windows: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + name: windows node 24 / wine blocking + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - 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" + + - uses: actions/cache/restore@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- + + # Master's wine-apt-cache job seeds the default-branch scope every pull + # request can read; a save from this job only reaches reruns of the + # same merge ref. + - name: Compose Wine apt cache key + id: wine-cache-key + run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ~/wine-debs + key: ${{ steps.wine-cache-key.outputs.key }} + + # 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: | + 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 + + - name: Run the Wine Windows gates + run: bash scripts/wine-windows-gates.sh + + - name: Shut down wineserver + if: always() + run: wineserver -k 2>/dev/null || true + + # Master seeds the Wine apt-archive cache in the default-branch scope, + # which every pull request's windows job can restore; saves from + # pull-request runs are scoped to their own merge ref and help nobody + # else. Runs in seconds when the image version already has a cache. + wine-apt-cache: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: wine apt cache + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Compose Wine apt cache key + id: wine-cache-key + run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + id: wine-cache + with: + path: ~/wine-debs + key: ${{ steps.wine-cache-key.outputs.key }} + + - name: Download the Wine dependency closure + if: steps.wine-cache.outputs.cache-hit != 'true' + run: | + 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/" + du -sh "$HOME/wine-debs" + + # Every pull request also gets a real Windows-kernel signal. This job keeps + # its own unmasked conclusion but is deliberately absent from + # all-checks-passed.needs, so it never delays or changes that required verdict. + # See the dual Wine/native pull-request CI decision: + # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md + windows-native: if: github.event_name == 'pull_request' runs-on: windows-2025 name: windows node 24 / native complete @@ -337,7 +446,7 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - - name: Run blocking and observational Windows gates concurrently + - name: Run complete native Windows gate inventory shell: pwsh run: pnpm run check:ci:windows-complete @@ -742,9 +851,10 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`; observational Windows gates share the required Windows job but are - # marked non-blocking inside run-gates. (`needs` cannot reach across workflow - # files; e2e.yml stays its own check.) + # `needs`. The required Wine job is listed as `windows`; `windows-native` is + # deliberately absent so its independent result never delays or changes this + # verdict. (`needs` cannot reach across workflow files; e2e.yml stays its own + # check.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including diff --git a/AGENTS.md b/AGENTS.md index ed9212b6d4..dcb001768e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,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 # 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) diff --git a/package.json b/package.json index ed6d915d6e..864814ffe6 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,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/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 558dc15548..05edaaef55 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -27,26 +27,40 @@ describe('CI workflow', () => { } }) - it('runs the required Windows contract on a native hosted runner', () => { + it('keeps Wine blocking while native Windows reports independently', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') - if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.windows)) { - throw new TypeError('CI workflow must define the windows job') + if (!isRecord(workflow.jobs) + || !isRecord(workflow.jobs.windows) + || !isRecord(workflow.jobs['windows-native']) + || !isRecord(workflow.jobs['all-checks-passed'])) { + throw new TypeError('CI workflow must define Wine, native Windows, and aggregate jobs') } const windows = workflow.jobs.windows - if (!Array.isArray(windows.steps)) throw new TypeError('windows job must define steps') - const steps: unknown[] = windows.steps - const commandSteps = steps.filter((step): step is Record & { run: string } => ( + const windowsNative = workflow.jobs['windows-native'] + const aggregate = workflow.jobs['all-checks-passed'] + if (!Array.isArray(windows.steps) || !Array.isArray(windowsNative.steps) || !Array.isArray(aggregate.needs)) { + throw new TypeError('Windows jobs must define steps and the aggregate must define needs') + } + const nativeCommandSteps = windowsNative.steps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) - expect(windows['runs-on']).toBe('windows-2025') - expect(windows.name).toBe('windows node 24 / native complete') - expect(commandSteps).toHaveLength(3) - expect(commandSteps.every(step => step.shell === 'pwsh')).toBe(true) - expect(commandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') - expect(JSON.stringify(windows)).not.toMatch(/wine/i) - expect(workflow.jobs).not.toHaveProperty('wine-apt-cache') + expect(windows['runs-on']).toBe('ubuntu-latest') + expect(windows.name).toBe('windows node 24 / wine blocking') + expect(windows.if).toBe("github.event_name == 'pull_request'") + expect(JSON.stringify(windows)).toContain('bash scripts/wine-windows-gates.sh') + expect(workflow.jobs).toHaveProperty('wine-apt-cache') + expect(windowsNative['runs-on']).toBe('windows-2025') + expect(windowsNative.name).toBe('windows node 24 / native complete') + expect(windowsNative.if).toBe("github.event_name == 'pull_request'") + expect(windowsNative).not.toHaveProperty('continue-on-error') + expect(nativeCommandSteps).toHaveLength(3) + expect(nativeCommandSteps.every(step => step.shell === 'pwsh')).toBe(true) + expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') + expect(JSON.stringify(windowsNative)).not.toMatch(/wine/i) + expect(aggregate.needs).toContain('windows') + expect(aggregate.needs).not.toContain('windows-native') }) }) diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh new file mode 100755 index 0000000000..9bf904a767 --- /dev/null +++ b/scripts/wine-windows-gates.sh @@ -0,0 +1,247 @@ +#!/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 and fidelity limits: +# .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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 +# 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 + printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2 + 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 + 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" + 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 + 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 + # The hoisted linker — used only by this lane — has an upstream rename + # race (pnpm/pnpm#12880): parallel linkers staging a nested package copy + # (observed on the tree's nested esbuild versions) rename their _tmp_* + # directory onto a path another racer already claimed, and the loser + # exits ERR_PNPM_ENOENT although an identical re-install succeeds. + # Exactly that signature earns up to two retries on a clean tree — the + # snapshot contains no node_modules, so wiping them restores the + # pre-install state; any other failure, or the race still standing after + # the final attempt, fails loud with the log tail. + local attempt + for attempt in 1 2 3; do + (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ + && return 0 + grep -q 'ERR_PNPM_ENOENT.*rename.*_tmp_' "$scratch/logs/install.log" || break + (( attempt < 3 )) || break + echo "wine-windows-gates: pnpm hoisted-linker rename race (pnpm/pnpm#12880) on install attempt $attempt; retrying on a clean tree" >&2 + find "$scratch/tree" -name node_modules -type d -prune -exec rm -rf {} + + done + 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=$! +# 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")"))" + +# ---- 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 build preserves the face order from package.json: compile and bundle the +# Host face before compiling and bundling the Client face. +# Both statuses are captured so one failure cannot hide the other's result. +build_gate() { + wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? + wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? + wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client +} +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 (Host tsc/tsdown, Client tsc/tsdown)' "$build_status" \ + "$scratch/logs/host-tsc.log" \ + "$scratch/logs/host-tsdown.log" \ + "$scratch/logs/client-tsc.log" \ + "$scratch/logs/client-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 461f2aaaf2b6909e747cd2214f905de3e6c7090e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:31:19 +0800 Subject: [PATCH 22/57] ci: use multicore native Windows runner --- ...08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../2026-08-08-native-windows-pull-request-ci.md | 12 ++++++++---- ...026-08-08-native-windows-pull-request-ci.zh.md | 12 ++++++++---- .github/workflows/ci.yml | 8 ++++---- .../ui-primitives/tests/code-block.spec.tsx | 2 +- .../tests/tool-subagent-report.spec.ts | 8 ++++++-- scripts/ci-workflow.spec.ts | 15 +++++++++++++-- vitest.config.ts | 13 +++++-------- 8 files changed, 47 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index f37e927efb..7501a9cf8f 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 162f7ae3d9fbe6e099f7fb1e8840ac1bdc4a853f -2026-08-08-native-windows-pull-request-ci.zh.md: 347239384fa5ea04b09f9a2de35975f72d1eadc5 +2026-08-08-native-windows-pull-request-ci.md: 39e67e2caaeb60888c62afe4e6c4e4a3f111fa8e +2026-08-08-native-windows-pull-request-ci.zh.md: 13a3f08d83a4fed0f573d689c9f94967ac2bface diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 162f7ae3d9..39e67e2caa 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,9 +14,11 @@ The coverage audit found that PR #499 had restored deterministic native-Windows The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that run the workspace build and production site. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts an independent `windows-native` job named `windows node 24 / native complete` on GitHub's standard `windows-2025` image. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. The job is deliberately absent from `all-checks-passed.needs`: the aggregate neither waits for it nor changes conclusion because of it, while the native job retains its own unmasked success or failure result. +Every pull request also starts an independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. The job is deliberately absent from `all-checks-passed.needs`: the aggregate neither waits for it nor changes conclusion because of it, while the native job retains its own unmasked success or failure result. -Inside `windows-native`, workspace build, production-site, and 100%-per-file coverage failures make that job fail, while the broader static, documentation, package, and built-artifact portability inventory remains observational. Coverage has a one-worker budget, and gate concurrency remains one so the instrumented and exempt-heavy suites do not overlap; one runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint and snapshot enforcement. +Inside `windows-native`, workspace build, production-site, and 100%-per-file coverage failures make that job fail, while the broader static, documentation, package, and built-artifact portability inventory remains observational. The 16-core lane gives coverage a six-worker budget, split into four instrumented workers and two exempt-heavy workers, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal has reproduced in shared worker threads on Windows as well as POSIX; the two-gate schedule also prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Two asynchronous fixtures whose real process or lazy grammar startup can exceed Vitest's default polling window use explicit five-second waits without changing their asserted outcomes. Linux remains the owner of duplicate lint and snapshot enforcement. + +The 16-core allocation is the measured stable point for this inventory. Relative to the previous two-core serial job, the complete native lane fell from 32 minutes 11 seconds to 6 minutes 27 seconds while all 41 gates and the unchanged per-file coverage threshold passed. A 32-core run reduced aggregate gate time by only 1.47 seconds and still triggered the same CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. @@ -40,7 +42,7 @@ The subsequent exact hosted run isolated seven other fixture contracts. The Powe That run also made syntax highlighting sensitive to runner contention rather than source text. Shiki's JavaScript engine deferred TextMate regexes longer than 3,000 characters until their first match, while Shiki counted that compilation against its 500 ms per-line tokenization budget. A busy Windows coverage worker could therefore stop the first TypeScript line after the `const` match and return the remainder under the same keyword style. The client now uses Shiki's default regex translation with lazy compilation disabled and tokenizes one representative sample for each boot grammar without a startup cutoff while constructing the singleton. Scanner creation and pattern compilation therefore finish before user content enters the unchanged 500 ms per-line budget. The token-boundary and Markdown DOM fixtures continue to require the complete highlighted result rather than accepting the partial stream. -The same exact hosted run showed that three concurrent instrumented Vitest workers were an unsafe budget for the standard Windows image: otherwise independent Git-merge and JSON-RPC HTTP integration cases reached the default five-second ceiling together. The native lane now gives Vitest one worker, while the real Git subprocess suite and the two real HTTP composition cases have explicit fifteen-second integration budgets without changing their workloads or assertions. The translation merge fixture also preserves `import.meta.resolve('tsx/esm')` as a `file:` URL when passing it to Node's `--import`; converting it to a drive-letter path had failed before the driver could print its owned recovery guidance. After the latest package regrouping, the fork-isolated JSONL suite's inventory follows its new `packages/session/` location rather than silently returning that process-bound suite to the shared thread pool. +The same exact hosted run showed that three concurrent instrumented Vitest workers were an unsafe budget for the standard Windows image: otherwise independent Git-merge and JSON-RPC HTTP integration cases reached the default five-second ceiling together. At that stage the native lane temporarily gave Vitest one worker, while the real Git subprocess suite and the two real HTTP composition cases received explicit fifteen-second integration budgets without changing their workloads or assertions. The translation merge fixture also preserves `import.meta.resolve('tsx/esm')` as a `file:` URL when passing it to Node's `--import`; converting it to a drive-letter path had failed before the driver could print its owned recovery guidance. After the latest package regrouping, the fork-isolated JSONL suite's inventory follows its new `packages/session/` location rather than silently returning that process-bound suite to the shared thread pool. The project-skill composition fixture had one separate eventual-consistency race: on a contended host the agent could begin its next model step after `write` returned but before Chokidar invalidated the skill catalog, moving the replacement catalog behind the subsequent `skill` call. The fixture now holds that post-write tool boundary until the real registry observes `hot-skill`, then retains its strict request-order and durable-transcript assertions. Production code remains asynchronous; the test explicitly waits for the watcher contract it intends to exercise instead of relying on scheduler timing or accepting a different request index. @@ -64,7 +66,9 @@ POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal **Run native Windows only after merge.** A post-merge reference diagnoses portability regressions after they enter `master`; it does not give reviewers an exact-head native result. -**Use an organization-owned larger Windows runner.** Larger images can reduce wall clock, but the diagnostic path would then depend on repository-external labels and allocation. Standard `windows-2025` is portable; larger runners remain benchmark targets. +**Keep GitHub's standard `windows-2025` runner.** That portable two-core image completed the exact inventory reliably, but its 32-minute serial result made the automatic native signal substantially less useful than the selected 16-core runner. + +**Use a 32-core or larger runner.** The 32-core comparison improved aggregate gate time by only 1.47 seconds over 16 cores and failed in Node's CJS lexer; earlier high-concurrency 32-core and 64-core trials failed in the same class. More capacity therefore added allocation cost without a stable end-to-end gain. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 347239384f..13a3f08d83 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,9 +14,11 @@ Status: implemented [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在 GitHub 标准 `windows-2025` 镜像上启动一个独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。该作业被刻意排除在 `all-checks-passed.needs` 之外:聚合流程既不等待它,也不会因它改变结论;原生作业则保留自身未被掩盖的成功或失败结果。 +每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。该作业被刻意排除在 `all-checks-passed.needs` 之外:聚合流程既不等待它,也不会因它改变结论;原生作业则保留自身未被掩盖的成功或失败结果。 -`windows-native` 内的工作区构建、生产网站和逐文件 100% 覆盖率故障会导致该作业失败,而更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。覆盖率检查的工作线程预算为 1 个,门禁并发数也保持为 1,因此插桩套件不会与免覆盖率的高负载套件重叠执行;同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。重复执行的 lint 与快照强制检查仍由 Linux 负责。 +`windows-native` 内的工作区构建、生产网站和逐文件 100% 覆盖率故障会导致该作业失败,而更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。16 核通道把覆盖率的 6 个工作线程拆分为 4 个插桩线程和 2 个免覆盖率高负载线程,同时运行 2 项顶层门禁,并允许 publint 使用 8 个工作线程。所有 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障现已在 Windows 和 POSIX 的共享工作线程中复现;2 项门禁的调度也避免免覆盖率的 Oxlint 探针与工作区构建争用其临时契约文件。两项真实进程或延迟语法启动可能超过 Vitest 默认轮询窗口的异步 fixture 使用显式的 5 秒等待,且不改变所断言的结果。重复执行的 lint 与快照强制检查仍由 Linux 负责。 + +16 核是这份清单实测得到的稳定点。相较此前双核串行作业,完整原生通道从 32 分 11 秒降至 6 分 27 秒,同时 41 项门禁全部通过,逐文件覆盖率阈值也保持不变。32 核运行仅将门禁总耗时再缩短 1.47 秒,却仍在一个 fork 工作线程中触发相同的 CJS lexer 致命故障,因此继续增加核心数没有带来可靠的墙钟时间收益。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 @@ -40,7 +42,7 @@ Status: implemented 该次运行还暴露出语法高亮会受运行器资源争用影响,而不只取决于源文本。Shiki 的 JavaScript 引擎会把超过 3,000 个字符的 TextMate 正则推迟到首次匹配时再编译,Shiki 同时把这段编译时间计入每行 500 毫秒的 tokenization(词元化)预算。繁忙的 Windows 覆盖率工作线程因此可能在首次 TypeScript 行匹配到 `const` 后提前停止,并让剩余内容沿用同一关键字样式。客户端现在仍使用 Shiki 的默认正则转换,但会关闭延迟编译,并在构造单例时以不设启动期截止时间的方式,为每项启动时语法 tokenization 一段代表性样例。因此,scanner(扫描器)创建与模式编译会在用户内容进入仍为每行 500 毫秒的预算前完成。词元边界与 Markdown DOM fixture 会继续要求完整高亮结果,不接受这类部分结果流。 -同一次分支头精确托管运行还表明,在标准 Windows 镜像上并发使用 3 个插桩 Vitest 工作线程并不安全:彼此独立的 Git merge 集成用例与 JSON-RPC HTTP 集成用例会同时触及默认的 5 秒上限。原生通道现在只为 Vitest 提供 1 个工作线程;真实 Git 子进程套件与两项真实 HTTP 组合用例则获得显式的 15 秒集成预算,其工作负载与断言均未改变。translation merge fixture 在把 `import.meta.resolve('tsx/esm')` 传给 Node 的 `--import` 时,也会保留其 `file:` URL;此前把它转换为盘符路径会在驱动程序输出自有恢复指引前就失败。纳入最新的 package regrouping(包重组)后,采用 fork 隔离的 JSONL 套件清单会跟随它在 `packages/session/` 下的新位置,而不会悄然把这一进程绑定套件送回共享线程池。 +同一次分支头精确托管运行还表明,在标准 Windows 镜像上并发使用 3 个插桩 Vitest 工作线程并不安全:彼此独立的 Git merge 集成用例与 JSON-RPC HTTP 集成用例会同时触及默认的 5 秒上限。在那个阶段,原生通道曾暂时只为 Vitest 提供 1 个工作线程;真实 Git 子进程套件与两项真实 HTTP 组合用例则获得显式的 15 秒集成预算,其工作负载与断言均未改变。translation merge fixture 在把 `import.meta.resolve('tsx/esm')` 传给 Node 的 `--import` 时,也会保留其 `file:` URL;此前把它转换为盘符路径会在驱动程序输出自有恢复指引前就失败。纳入最新的 package regrouping(包重组)后,采用 fork 隔离的 JSONL 套件清单会跟随它在 `packages/session/` 下的新位置,而不会悄然把这一进程绑定套件送回共享线程池。 项目 skill 组合 fixture 另有一项最终一致性竞态:宿主资源紧张时,agent 可能在 `write` 返回后、Chokidar 使 skill 目录缓存失效前就开始下一次模型步骤,导致替换目录消息落到后续 `skill` 调用之后。现在,fixture 会在写入后的工具边界等待真实注册表观察到 `hot-skill`,然后继续严格断言请求顺序与持久转录。生产代码仍保持异步;测试会显式等待其本来要验证的 watcher 契约,而不是依赖调度时序或接受另一个请求索引。 @@ -64,7 +66,9 @@ POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock **只在合并后运行原生 Windows。** 合并后的参考流程只能在可移植性回归进入 `master` 后进行诊断;它无法向评审者提供分支头精确的原生结果。 -**使用组织自有的大型 Windows 运行器。** 更大规格的运行器镜像可以缩短墙钟时间,但诊断路径将因此依赖仓库外部的运行器标签与分配能力。标准 `windows-2025` 具备可移植性;大型运行器仍作为基准测试目标。 +**保留 GitHub 标准 `windows-2025` 运行器。** 这个可移植的双核镜像可以可靠完成同一份清单,但串行结果需要 32 分钟,因此自动原生信号的实用性明显低于最终选择的 16 核运行器。 + +**使用 32 核或更大的运行器。** 32 核对照仅比 16 核缩短了 1.47 秒门禁总耗时,却在 Node 的 CJS lexer 中失败;此前的高并发 32 核与 64 核实验也以同类故障失败。因此,更多容量只增加了分配成本,没有带来稳定的端到端收益。 ## 后果 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 885aae180e..f0bdcd837f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -416,12 +416,12 @@ jobs: # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md windows-native: if: github.event_name == 'pull_request' - runs-on: windows-2025 + runs-on: dsh-windows-2025-16core name: windows node 24 / native complete env: - DSH_COVERAGE_MAX_WORKERS: '1' - DSH_GATE_CONCURRENCY: '1' - DSH_PUBLINT_CONCURRENCY: '1' + DSH_COVERAGE_MAX_WORKERS: '6' + DSH_GATE_CONCURRENCY: '2' + DSH_PUBLINT_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 with: diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx index 2d56bc1a2d..06fc6c7d90 100644 --- a/packages/client/ui-primitives/tests/code-block.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.spec.tsx @@ -47,7 +47,7 @@ describe('highlightToHtml', () => { // Once every grammar has registered, the same call highlights. await vi.waitFor(() => { for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki') - }) + }, { timeout: 5_000 }) }) }) diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index ac90b4612b..a63c4a9dce 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -219,7 +219,9 @@ describe('dsh-tool-subagent-report', () => { expect((await callReport(ctx, child, 'DURABLE_SELECTION')).isError).toBe(false) adapter.release() - await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) + await vi.waitFor(() => { + expect(ctx.agents.get(started.childId) === undefined).toBe(true) + }, { timeout: 5_000 }) expect(reports(parent).map(report => report.text)).toEqual([ `Background subagent ${started.childId} reported:\nDURABLE_SELECTION`, ]) @@ -421,7 +423,9 @@ describe('dsh-tool-subagent-report result independence', () => { const { ctx, parent, adapter } = await setup() const { started } = await startChild(ctx, parent) adapter.release() - await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) + await vi.waitFor(() => { + expect(ctx.agents.get(started.childId) === undefined).toBe(true) + }, { timeout: 5_000 }) expect(reports(parent)).toEqual([]) expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task']) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 87a5f76287..c026c8323c 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -51,10 +51,14 @@ describe('CI workflow', () => { expect(windows.if).toBe("github.event_name == 'pull_request'") expect(JSON.stringify(windows)).toContain('bash scripts/wine-windows-gates.sh') expect(workflow.jobs).toHaveProperty('wine-apt-cache') - expect(windowsNative['runs-on']).toBe('windows-2025') + expect(windowsNative['runs-on']).toBe('dsh-windows-2025-16core') expect(windowsNative.name).toBe('windows node 24 / native complete') expect(windowsNative.if).toBe("github.event_name == 'pull_request'") - expect(windowsNative.env).toMatchObject({ DSH_COVERAGE_MAX_WORKERS: '1' }) + expect(windowsNative.env).toMatchObject({ + DSH_COVERAGE_MAX_WORKERS: '6', + DSH_GATE_CONCURRENCY: '2', + DSH_PUBLINT_CONCURRENCY: '8', + }) expect(windowsNative).not.toHaveProperty('continue-on-error') expect(nativeCommandSteps).toHaveLength(3) expect(nativeCommandSteps.every(step => step.shell === 'pwsh')).toBe(true) @@ -71,6 +75,13 @@ describe('CI workflow', () => { expect(config).not.toContain('packages/lsp/lsp-local/src/index.ts') expect(config).not.toContain('packages/lsp/lsp-local/src/instance.ts') }) + + it('keeps every Vitest project process-isolated on native Windows', () => { + const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8') + + expect(config).not.toContain("pool: process.platform === 'win32' ? 'threads' : 'forks'") + expect(config.match(/pool: 'forks'/g)).toHaveLength(2) + }) }) describe('E2B e2e workflow', () => { diff --git a/vitest.config.ts b/vitest.config.ts index 207ca576cc..c67cdacf72 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -86,9 +86,8 @@ export default defineConfig({ // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), - // One coverage invocation aggregates both projects. Regular suites fork on - // POSIX for Node stability and use threads on Windows; process-bound suites - // always fork. + // One coverage invocation aggregates both projects. Every suite forks for + // Node stability; process-bound suites stay separate for inventory control. projects: [ { plugins: [pathsPlugin(), standardDecoratorPlugin()], @@ -96,11 +95,9 @@ export default defineConfig({ name: 'thread-safe', execArgv: vitestExecArgv, // Node 24 has aborted in its CJS lexer (v8::ToLocalChecked Empty - // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS - // arm64 and later on Linux. A fork contains that external runtime - // failure to the test process; Windows keeps the thread pool, where - // the abort has not reproduced and process spawn is costlier. - pool: process.platform === 'win32' ? 'threads' : 'forks', + // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS, + // Linux, and Windows. Forked workers avoid that shared thread path. + pool: 'forks', setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ From f1fafe087271aad4281c3fb546795080bb83eb2c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:25:18 +0800 Subject: [PATCH 23/57] test(windows): keep repository helpers in git subpath --- ...8-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../2026-08-08-native-windows-pull-request-ci.md | 2 ++ ...26-08-08-native-windows-pull-request-ci.zh.md | 2 ++ .../boot/app-boot/tests/repository-cache.spec.ts | 16 ++++++++-------- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index f37e927efb..fb0210cbe8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 162f7ae3d9fbe6e099f7fb1e8840ac1bdc4a853f -2026-08-08-native-windows-pull-request-ci.zh.md: 347239384fa5ea04b09f9a2de35975f72d1eadc5 +2026-08-08-native-windows-pull-request-ci.md: 1ef1cd906299a601a1d9f4cd95bb29c6935e5a10 +2026-08-08-native-windows-pull-request-ci.zh.md: 073ff1c2856c310ff52eb73ef53f0f34e1b841c9 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 162f7ae3d9..1ef1cd9062 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -52,6 +52,8 @@ The next run reached the repaired branches but one real PowerShell executor comp The following exact-head run passed all 10,938 instrumented tests and isolated four remaining locations whose existing fixtures depended on host scheduling. The E2B service retains its real surviving-group cleanup fixture and separately injects and observes an immediate automatic terminal-release rejection before proving disposal retries it. The pi-ai discovery fixture drives cancellation from a controlled response body read instead of racing a local socket timer, and the persistent-bash fixture makes an incremental PTY delta the only recoverable output before asserting the rendered fallback. These cases exercise the supported branches directly on every host; the coverage inventory and denominator remain unchanged. +After a newer `master` added exact Git-subpath package preparation, native coverage showed that the repository fixture's `file:` development dependencies outside the selected `.dsh-plugin` subpath did not expose their command shims on Windows. The fixture now keeps both helper packages inside that selected package and declares them through `file:./...`; the enclosing workspace remains excluded, while `prepack` still proves that ordinary bins from package-owned dependencies can build and prepare the installed repository. No production path, coverage threshold, or asserted artifact changed. + POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. Only intrinsically POSIX source arms carry narrow, explained denominator ignores; no source file or platform-independent branch is excluded from Windows coverage to accommodate these differences. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 347239384f..073ff1c285 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -52,6 +52,8 @@ Status: implemented 随后的分支头精确运行通过了全部 10,938 项插桩测试,并隔离出 4 个现有 fixture 依赖宿主调度的剩余位置。E2B 服务会保留真实的存活进程组清理 fixture,并另行注入并观察一次立即发生的终端自动释放拒绝,再证明服务释放会重试该终端。pi-ai 发现 fixture 改为从受控响应 body 的读取过程触发取消,不再与本地 socket 定时器竞速;persistent-bash fixture 则让 PTY 增量片段成为唯一可恢复输出,再断言渲染后的回退结果。这些用例会在每种宿主上直接执行受支持分支;覆盖率清单与分母均未改变。 +更新后的 `master` 新增针对精确 Git 子路径的包准备流程后,原生覆盖率表明,repository fixture 中位于所选 `.dsh-plugin` 子路径之外的 `file:` 开发依赖不会在 Windows 上暴露其命令包装脚本。现在,该 fixture 将两个辅助包都保留在所选包内,并通过 `file:./...` 声明它们;外层 workspace 仍被排除,而 `prepack` 仍会证明,来自包自有依赖的常规 bin 能够构建并准备已安装的 repository。生产路径、覆盖率阈值与断言所涉及的产物均未改变。 + POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。只有本质上属于 POSIX 的源码分支带有窄范围且说明明确的分母忽略;没有任何源码文件或平台无关分支为适应这些差异而从 Windows 覆盖率中排除。 ## 曾考虑的替代方案 diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index fac3610b58..af23681e17 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -119,8 +119,8 @@ describe('RepositoryCache', () => { const root = await temporaryRoot('repository-pnpm') const repository = join(root, 'source') await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) - await mkdir(join(repository, 'build-helper'), { recursive: true }) - await mkdir(join(repository, 'prepare-helper'), { recursive: true }) + await mkdir(join(repository, '.dsh-plugin', 'build-helper'), { recursive: true }) + await mkdir(join(repository, '.dsh-plugin', 'prepare-helper'), { recursive: true }) await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) await writeFile(join(repository, 'package.json'), `${JSON.stringify({ name: 'repository-fixture', @@ -138,22 +138,22 @@ describe('RepositoryCache', () => { ' .: {}', '', ].join('\n')) - await writeFile(join(repository, 'build-helper', 'package.json'), `${JSON.stringify({ + await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'package.json'), `${JSON.stringify({ name: 'repository-build-helper', version: '1.0.0', bin: 'index.js', })}\n`) - await writeFile(join(repository, 'build-helper', 'index.js'), [ + await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'index.js'), [ '#!/usr/bin/env node', "require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')", '', ].join('\n'), { mode: 0o700 }) - await writeFile(join(repository, 'prepare-helper', 'package.json'), `${JSON.stringify({ + await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'package.json'), `${JSON.stringify({ name: 'repository-prepare-helper', version: '1.0.0', bin: { 'dsh-plugin-prepare': 'index.js' }, })}\n`) - await writeFile(join(repository, 'prepare-helper', 'index.js'), [ + await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'index.js'), [ '#!/usr/bin/env node', "const { cpSync, mkdirSync, writeFileSync } = require('node:fs')", "mkdirSync('dsh-plugin-assets/skills', { recursive: true })", @@ -168,8 +168,8 @@ describe('RepositoryCache', () => { version: '1.0.0', scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' }, devDependencies: { - 'repository-build-helper': 'file:../build-helper', - 'repository-prepare-helper': 'file:../prepare-helper', + 'repository-build-helper': 'file:./build-helper', + 'repository-prepare-helper': 'file:./prepare-helper', }, dsh: { skills: ['../skills'] }, })}\n`) From 3b5ee1dce17545efb97e220f0598aaa5d740d48d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:57:50 +0800 Subject: [PATCH 24/57] fix(windows): address native CI review findings --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 ++-- ...7-21-serial-cross-platform-ci-reference.md | 2 +- ...1-serial-cross-platform-ci-reference.zh.md | 2 +- .github/workflows/ci.yml | 5 +++-- apps/cli/src/profile-boot.ts | 11 +++++++--- .../boot/app-boot/tests/hmr-config.spec.ts | 6 +++-- packages/util/paths/src/index.ts | 22 ++++++++++++++----- scripts/ci-workflow.spec.ts | 1 + vendor/README.md | 2 +- vendor/hmr/src/index.ts | 7 +++--- 10 files changed, 41 insertions(+), 21 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 5e8f015f50..33bffe92ff 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 @@ -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-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: dd41cdb51bd226b19bc6aca498c7713a3cc9a6b7 -2026-07-21-serial-cross-platform-ci-reference.zh.md: ec026df8164df0a2400200adbe81230920ce5daa +2026-07-21-serial-cross-platform-ci-reference.md: 07dc430e6fed3fe75a006ca03523bd6e4fc969d0 +2026-07-21-serial-cross-platform-ci-reference.zh.md: 8bbb60cdead2957069de22ecaddf01c6cd9fb305 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 dd41cdb51b..07dc430e6f 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 @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. 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. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active reference is `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). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding 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 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. 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 ec026df816..8bbb60cdea 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 @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——该热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3e2239fdd..264cbd9f6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -431,6 +431,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: windows-2025 name: windows node 24 / native complete + timeout-minutes: 60 env: DSH_GATE_CONCURRENCY: '1' DSH_PUBLINT_CONCURRENCY: '1' @@ -721,8 +722,8 @@ jobs: with: dest: ${{ runner.temp }}/setup-pnpm - # The Windows lanes deliberately skip the store cache like the required - # windows job; an empty cache input disables setup-node's caching. + # The benchmark's Windows lanes deliberately skip the store cache like + # the independent native Windows job; an empty input disables caching. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index b49bd37535..b4fee306cf 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -168,6 +168,7 @@ export interface RunProfileOptions { environment: EnvironmentSnapshot } +/** Re-throw setup failures unless this invocation's signal already owns shutdown. */ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void { if (!signal.aborted) throw error } @@ -253,9 +254,13 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con }) app.current = ctx // A surface can dispose the whole tree while startup or this post-boot - // watcher setup is still in flight. Fiber state owns liveness; the local - // signal fact distinguishes that expected exit race from a real HMR error. - if (watchProfilePatch && !signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE) { + // watcher setup is still in flight. Loader presence and fiber state own + // liveness; the local signal fact distinguishes that expected exit race + // from a real HMR error. + if (watchProfilePatch + && !signalShutdown.signal.aborted + && ctx.fiber.state === FiberState.ACTIVE + && ctx.get('loader') !== undefined) { try { // Config-only HMR for the live profile patch layer: the web bundle // disables the shared module-reload `hmr` row (its reload lifecycle is diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index fe6bb556b4..ba14e6967f 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import Hmr from '@cordisjs/plugin-hmr' import Loader from '@cordisjs/plugin-loader' import Timer from '@cordisjs/plugin-timer' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' async function bootHmr(dir: string, root: string[] = []): Promise { const ctx = new Context() @@ -33,7 +33,8 @@ describe('HMR exact config paths', () => { symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') writeFileSync(filename, 'export const generation = 0\n') const ctx = await bootHmr(alias, ['.']) - const expected = pathToFileURL(filename).href + const expected = pathToFileURL(join(realpathSync(target), 'module.ts')).href + const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) const observed: string[] = [] ctx.on('hmr/change', (url) => { observed.push(url) }) try { @@ -43,6 +44,7 @@ describe('HMR exact config paths', () => { writeFileSync(filename, `export const generation = ${generation}\n`) await new Promise(resolve => setTimeout(resolve, 20)) } + expect(cacheHas).toHaveBeenCalledWith(expected) } finally { await ctx.fiber.dispose() rmSync(alias, { force: true }) diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index 591158d7c0..122408509a 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-paths */ -import { realpath } from 'node:fs/promises' +import { opendir, realpath } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' @@ -20,19 +20,29 @@ export const DSH_HOME_ENV = 'DSH_HOME' /** * Give a native filesystem watcher one canonical spelling of a path, even * when its final components do not exist yet. The deepest existing ancestor - * is resolved through {@link realpath}; the missing suffix is then restored. - * This prevents Windows short-name aliases from being mixed with long paths - * emitted by the native watcher backend. + * is resolved through {@link realpath}; when a suffix is missing, that + * ancestor is also proved to be an enumerable directory before the suffix is + * restored. This prevents Windows from treating a regular-file ancestor as + * ordinary absence, and prevents short-name aliases from being mixed with + * long paths emitted by the native watcher backend. * @param path - Watch target or root, resolved against the current directory. * @returns the target with its existing ancestor canonicalized. - * @throws when ancestor traversal encounters an error other than absence. + * @throws when ancestor traversal encounters an error other than absence, or + * the existing ancestor of a missing suffix is not an enumerable directory. */ export async function canonicalizeWatchPath(path: string): Promise { let current = resolve(path) const missing: string[] = [] while (true) { try { - return join(await realpath(current), ...missing.reverse()) + const canonical = await realpath(current) + if (missing.length > 0) { + // A Windows file-as-parent probe reports ENOENT. Opening the resolved + // ancestor preserves the cross-platform directory requirement. + const directory = await opendir(canonical) + await directory.close() + } + return join(canonical, ...missing.reverse()) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error const parent = dirname(current) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 05edaaef55..128988a464 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -53,6 +53,7 @@ describe('CI workflow', () => { expect(workflow.jobs).toHaveProperty('wine-apt-cache') expect(windowsNative['runs-on']).toBe('windows-2025') expect(windowsNative.name).toBe('windows node 24 / native complete') + expect(windowsNative['timeout-minutes']).toBe(60) expect(windowsNative.if).toBe("github.event_name == 'pull_request'") expect(windowsNative).not.toHaveProperty('continue-on-error') expect(nativeCommandSteps).toHaveLength(3) diff --git a/vendor/README.md b/vendor/README.md index 0e44771616..a5e998ef39 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. -9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper makes pnpm's nested Git-package install reinvoke the same bundled entry with `--ignore-workspace`, so the selected package installs its own manifest dependencies instead of joining an enclosing source workspace. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index c1d379e36c..7c36201b13 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -242,12 +242,13 @@ class Hmr extends Service { const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { this.ctx.logger.debug('%s detected at %C', kind, path) - const filename = resolve(this.baseDir, path) + const filename = resolve(watchBaseDir, path) + const configuredFilename = resolve(this.baseDir, path) // Config reload: the file is a loader config file (e.g. cordis.yml). for (const entry of loader.entries()) { const include = entry.subtree as Include | undefined - if (include?.filename !== filename) continue - this.refreshConfig(include, filename, () => include.refresh()) + if (include?.filename !== filename && include?.filename !== configuredFilename) continue + this.refreshConfig(include, include.filename, () => include.refresh()) return } From 03fafca0e0a23e382375c1084122f1f6409eea6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:59:24 +0800 Subject: [PATCH 25/57] fix(windows): close native coverage review gaps --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 2 +- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 60 ++++++------------- ...08-08-native-windows-pull-request-ci.zh.md | 60 ++++++------------- .../credentials-local/src/index.ts | 12 +--- .../tests/loader-composition.spec.ts | 27 ++++++++- packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 20 +++---- .../tests/skill-local-watcher.spec.ts | 26 ++++++++ vendor/README.md | 2 +- vitest.config.ts | 4 +- 15 files changed, 111 insertions(+), 120 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 d8d79cbf9a..2c61edfac9 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: 53cc86efce9061c8f9836a17cb35ebb128085b7a -2026-07-22-evidence-based-larger-hosted-runners.zh.md: c9daf5273e190d3828e80ebe81369e72f5d7a74d +2026-07-22-evidence-based-larger-hosted-runners.md: 84c951809891b4936549a2f429dc7efc99833c1b +2026-07-22-evidence-based-larger-hosted-runners.zh.md: e097f8b18c7a03a4760e9cb4f45b6385c44b051c 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 53cc86efce..84c9518098 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 @@ -24,7 +24,7 @@ The gate dependencies remain explicit. Coverage consumes source and does not wai The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails. -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. +Within this enterprise required topology, Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts, while Linux owns the duplicate lint, coverage, and snapshot inventories. The later [dual Windows pull-request topology](2026-08-08-native-windows-pull-request-ci.md) adds a separate non-blocking standard-hosted native job that independently enforces supported-source coverage without extending this paid required path. An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: 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 c9daf5273e..e097f8b18c 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 @@ -24,7 +24,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布约定只要遗漏一个运行时分片,检查仍会失败。 -Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物约定。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台约定。 +在这项企业级必需拓扑中,Windows 通过一次 32 核环境设置同时承载阻塞性构建、生产网站与观测性构建产物约定,重复的 lint、覆盖率和快照清单则由 Linux 负责。后续的[拉取请求双 Windows 拓扑](2026-08-08-native-windows-pull-request-ci.md)新增一个独立且不阻断的标准托管原生作业;该作业会独立强制执行受支持源码覆盖率,同时不延长这条付费必需路径。 一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index fb0210cbe8..cf43388f62 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 1ef1cd906299a601a1d9f4cd95bb29c6935e5a10 -2026-08-08-native-windows-pull-request-ci.zh.md: 073ff1c2856c310ff52eb73ef53f0f34e1b841c9 +2026-08-08-native-windows-pull-request-ci.md: eafe378f83385f028b8d2f53213514835f1af3fd +2026-08-08-native-windows-pull-request-ci.zh.md: 639ed8ab120df94371195bd81ff81d5013425569 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 1ef1cd9062..eafe378f83 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -6,72 +6,46 @@ English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) ## Problem -The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. The Wine lane provides that critical-path signal but executes over a Linux kernel and case-sensitive ext4, requires a hoisted dependency layout and host-created symlinks, and cannot prove NTFS, DACL, ConPTY, crash-durability, or native process behavior. With the native serial references disabled, ordinary CI also needs an automatic real Windows-kernel result on every pull-request head even when that result is not part of branch protection. +The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. Wine provides that critical-path signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. -The coverage audit found that PR #499 had restored deterministic native-Windows LSP coverage, but a later GUI branch replayed its three temporary source exclusions from stale branch state. The current LSP fixtures skip only genuinely POSIX primitives and otherwise exercise the supported Windows process, transport, and lifecycle paths, so excluding `connection.ts`, `index.ts`, and `instance.ts` hid supported behavior rather than a platform limitation. +A coverage audit found that stale branch state had restored temporary exclusions for supported LSP sources. Native Windows therefore needed to execute the complete supported source inventory at the same 100%-per-file threshold instead of relying on a smaller platform-specific denominator. ## Decision -The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that run the workspace build and production site. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. +The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts an independent `windows-native` job named `windows node 24 / native complete` on GitHub's standard `windows-2025` image. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. The job is deliberately absent from `all-checks-passed.needs`: the aggregate neither waits for it nor changes conclusion because of it, while the native job retains its own unmasked success or failure result. +Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on GitHub's standard two-core `windows-2025` image. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 60-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. -Inside `windows-native`, workspace build, production-site, and 100%-per-file coverage failures make that job fail, while the broader static, documentation, package, and built-artifact portability inventory remains observational. Coverage has a one-worker budget, and gate concurrency remains one so the instrumented and exempt-heavy suites do not overlap; one runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint and snapshot enforcement. +The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. +The standard native lane gives coverage and the top-level gate scheduler one worker each so the instrumented and exempt-heavy suites do not overlap. Process-global, real-process-tree, and timing-sensitive suites run in the fork-isolated Vitest project but still merge coverage into the same per-file threshold. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. -The coverage follow-up then exercised the serial heavy suites on the native host and removed their remaining path-spelling assumptions. Filesystem identity assertions compare native real paths instead of Git's slash convention with Node's temporary-directory spelling; quoted diagnostics are matched in their escaped form; TypeScript-owned file names are compared after separator normalization; and Typert passes a slash-normalized config name consistently across TypeScript's read and parse boundary so malformed Windows configs produce the owned analysis error instead of a compiler debug failure. The Oxlint subprocess contract also uses the same explicit twenty-second budget as its neighboring executable probes. These are portability repairs to supported tests and parser behavior, not platform skips or coverage exclusions. +Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. -The blocking coverage gate exposed two more fixture contracts that had never run on the native lane. The JSONL materialization fault now asserts the structured filesystem error code because the Windows durable-directory implementation owns an `ENOTDIR` code without copying it into human prose. The ACP teardown ladder now uses Node children instead of assuming a POSIX shell and asserts Windows' force-termination outcome rather than POSIX signal names; POSIX still proves the `SIGTERM` and `SIGKILL` tiers. Those suites load native bindings or own real process trees, so the Windows thread pool runs them in the existing fork-isolated project while still merging their coverage into the same per-file threshold. +Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary. -After the branch incorporated a newer `master`, the next native coverage run found the last uncatalogued watcher path and a stress-test budget. `skill-local` opened existing Chokidar roots with the configured spelling, so `%TEMP%` could still reach libuv through `C:\\Users\\RUNNER~1` while events used the long directory name; its root and ancestor modes now share the canonical watch-path contract, while discovery retains the configured path. The newly added 10,000-session descendant walk also exceeded Vitest's default timeout under Windows coverage instrumentation, so that unchanged stack-safety workload has an explicit twenty-second stress-test budget rather than a smaller depth or a platform skip. +Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. -The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. +Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. -Running the complete instrumented graph instead of the earlier reduced inventory exposed the remaining cross-platform fixture contracts. Windows path identity now accounts for 8.3 aliases, native separators, Git checkout line endings, cross-drive relative paths, and file URLs before constructing loader symlinks. The JSONL durable-directory helper applies the extended-length namespace to probes and staging creation, real product tests invoke portable executable entries and tolerate bounded Windows handle release, and stress tests retain their workloads with explicit coverage budgets. A credential document or watch path whose deepest existing ancestor is a file now fails `ENOTDIR` on every host, while `skill-local` uses effect-owned persistent Chokidar handles so asynchronous libuv errors are contained instead of escaping the test process. - -The final root-probe failure came from applying the extended-length namespace to the drive root as well as long descendants. Node rejected the bare root probe as `EISDIR`, cascading through every JSONL fixture and assembled binary that materialized a session. The Windows durable-directory helper now probes the short drive root in its native spelling and namespaces only descendants; an injected Win32-path unit test locks both spellings while native coverage exercises the real filesystem. - -The next complete coverage run reached six independent late failures rather than one shared cascade. React queue-action coverage now resolves its mocked request inside an awaited `act()` before observing the settled render. The unclosed-Markdown workload keeps all 6,400 candidates under an explicit three-second coverage budget, and the asynchronous workspace-projection warning test gives its outer case a twenty-second budget larger than its ten-second poll. Real Claude Code teardown uses asynchronous recursive removal with ten bounded retries after every managed handle reports exit, accommodating Windows' delayed handle release without weakening the quiescence assertions. - -Two product boundaries required foundation repairs. Include's debounced config persistence previously launched an unobserved promise from a timer; a transient Windows `EPERM` while replacing `cordis.yml` could therefore lose the disabled row and escape as an unhandled rejection. The vendored writer now serializes writes, retries only transient access/busy failures with bounded backoff, observes every rejection, and drains the latest write at teardown; the real Loader composition injects one `EPERM` and proves the durable retry. Codex 0.146 advertised `exec_command` to the loopback Responses model on Windows but rejected the returned call in its own router, the same upstream failure class tracked in [openai/codex#31665](https://github.com/openai/codex/issues/31665). Development evidence is pinned to the current stable 0.147.0 release: regenerated upstream schemas preserve the provider-owned handshake, thread/turn, approval, user-input, and elicitation contract. Because Codex can advertise the legacy `shell_command` instead when unified exec is unavailable on the host, the loopback model now selects an advertised command tool and supplies that tool's argument shape rather than injecting `exec_command` unconditionally. The real-product suite therefore proves unattended rejection without a side effect and whole-tree exit through the product's actual default tool inventory on each host. - -The subsequent exact hosted run isolated seven other fixture contracts. The PowerShell background-output case now waits for process completion before draining and comparing the final delta, while the pi-ai idle-watchdog case retains a bounded one-second close deadline that accommodates the delayed Windows socket notification. The asynchronous workspace projection seeds its in-memory filesystem at the host-resolved root. The Include retry acceptance asserts the injected failure and eventual persistence rather than an incidental total rename count, which may include another valid serialized write. LSP's bare-command fixture supplies a `.cmd` executable through `PATHEXT` on Windows, and URI rendering expectations distinguish the execution world's path convention from the test host's separators. None of these changes skips a supported path or weakens the asserted outcome. - -That run also made syntax highlighting sensitive to runner contention rather than source text. Shiki's JavaScript engine deferred TextMate regexes longer than 3,000 characters until their first match, while Shiki counted that compilation against its 500 ms per-line tokenization budget. A busy Windows coverage worker could therefore stop the first TypeScript line after the `const` match and return the remainder under the same keyword style. The client now uses Shiki's default regex translation with lazy compilation disabled and tokenizes one representative sample for each boot grammar without a startup cutoff while constructing the singleton. Scanner creation and pattern compilation therefore finish before user content enters the unchanged 500 ms per-line budget. The token-boundary and Markdown DOM fixtures continue to require the complete highlighted result rather than accepting the partial stream. - -The same exact hosted run showed that three concurrent instrumented Vitest workers were an unsafe budget for the standard Windows image: otherwise independent Git-merge and JSON-RPC HTTP integration cases reached the default five-second ceiling together. The native lane now gives Vitest one worker, while the real Git subprocess suite and the two real HTTP composition cases have explicit fifteen-second integration budgets without changing their workloads or assertions. The translation merge fixture also preserves `import.meta.resolve('tsx/esm')` as a `file:` URL when passing it to Node's `--import`; converting it to a drive-letter path had failed before the driver could print its owned recovery guidance. After the latest package regrouping, the fork-isolated JSONL suite's inventory follows its new `packages/session/` location rather than silently returning that process-bound suite to the shared thread pool. - -The project-skill composition fixture had one separate eventual-consistency race: on a contended host the agent could begin its next model step after `write` returned but before Chokidar invalidated the skill catalog, moving the replacement catalog behind the subsequent `skill` call. The fixture now holds that post-write tool boundary until the real registry observes `hot-skill`, then retains its strict request-order and durable-transcript assertions. Production code remains asynchronous; the test explicitly waits for the watcher contract it intends to exercise instead of relying on scheduler timing or accepting a different request index. - -The next exact-head run passed all 10,933 instrumented tests but correctly failed the per-file threshold at 99.95%, exposing five branches that Linux happened to cover. Deterministic cross-platform fixtures now exercise backward PTY scrollback pagination, a settings document that names a directory, an invalid SQLite filename, and an atomic-writer lock beneath a regular-file parent. The credentials provider's remaining `stat` and mode-enforcement arm is intrinsically POSIX, so it carries the same narrow annotated peer ignore used by the durable JSONL and storage backends; its behavior test remains enforced on POSIX. The threshold and source-file inventory remain unchanged. - -The follow-up exact-head run passed all 10,937 instrumented tests and narrowed the threshold result to 99.99%. Its two remaining lines showed that the first PTY fixture had reached the page-offset helper but only supplied two pages, and that Windows path canonicalization rejected the real invalid-path fixture before `readFile` reached the reload-policy branch. The PTY fixture now supplies three backward pages, while the watcher fixture injects one non-absence read failure after the real permission check; both retain the observable output or last-good-snapshot assertions they exist to prove. - -The next run reached the repaired branches but one real PowerShell executor composition case timed out at Vitest's five-second ceiling before the coverage report. That fixture had configured the product timeout to the same five seconds as the test timeout, leaving no budget for the executor to return either its owned result or its owned timeout classification under instrumentation. The command now has a ten-second product budget and the integration test a fifteen-second ceiling; its exit code, output, and resolved-timeout assertions are unchanged. - -The following exact-head run passed all 10,938 instrumented tests and isolated four remaining locations whose existing fixtures depended on host scheduling. The E2B service retains its real surviving-group cleanup fixture and separately injects and observes an immediate automatic terminal-release rejection before proving disposal retries it. The pi-ai discovery fixture drives cancellation from a controlled response body read instead of racing a local socket timer, and the persistent-bash fixture makes an incremental PTY delta the only recoverable output before asserting the rendered fallback. These cases exercise the supported branches directly on every host; the coverage inventory and denominator remain unchanged. - -After a newer `master` added exact Git-subpath package preparation, native coverage showed that the repository fixture's `file:` development dependencies outside the selected `.dsh-plugin` subpath did not expose their command shims on Windows. The fixture now keeps both helper packages inside that selected package and declares them through `file:./...`; the enclosing workspace remains excluded, while `prepack` still proves that ordinary bins from package-owned dependencies can build and prepare the installed repository. No production path, coverage threshold, or asserted artifact changed. - -POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. Only intrinsically POSIX source arms carry narrow, explained denominator ignores; no source file or platform-independent branch is excluded from Windows coverage to accommodate these differences. +Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. ## Alternatives considered -**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the longest hosted job and for Windows capacity. The independent result keeps that signal automatic without changing the existing required path. +**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path. -**Run only Wine on pull requests.** Wine reaches the blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. +**Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. **Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism. -**Run native Windows only after merge.** A post-merge reference diagnoses portability regressions after they enter `master`; it does not give reviewers an exact-head native result. +**Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures. -**Use an organization-owned larger Windows runner.** Larger images can reduce wall clock, but the diagnostic path would then depend on repository-external labels and allocation. Standard `windows-2025` is portable; larger runners remain benchmark targets. +**Use an organization-owned larger Windows runner.** Larger images can reduce wall time, but the portable diagnostic path would then depend on repository-external labels and allocation. Standard `windows-2025` remains the baseline until an exact-head benchmark proves a stable configuration worth that dependency. ## Consequences Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result. -Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, and native addon signal. The native job is slower than Wine and duplicates setup plus the two blocking builds, but it also executes the portability inventory that exposed path, watcher, and lifecycle defects hidden by the compatibility lane. +Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. -Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native job uses the immutable workspace on Windows. A failure unique to either job must be classified against that boundary rather than weakened or silently skipped. Native coverage enforces the repository's per-file threshold without Windows-only source exclusions for supported LSP behavior. Native snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before joining the native lane. +Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native job uses the immutable workspace on Windows. A failure unique to either job must be classified against that boundary rather than weakened or silently skipped. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 073ff1c285..639ed8ab12 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -6,72 +6,46 @@ Status: implemented ## 问题 -拉取请求必需的 Windows 判定既需要快速的 win32 工具链信号,也不能让聚合流程等待稀缺的 Windows 容量。Wine 通道提供这项关键路径信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,要求采用 hoisted 依赖布局和由宿主侧创建的符号链接,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,即使真实 Windows 内核结果不属于分支保护,常规 CI 也需要针对每个拉取请求分支头自动产出该结果。 +拉取请求必需的 Windows 判定既需要快速的 win32 工具链信号,也不能让聚合流程等待稀缺的 Windows 容量。Wine 提供这项关键路径信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 -覆盖率审计发现,PR(Pull Request)#499 已恢复确定性的原生 Windows LSP 覆盖率,后续的 GUI 分支却回放了陈旧分支状态中的 3 个临时源码排除项。当前的 LSP fixture(测试前置数据)只跳过真正属于 POSIX 的原语,除此之外还会检验受支持的 Windows 进程、传输与生命周期路径;因此,排除 `connection.ts`、`index.ts` 和 `instance.ts` 所掩盖的是受支持的行为,而非平台限制。 +覆盖率审计发现,陈旧分支状态恢复了针对受支持 LSP 源码的临时排除项。因此,原生 Windows 需要按同一逐文件 100% 阈值执行完整的受支持源码清单,而不能依赖缩小后的平台专用分母。 ## 决策 [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在 GitHub 标准 `windows-2025` 镜像上启动一个独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。该作业被刻意排除在 `all-checks-passed.needs` 之外:聚合流程既不等待它,也不会因它改变结论;原生作业则保留自身未被掩盖的成功或失败结果。 +每个拉取请求还会在 GitHub 标准的双核 `windows-2025` 镜像上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,60 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 -`windows-native` 内的工作区构建、生产网站和逐文件 100% 覆盖率故障会导致该作业失败,而更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。覆盖率检查的工作线程预算为 1 个,门禁并发数也保持为 1,因此插桩套件不会与免覆盖率的高负载套件重叠执行;同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。重复执行的 lint 与快照强制检查仍由 Linux 负责。 +原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 +标准原生通道分别为覆盖率和顶层门禁调度器分配 1 个工作线程,避免插桩套件与免覆盖率项较多的套件重叠执行。涉及进程全局状态、真实进程树或时序敏感的套件在采用 fork 隔离的 Vitest 项目中运行,但其覆盖率仍汇入同一逐文件阈值。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 -随后,覆盖率后续工作在原生宿主上运行了串行的高负载测试套件,并移除了其中残留的路径拼写假设。文件系统标识断言改为比较原生真实路径,不再直接比较遵循 Git 斜杠约定的路径与 Node 的临时目录拼写;带引号的诊断文本按 JSON 转义后的形式匹配;TypeScript 提供的文件名在统一分隔符后再比较;Typert 则让经过斜杠归一化的配置名称一致贯穿 TypeScript 的读取与解析边界,使格式错误的 Windows 配置产生 Typert 自有的分析错误,而非编译器的调试故障。Oxlint 子进程契约也采用与相邻可执行文件探测相同的显式 20 秒预算。这些都是针对受支持测试与解析器行为的可移植性修复,不是按平台跳过测试或设置覆盖率排除项。 +可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 -这项阻断覆盖率门禁又暴露出两项从未在原生通道上运行过的 fixture 契约。JSONL 实体化故障场景现在断言结构化文件系统错误码,因为 Windows 的持久目录实现拥有 `ENOTDIR` 错误码,却不会将其复制进人类可读文本。ACP(Agent Client Protocol)拆卸阶梯现在使用 Node 子进程,不再假定 POSIX shell,并断言 Windows 的强制终止结果而非 POSIX 信号名称;POSIX 仍会证明 `SIGTERM` 与 `SIGKILL` 两级。这些套件会加载原生绑定或拥有真实进程树,因此 Windows 线程池会让它们在现有的 fork 隔离项目中运行,同时仍将这些套件的覆盖率汇入同一项逐文件阈值。 +原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。 -分支纳入更新的 `master` 后,下一次原生覆盖率运行发现了最后一条未纳入统一契约的 watcher 路径和一项压力测试预算。`skill-local` 曾以配置时的路径拼写打开现有 Chokidar 根,因此 `%TEMP%` 仍可能以 `C:\\Users\\RUNNER~1` 进入 libuv,而事件使用长目录名;现在它的根模式与祖先模式共用规范化监听路径契约,发现过程仍保留配置路径。新增的 10,000 会话后代遍历在 Windows 覆盖率插桩下还会超过 Vitest 默认超时,因此该栈安全工作负载保持原有规模并获得显式的 20 秒压力测试预算,而不是缩小深度或按平台跳过。 +Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。 -下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture 通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 +启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 -运行完整的覆盖率插桩图而非此前缩减的清单后,剩余的跨平台 fixture 契约也显现出来。Windows 路径标识现在会在比较或构造 loader 符号链接前处理 8.3 别名、原生分隔符、Git 检出换行、跨盘符相对路径与文件 URL。JSONL 持久目录辅助函数会对探测与临时目录创建应用扩展长度命名空间;真实产品测试会调用可移植的可执行入口,并以有界重试容纳 Windows 句柄释放;压力测试则保留原工作负载并获得显式的覆盖率预算。如果凭据文档或监听路径最深的现有祖先是文件,所有宿主现在都会返回 `ENOTDIR`;`skill-local` 同时改用由 effect 拥有的持久 Chokidar 句柄,使异步 libuv 错误得到收束,不再逸出测试进程。 - -最后一个根路径探测失败源于扩展长度命名空间既应用到长后代路径,也应用到了驱动器根目录。Node 将裸根目录探测拒绝为 `EISDIR`,从而连锁影响所有会物化会话的 JSONL fixture 和组装后二进制。Windows 持久目录辅助函数现在以原生写法探测本来就很短的驱动器根目录,仅对后代路径添加命名空间;注入 Win32 路径语义的单元测试固定两种写法,原生覆盖率则验证真实文件系统。 - -下一次完整覆盖率运行触及的是 6 项相互独立的末端故障,不再是同一问题的连锁结果。React 队列动作覆盖现在会在 awaited `act()` 中解析模拟请求,再观察渲染完成后的状态。未闭合 Markdown 工作负载保留全部 6,400 个候选项,并采用显式的 3 秒覆盖率预算;异步工作区投影告警测试则为外层用例设置 20 秒预算,大于其 10 秒轮询预算。真实 Claude Code 拆卸会在所有受管句柄均报告退出后,采用带 10 次有界重试的异步递归删除,以容纳 Windows 延迟释放句柄的行为,同时不削弱完全停稳断言。 - -另有两个产品边界需要基础性修复。Include 的防抖配置持久化此前会从计时器启动一个无人观察的 Promise;Windows 在替换 `cordis.yml` 时若瞬时返回 `EPERM`,既可能丢失已禁用行,也会让 rejection 以未处理形式逸出。现在,vendored writer 会串行化写入,只对瞬时的访问/忙碌错误执行有界退避重试,观察每个 rejection,并在拆卸时排空最新写入;真实 Loader 组合测试会注入一次 `EPERM` 并证明持久化重试。Codex 0.146 在 Windows 上会把 `exec_command` 提供给回环 Responses 模型,却在自身路由器中拒绝模型返回的调用;这与 [openai/codex#31665](https://github.com/openai/codex/issues/31665) 跟踪的上游故障属于同一类。开发证据现锁定当前稳定版 0.147.0:重新生成的上游 schema 保留了提供方拥有的握手、线程/轮次、审批、用户输入和 elicitation 契约。当宿主无法使用 unified exec 时,Codex 可能改为提供旧版 `shell_command`;因此,回环模型现在会选择实际提供的命令工具,并使用该工具对应的参数形态,而不再无条件注入 `exec_command`。真实产品测试由此会通过各宿主的实际默认工具清单,证明无人值守拒绝不产生副作用,且整棵进程树退出。 - -随后的分支头精确托管运行又隔离出另外 7 项 fixture 契约。PowerShell 后台输出场景现在会等待进程完成,再排空并比较最后一段增量;pi-ai 空闲 watchdog 场景则保留 1 秒的有界关闭期限,以容纳 Windows 延迟送达的 socket 通知。异步工作区投影会在宿主解析后的根目录上填充内存文件系统。Include 重试验收现在断言注入的故障与最终持久化结果,而不再断言可能包含另一项合法串行写入的偶然 rename 总次数。LSP 的裸命令 fixture 会在 Windows 上通过 `PATHEXT` 提供 `.cmd` 可执行文件,URI 渲染预期也会区分执行环境的路径约定与测试宿主的分隔符。上述修改既没有跳过受支持路径,也没有削弱结果断言。 - -该次运行还暴露出语法高亮会受运行器资源争用影响,而不只取决于源文本。Shiki 的 JavaScript 引擎会把超过 3,000 个字符的 TextMate 正则推迟到首次匹配时再编译,Shiki 同时把这段编译时间计入每行 500 毫秒的 tokenization(词元化)预算。繁忙的 Windows 覆盖率工作线程因此可能在首次 TypeScript 行匹配到 `const` 后提前停止,并让剩余内容沿用同一关键字样式。客户端现在仍使用 Shiki 的默认正则转换,但会关闭延迟编译,并在构造单例时以不设启动期截止时间的方式,为每项启动时语法 tokenization 一段代表性样例。因此,scanner(扫描器)创建与模式编译会在用户内容进入仍为每行 500 毫秒的预算前完成。词元边界与 Markdown DOM fixture 会继续要求完整高亮结果,不接受这类部分结果流。 - -同一次分支头精确托管运行还表明,在标准 Windows 镜像上并发使用 3 个插桩 Vitest 工作线程并不安全:彼此独立的 Git merge 集成用例与 JSON-RPC HTTP 集成用例会同时触及默认的 5 秒上限。原生通道现在只为 Vitest 提供 1 个工作线程;真实 Git 子进程套件与两项真实 HTTP 组合用例则获得显式的 15 秒集成预算,其工作负载与断言均未改变。translation merge fixture 在把 `import.meta.resolve('tsx/esm')` 传给 Node 的 `--import` 时,也会保留其 `file:` URL;此前把它转换为盘符路径会在驱动程序输出自有恢复指引前就失败。纳入最新的 package regrouping(包重组)后,采用 fork 隔离的 JSONL 套件清单会跟随它在 `packages/session/` 下的新位置,而不会悄然把这一进程绑定套件送回共享线程池。 - -项目 skill 组合 fixture 另有一项最终一致性竞态:宿主资源紧张时,agent 可能在 `write` 返回后、Chokidar 使 skill 目录缓存失效前就开始下一次模型步骤,导致替换目录消息落到后续 `skill` 调用之后。现在,fixture 会在写入后的工具边界等待真实注册表观察到 `hot-skill`,然后继续严格断言请求顺序与持久转录。生产代码仍保持异步;测试会显式等待其本来要验证的 watcher 契约,而不是依赖调度时序或接受另一个请求索引。 - -下一次分支头精确运行通过了全部 10,933 项插桩测试,但逐文件阈值仍在 99.95% 正确失败,从而暴露出 5 个此前恰由 Linux 覆盖的分支。新增的确定性跨平台 fixture 会分别覆盖 PTY 向后翻页拼接 scrollback、以目录作为 settings 文档、非法 SQLite 文件名,以及 regular file(普通文件)父级之下的原子写入锁。credentials provider 剩余的 `stat` 与模式位强制分支本质上只属于 POSIX,因此采用与持久 JSONL、storage backend 相同的窄范围、带说明的对等分支忽略;其行为测试仍会在 POSIX 上强制执行。阈值与源码文件清单均未改变。 - -后续分支头精确运行通过了全部 10,937 项插桩测试,并把阈值结果收窄到 99.99%。剩余的两行表明,首版 PTY fixture 已到达页偏移 helper,却只提供了两页数据;Windows 路径规范化还会在 `readFile` 到达 reload policy(重载策略)分支前,先拒绝真实的非法路径 fixture。PTY fixture 现在会提供 3 个向后翻页页面;watcher fixture 则会在真实权限检查之后注入一次非“文件不存在”的读取失败。两项 fixture 仍保留其本来要证明的可观察输出或 last-good snapshot(最后有效快照)断言。 - -再下一次运行已到达修复后的分支,但一项真实 PowerShell executor 组合用例会在生成覆盖率报告前,触及 Vitest 的 5 秒上限。该 fixture 把产品超时和测试超时都配置成了同样的 5 秒;插桩环境下,executor 因而没有余量返回其自有结果或自有超时分类。现在,命令的产品预算为 10 秒,集成测试上限为 15 秒;退出码、输出和解析后超时值的断言均未改变。 - -随后的分支头精确运行通过了全部 10,938 项插桩测试,并隔离出 4 个现有 fixture 依赖宿主调度的剩余位置。E2B 服务会保留真实的存活进程组清理 fixture,并另行注入并观察一次立即发生的终端自动释放拒绝,再证明服务释放会重试该终端。pi-ai 发现 fixture 改为从受控响应 body 的读取过程触发取消,不再与本地 socket 定时器竞速;persistent-bash fixture 则让 PTY 增量片段成为唯一可恢复输出,再断言渲染后的回退结果。这些用例会在每种宿主上直接执行受支持分支;覆盖率清单与分母均未改变。 - -更新后的 `master` 新增针对精确 Git 子路径的包准备流程后,原生覆盖率表明,repository fixture 中位于所选 `.dsh-plugin` 子路径之外的 `file:` 开发依赖不会在 Windows 上暴露其命令包装脚本。现在,该 fixture 将两个辅助包都保留在所选包内,并通过 `file:./...` 声明它们;外层 workspace 仍被排除,而 `prepack` 仍会证明,来自包自有依赖的常规 bin 能够构建并准备已安装的 repository。生产路径、覆盖率阈值与断言所涉及的产物均未改变。 - -POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。只有本质上属于 POSIX 的源码分支带有窄范围且说明明确的分母忽略;没有任何源码文件或平台无关分支为适应这些差异而从 Windows 覆盖率中排除。 +Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 ## 曾考虑的替代方案 -**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最长的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。 +**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最慢的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。 -**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性的 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件契约已经损坏,也可能报告绿灯。 +**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 -**将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留普通独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。 +**将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留常规独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。 -**只在合并后运行原生 Windows。** 合并后的参考流程只能在可移植性回归进入 `master` 后进行诊断;它无法向评审者提供分支头精确的原生结果。 +**排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。 -**使用组织自有的大型 Windows 运行器。** 更大规格的运行器镜像可以缩短墙钟时间,但诊断路径将因此依赖仓库外部的运行器标签与分配能力。标准 `windows-2025` 具备可移植性;大型运行器仍作为基准测试目标。 +**使用组织自有的大型 Windows 运行器。** 更大规格的镜像可以缩短墙钟时间,但可移植诊断路径将因此依赖仓库外部的运行器标签与分配能力。在分支头精确基准测试证明某项稳定配置值得引入该依赖之前,标准 `windows-2025` 仍是基线。 ## 后果 Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。 -尽管如此,每个拉取请求都会获得来自真实 NT 内核、NTFS、PowerShell、Windows 进程和原生插件的信号。原生作业比 Wine 更慢,并重复执行设置流程和两项阻断构建,但它也会运行那份可移植性清单;兼容性通道隐藏的路径、watcher 与生命周期缺陷正是由该清单暴露。 +尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程与两项阻断构建,在标准镜像上明显更慢;但它也会暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 -维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在 Windows 上使用不可变工作区。任一作业独有的失败都必须依据该边界分类,不得削弱或静默跳过。原生覆盖率会强制执行仓库的逐文件阈值,且不会为受支持的 LSP 行为设置仅针对 Windows 的源码排除项。原生快照仍是明确列出的缺口,不会仅由作业名称暗示已经纳入;必须先为其建立专门且经过测试的契约,才能加入原生通道。 +维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在 Windows 上使用不可变工作区。任一作业独有的失败都必须依据该边界分类,不得削弱或静默跳过。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 0b6b2272ce..69fcf5c6ab 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -100,15 +100,6 @@ const GROUP_OTHER_BITS = 0o077 * @throws when the path hierarchy is invalid or the file exists with group or other permission bits set. */ async function assertOwnerOnly(filename: string): Promise { - /* v8 ignore start -- native Windows coverage exercises this path; POSIX covers mode enforcement */ - if (process.platform === 'win32') { - // Windows has no POSIX mode bits, but it reports a file-as-parent as - // ordinary ENOENT; canonicalization preserves the invalid-path failure. - await canonicalizeWatchPath(filename) - return - } - /* v8 ignore stop */ - /* v8 ignore start -- Windows has no POSIX mode enforcement; POSIX behavior tests enforce this peer. */ let mode: number try { mode = (await stat(filename)).mode @@ -117,6 +108,9 @@ async function assertOwnerOnly(filename: string): Promise { await canonicalizeWatchPath(filename) return } + /* v8 ignore next -- POSIX coverage cannot take the Windows peer; native Windows coverage does. */ + if (process.platform === 'win32') return + /* v8 ignore start -- Windows has no POSIX mode enforcement; POSIX behavior tests enforce this peer. */ const offending = mode & GROUP_OTHER_BITS if (offending === 0) return throw new Error( diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 4420e54da4..2ab50d6e80 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -22,7 +22,12 @@ import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' import * as DirectoryPickerAuto from '../src/index.ts' -const renameControl = vi.hoisted(() => ({ attempts: 0, injectedFailures: 0, remainingFailures: 0 })) +const renameControl = vi.hoisted(() => ({ + attempts: 0, + failureCode: 'EPERM', + injectedFailures: 0, + remainingFailures: 0, +})) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -33,7 +38,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { if (renameControl.remainingFailures > 0) { renameControl.remainingFailures-- renameControl.injectedFailures++ - throw Object.assign(new Error(`transient rename failure for ${newPath}`), { code: 'EPERM' }) + throw Object.assign(new Error(`injected rename failure for ${newPath}`), { code: renameControl.failureCode }) } await actual.rename(oldPath, newPath) }, @@ -60,6 +65,7 @@ afterEach(async () => { root = undefined fakeBin = undefined renameControl.attempts = 0 + renameControl.failureCode = 'EPERM' renameControl.injectedFailures = 0 renameControl.remainingFailures = 0 }) @@ -193,4 +199,21 @@ describe('real Loader composition', () => { expect(renameControl.remainingFailures).toBe(0) expect(renameControl.attempts).toBeGreaterThanOrEqual(2) }) + + it('reports a terminal debounced-write failure again to the teardown owner', { timeout: 60_000 }, async () => { + stubAttendedHost() + const { ctx } = await loadComposition('127.0.0.1') + const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! + const include = [...ctx.loader.entries()] + .find(entry => entry.options.name === 'cordis:include')?.subtree as Include | undefined + if (include === undefined) throw new Error('expected the root Include tree') + renameControl.failureCode = 'EIO' + renameControl.remainingFailures = 1 + + await autoEntry.fiber!.dispose() + await expect.poll(() => renameControl.injectedFailures).toBe(1) + await expect(include.stop()).rejects.toMatchObject({ code: 'EIO' }) + await expect(ctx.fiber.dispose()).resolves.not.toThrow() + context = undefined + }) }) diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index 73bfe42ec1..97ec37d450 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md -README.md: dc2e97f89349a85ce548e5f6f1b01408eb32a293 -README.zh.md: 0c17d9fb77af3df2071613c2c6b8fc15086581a7 +README.md: 26ce8ed628c4bdcb3a79de204bfae2668067ec0d +README.zh.md: c741d120160755675c2c760d7ccf6d72532f420c diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index dc2e97f893..26ce8ed628 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -44,7 +44,7 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read ## Catalog Change Detection -Existing skill roots are watched with Chokidar. Before opening a native watcher, the provider realpaths the existing root or ancestor and restores the next missing segment; discovery and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation. +Existing skill roots are watched with Chokidar. Before opening a native watcher, the provider realpaths the existing root or ancestor and restores the next missing segment; when `watchFollowSymlinks` is false and the root itself is a symbolic link, it preserves that final link so Chokidar can enforce the configured boundary. Discovery and diagnostics retain the configured path, while Windows cannot otherwise mix an 8.3 alias with long-form libuv events. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation. A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 0c17d9fb77..c741d12016 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -44,7 +44,7 @@ ## 目录变更检测 -现有 skill 根由 Chokidar 监视。打开原生 watcher 前,提供方会对现有根或祖先执行 realpath 解析,并拼回下一个缺失路径段;发现与诊断仍保留配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。 +现有 skill 根由 Chokidar 监视。打开原生 watcher 前,提供方会对现有根或祖先执行 realpath 解析,并拼回下一个缺失路径段;当 `watchFollowSymlinks` 为 false 且根本身是符号链接时,提供方不会展开最后这一级链接,使 Chokidar 能够强制执行配置边界。发现与诊断仍保留配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。 不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 5b11e71f26..4fe09b3be9 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-skill-local */ -import { access, readdir, readFile, stat } from 'node:fs/promises' +import { access, lstat, readdir, readFile, stat } from 'node:fs/promises' import { unwatchFile, watchFile, type Stats } from 'node:fs' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' @@ -394,10 +394,9 @@ class SkillWatchManager { private async ensureCurrentWatcher(state: RootWatchState): Promise { const watcher = state.watcher if (watcher !== undefined && !state.unhealthy) { - const current = await resolveRootWatchMode(state.root.path) + const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks) // A child unlink can publish an empty catalog before root unlinkDir arrives. // Discovery therefore revalidates the retained handle independently. - // oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return } await this.replaceWatcher(state) @@ -414,7 +413,6 @@ class SkillWatchManager { /* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */ if (watcher === undefined) return /* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */ - // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup if (this.closing || state.owners.size === 0) { await this.closeWatcher(watcher) return @@ -423,7 +421,6 @@ class SkillWatchManager { state.watcher = watcher state.unhealthy = false } catch (error) { - // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup if (!this.closing) { state.unhealthy = true this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) @@ -436,11 +433,11 @@ class SkillWatchManager { // service; keep skill filtering and invalidation here. private async openStableWatcher(state: RootWatchState): Promise { while (!this.closing && state.owners.size > 0) { - const mode = await resolveRootWatchMode(state.root.path) + const mode = await resolveRootWatchMode(state.root.path, this.config.followSymlinks) const watcher = mode.kind === 'ancestor' ? this.openAncestorWatcher(state, mode) : await this.openRootWatcher(state, mode) - const current = await resolveRootWatchMode(state.root.path) + const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks) /* v8 ignore else -- A host path transition between the two probes is timing-dependent. */ if (sameWatchMode(mode, current)) return watcher /* v8 ignore next -- Covered by the same host path transition guard. */ @@ -472,7 +469,7 @@ class SkillWatchManager { ): Promise { let current: RootWatchMode try { - current = await resolveRootWatchMode(state.root.path) + current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks) } catch (error) { /* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */ if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error) @@ -623,13 +620,16 @@ function resolveWatchConfig(config: Config): ResolvedWatchConfig { } } -async function resolveRootWatchMode(root: string): Promise { +async function resolveRootWatchMode(root: string, followSymlinks: boolean): Promise { let candidate = root while (true) { try { const info = await stat(candidate) if (info.isDirectory()) { - const anchor = await canonicalizeWatchPath(candidate) + const preserveRootLink = candidate === root + && !followSymlinks + && (await lstat(candidate)).isSymbolicLink() + const anchor = preserveRootLink ? resolve(candidate) : await canonicalizeWatchPath(candidate) if (candidate === root) return { kind: 'root', anchor } const firstSegment = relative(candidate, root).split(sep)[0] /* v8 ignore next -- candidate is a strict ancestor of root. */ diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 8b2a62e842..6aa0ef5ca4 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -136,6 +136,32 @@ describe('skill-local watcher failures', () => { await fiber.dispose() }) + it('preserves a symlink root when link following is disabled', async () => { + const target = await tempDir('skill-watch-link-target') + const aliasParent = await tempDir('skill-watch-link-alias') + const alias = join(aliasParent, 'skills') + await writeSkill(target, 'linked-skill') + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + includeDefaultRoots: false, + customSkillDirs: [alias], + watch: true, + watchFollowSymlinks: false, + }) + + try { + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-skill']) + expect(watcherHarness.watchers[0]?.path).toBe(alias) + expect(watcherHarness.watchers[0]?.options.followSymlinks).toBe(false) + } finally { + await fiber.dispose() + await rm(aliasParent, { recursive: true, force: true }) + await rm(target, { recursive: true, force: true }) + } + }) + it('ignores missing-path probes until the observed path actually changes', async () => { const home = await tempDir('skill-watch-missing-stable') const ctx = new Context() diff --git a/vendor/README.md b/vendor/README.md index d743ea5ca4..d5a7561c4a 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -44,7 +44,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. 14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. -15. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, contained asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with an injected transient rename failure. +15. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. ## Sync procedure diff --git a/vitest.config.ts b/vitest.config.ts index 207ca576cc..aa4b1f96d9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,8 +37,8 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] -// pwsh-local's run/start/lifecycle suites -// self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file +// pwsh-local's run/start/lifecycle suites self-skip without a real pwsh +// (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts // green while CI runners ship pwsh and still enforce the full bar. The probe // runs the suites' own resolution (the dependency-free resolve.ts module), From 8efaf10f4fdf5df7c2532ea45ba4bf6a25ac2c87 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:21:52 +0800 Subject: [PATCH 26/57] fix(ci): preserve watcher race lint annotations --- packages/skill/skill-local/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 4fe09b3be9..c975d0e581 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -397,6 +397,7 @@ class SkillWatchManager { const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks) // A child unlink can publish an empty catalog before root unlinkDir arrives. // Discovery therefore revalidates the retained handle independently. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return } await this.replaceWatcher(state) @@ -413,6 +414,7 @@ class SkillWatchManager { /* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */ if (watcher === undefined) return /* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */ + // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup if (this.closing || state.owners.size === 0) { await this.closeWatcher(watcher) return @@ -421,6 +423,7 @@ class SkillWatchManager { state.watcher = watcher state.unhealthy = false } catch (error) { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup if (!this.closing) { state.unhealthy = true this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) From 8d76c794bd5b87c5aa8659d95f3536cd328c8469 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:34:18 +0800 Subject: [PATCH 27/57] test(windows): bundle repository prepare helpers --- packages/boot/app-boot/tests/repository-cache.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index af23681e17..0f97ae2e11 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -167,10 +167,11 @@ describe('RepositoryCache', () => { name: 'repository-plugin-fixture', version: '1.0.0', scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' }, - devDependencies: { + dependencies: { 'repository-build-helper': 'file:./build-helper', 'repository-prepare-helper': 'file:./prepare-helper', }, + bundledDependencies: ['repository-build-helper', 'repository-prepare-helper'], dsh: { skills: ['../skills'] }, })}\n`) await execFileAsync('git', ['init', '--quiet'], { cwd: repository }) From 3754c0d58615899d0040d12050b35526323e9b16 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:35:46 +0800 Subject: [PATCH 28/57] test(windows): let HMR atomic changes settle --- packages/boot/app-boot/tests/hmr-config.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index ba14e6967f..033ad1661a 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -42,7 +42,8 @@ describe('HMR exact config paths', () => { for (let generation = 1; !observed.includes(expected); generation += 1) { if (Date.now() >= deadline) throw new Error('HMR did not observe a module change through the alias') writeFileSync(filename, `export const generation = ${generation}\n`) - await new Promise(resolve => setTimeout(resolve, 20)) + // Leave Chokidar's atomic-write window idle so one coalesced change can publish. + await new Promise(resolve => setTimeout(resolve, 250)) } expect(cacheHas).toHaveBeenCalledWith(expected) } finally { From d950f60bdfbd3d9ef4a7fe3349f8a03a8b03afc5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:58:54 +0800 Subject: [PATCH 29/57] test(windows): stabilize aliased HMR writes --- packages/boot/app-boot/tests/hmr-config.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index 033ad1661a..1de504c221 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -26,21 +26,23 @@ async function eventually(test: () => boolean, message: string): Promise { } describe('HMR exact config paths', () => { - it('observes module changes when its watch base is a filesystem alias', { timeout: 20_000 }, async () => { + it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => { const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) const alias = `${target}-alias` - const filename = join(alias, 'module.ts') + const aliasFilename = join(alias, 'module.ts') symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') - writeFileSync(filename, 'export const generation = 0\n') + writeFileSync(aliasFilename, 'export const generation = 0\n') const ctx = await bootHmr(alias, ['.']) - const expected = pathToFileURL(join(realpathSync(target), 'module.ts')).href + const filename = join(realpathSync(target), 'module.ts') + const expected = pathToFileURL(filename).href const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) const observed: string[] = [] ctx.on('hmr/change', (url) => { observed.push(url) }) try { - const deadline = Date.now() + 10_000 + const deadline = Date.now() + 20_000 for (let generation = 1; !observed.includes(expected); generation += 1) { if (Date.now() >= deadline) throw new Error('HMR did not observe a module change through the alias') + // The watch base, not the writer spelling, is the alias under test. writeFileSync(filename, `export const generation = ${generation}\n`) // Leave Chokidar's atomic-write window idle so one coalesced change can publish. await new Promise(resolve => setTimeout(resolve, 250)) From 43c9bafcea6e89488b8486ddb5b9c877a1eb6817 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:59:30 +0800 Subject: [PATCH 30/57] test(windows): invoke repository fixture helpers directly --- packages/boot/app-boot/tests/repository-cache.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index 0f97ae2e11..f6b03a41da 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -166,7 +166,12 @@ describe('RepositoryCache', () => { await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ name: 'repository-plugin-fixture', version: '1.0.0', - scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' }, + scripts: { + prepack: [ + 'node ./node_modules/repository-build-helper/index.js', + 'node ./node_modules/repository-prepare-helper/index.js', + ].join(' && '), + }, dependencies: { 'repository-build-helper': 'file:./build-helper', 'repository-prepare-helper': 'file:./prepare-helper', From 03ba3c506f4ed93233787ff905f63c45fc2bf33c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:15:03 +0800 Subject: [PATCH 31/57] fix(windows): isolate nested repository installs --- packages/boot/app-boot/tests/repository-cache.spec.ts | 10 ++-------- vendor/README.md | 2 +- vendor/loader/src/repository.ts | 8 ++++++-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index f6b03a41da..af23681e17 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -166,17 +166,11 @@ describe('RepositoryCache', () => { await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ name: 'repository-plugin-fixture', version: '1.0.0', - scripts: { - prepack: [ - 'node ./node_modules/repository-build-helper/index.js', - 'node ./node_modules/repository-prepare-helper/index.js', - ].join(' && '), - }, - dependencies: { + scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' }, + devDependencies: { 'repository-build-helper': 'file:./build-helper', 'repository-prepare-helper': 'file:./prepare-helper', }, - bundledDependencies: ['repository-build-helper', 'repository-prepare-helper'], dsh: { skills: ['../skills'] }, })}\n`) await execFileAsync('git', ['init', '--quiet'], { cwd: repository }) diff --git a/vendor/README.md b/vendor/README.md index 9473328e1b..d316a2b509 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper makes pnpm's nested Git-package install reinvoke the same bundled entry with `--ignore-workspace`, so the selected package installs its own manifest dependencies instead of joining an enclosing source workspace. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper and inherited `NPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace, including when Windows lifecycle setup prepends its own command directory to `PATH`. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts index 5cb6ec387b..3d75453655 100644 --- a/vendor/loader/src/repository.ts +++ b/vendor/loader/src/repository.ts @@ -39,10 +39,14 @@ function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS. function installEnvironment(commandDirectory: string): NodeJS.ProcessEnv { const scrubbed = scrubEnvironment() const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1] - const withoutPath = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => name.toUpperCase() !== 'PATH')) + const withoutOverrides = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => { + return name.toUpperCase() !== 'PATH' && name.toUpperCase() !== 'NPM_CONFIG_IGNORE_WORKSPACE' + })) return { - ...withoutPath, + ...withoutOverrides, PATH: [commandDirectory, ...(path === undefined ? [] : [path])].join(delimiter), + // pnpm prepends its own command directory to lifecycle PATH on Windows. + NPM_CONFIG_IGNORE_WORKSPACE: 'true', } } From 98687f6d28048d14ddc1a146be49ae0fff06b208 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:17:46 +0800 Subject: [PATCH 32/57] ci(windows): reduce coverage worker fan-out --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.zh.md | 4 ++-- .github/workflows/ci.yml | 2 +- scripts/ci-workflow.spec.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 1e149d554a..255c2fd603 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 3256d3fa505f73914d680df46e6332e01c34f260 -2026-08-08-native-windows-pull-request-ci.zh.md: 445cdb73115399e2f25988c8e84919e9e3114664 +2026-08-08-native-windows-pull-request-ci.md: 246c7d64e2e061bc7d259103e06633895a35d5bd +2026-08-08-native-windows-pull-request-ci.zh.md: 504ec5e1ae6ffaa3a0fa5016dd0410c9bce7d1d3 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 3256d3fa50..246c7d64e2 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,9 +18,9 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a six-worker budget, split into four instrumented workers and two exempt-heavy workers, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Two asynchronous fixtures whose real process or lazy grammar startup can exceed Vitest's default polling window use explicit five-second waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a four-worker budget, split into three instrumented workers and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Two asynchronous fixtures whose real process or lazy grammar startup can exceed Vitest's default polling window use explicit five-second waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. -The 16-core allocation is the measured stable point for this inventory. Relative to the previous two-core serial job, the complete native lane fell from 32 minutes 11 seconds to 6 minutes 27 seconds while all 41 gates and the unchanged per-file coverage threshold passed; a second exact-head run passed in 7 minutes 50 seconds. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. +The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed an unreliable watcher fixture under four concurrent instrumented workers. The selected budget therefore reduces that fan-out to three while retaining two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 445cdb7311..504ec5e1ae 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,9 +18,9 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 6 个工作线程,其中 4 个用于插桩套件,2 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项异步 fixture 的真实进程或延迟语法启动可能超过 Vitest 的默认轮询窗口,因此改用显式的 5 秒等待,且不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 4 个工作线程,其中 3 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项异步 fixture 的真实进程或延迟语法启动可能超过 Vitest 的默认轮询窗口,因此改用显式的 5 秒等待,且不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 -16 核配置是这项清单的实测稳定点。与此前的双核串行作业相比,完整原生通道从 32 分 11 秒降至 6 分 27 秒,同时全部 41 项门禁与未变更的逐文件覆盖率阈值均通过;第二次分支头精确运行也在 7 分 50 秒内通过。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 +16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑发现,在 4 个插桩工作线程并发时,一项 watcher fixture 表现不稳定。因此,所选预算将这一扇出降至 3,同时保留两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e914c4f649..a9aacc16cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -433,7 +433,7 @@ jobs: name: windows node 24 / native complete timeout-minutes: 60 env: - DSH_COVERAGE_MAX_WORKERS: '6' + DSH_COVERAGE_MAX_WORKERS: '4' DSH_GATE_CONCURRENCY: '2' DSH_PUBLINT_CONCURRENCY: '8' steps: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 56d660c4b5..62fc25209f 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -56,7 +56,7 @@ describe('CI workflow', () => { expect(windowsNative['timeout-minutes']).toBe(60) expect(windowsNative.if).toBe("github.event_name == 'pull_request'") expect(windowsNative.env).toMatchObject({ - DSH_COVERAGE_MAX_WORKERS: '6', + DSH_COVERAGE_MAX_WORKERS: '4', DSH_GATE_CONCURRENCY: '2', DSH_PUBLINT_CONCURRENCY: '8', }) From 2abc132af52dabb7f32e657b8d54911bc79ddcdc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:42:01 +0800 Subject: [PATCH 33/57] fix(windows): stabilize covered native fixtures --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 2 +- .../process/2026-08-08-native-windows-pull-request-ci.zh.md | 2 +- packages/boot/app-boot/tests/repository-cache.spec.ts | 4 ++-- .../session-query/session-query-sqlite/tests/sqlite.spec.ts | 4 ++-- vendor/README.md | 2 +- vendor/loader/src/repository.ts | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index cf43388f62..575ead7c38 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: eafe378f83385f028b8d2f53213514835f1af3fd -2026-08-08-native-windows-pull-request-ci.zh.md: 639ed8ab120df94371195bd81ff81d5013425569 +2026-08-08-native-windows-pull-request-ci.md: c8459ad0eae9b74b0b9bbcdf93f1f315a1e30c72 +2026-08-08-native-windows-pull-request-ci.zh.md: d0f814f439033a6aac2f7ab07d82b8e8d0de5247 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index eafe378f83..c8459ad0ea 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -24,7 +24,7 @@ Portable filesystem fixtures derive paths with `node:path`, compare native realp Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary. -Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. +Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration so a nested Git-package install cannot rejoin an enclosing workspace when Windows lifecycle setup takes precedence over the transaction-owned command wrapper. Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 639ed8ab12..d0f814f439 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -24,7 +24,7 @@ Status: implemented 原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。 -Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。 +Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,因此当 Windows 生命周期设置的 PATH 优先级绕过由事务持有的命令包装层时,嵌套的 Git 包安装也不会重新加入外层 workspace。 启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index af23681e17..13b67c4361 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -159,7 +159,7 @@ describe('RepositoryCache', () => { "mkdirSync('dsh-plugin-assets/skills', { recursive: true })", "cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", "writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')", - "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", + "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}|${process.env.PNPM_CONFIG_IGNORE_WORKSPACE ?? 'absent'}\\n`)", '', ].join('\n'), { mode: 0o700 }) await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') @@ -187,7 +187,7 @@ describe('RepositoryCache', () => { const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n') - await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') + await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent|true\n') await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))) .toBe('repository skill source\n') diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 36be5cb9e5..c5cbccbf24 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1050,7 +1050,7 @@ describe('SQLite reconciliation and source lifecycle', () => { .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) }) - it('preserves unchanged persisted generations while reconciling new, changed, and deleted rows', async () => { + it('preserves unchanged persisted generations while reconciling new, changed, and deleted rows', { timeout: 20_000 }, async () => { const path = await temporaryPath() const unchanged = header('unchanged') const changed = header('changed') @@ -1236,7 +1236,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => expect(ctx.sessionQuery).toBeUndefined() }) - it('resets a recognized incompatible schema but refuses unknown or foreign tables', async () => { + it('resets a recognized incompatible schema but refuses unknown or foreign tables', { timeout: 20_000 }, async () => { const stalePath = await temporaryPath('stale.db') const staleOwner = await liveContext({ path: stalePath }) await (staleOwner.sessionQuery as SessionQuerySqlite).close() diff --git a/vendor/README.md b/vendor/README.md index d316a2b509..49638263fc 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper and inherited `NPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace, including when Windows lifecycle setup prepends its own command directory to `PATH`. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper and inherited `PNPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace, including when Windows lifecycle setup prepends its own command directory to `PATH`. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts index 3d75453655..b24525554b 100644 --- a/vendor/loader/src/repository.ts +++ b/vendor/loader/src/repository.ts @@ -40,13 +40,13 @@ function installEnvironment(commandDirectory: string): NodeJS.ProcessEnv { const scrubbed = scrubEnvironment() const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1] const withoutOverrides = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => { - return name.toUpperCase() !== 'PATH' && name.toUpperCase() !== 'NPM_CONFIG_IGNORE_WORKSPACE' + return name.toUpperCase() !== 'PATH' && name.toUpperCase() !== 'PNPM_CONFIG_IGNORE_WORKSPACE' })) return { ...withoutOverrides, PATH: [commandDirectory, ...(path === undefined ? [] : [path])].join(delimiter), // pnpm prepends its own command directory to lifecycle PATH on Windows. - NPM_CONFIG_IGNORE_WORKSPACE: 'true', + PNPM_CONFIG_IGNORE_WORKSPACE: 'true', } } From 48882aa988b528fb9d1629f8dfff443e6ff14767 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:42:24 +0800 Subject: [PATCH 34/57] test(windows): isolate HMR alias identity --- packages/boot/app-boot/tests/hmr-config.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index 1de504c221..b555645da8 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -8,12 +8,12 @@ import Loader from '@cordisjs/plugin-loader' import Timer from '@cordisjs/plugin-timer' import { describe, expect, it, vi } from 'vitest' -async function bootHmr(dir: string, root: string[] = []): Promise { +async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(dir).href + '/' await ctx.plugin(Loader) await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root, ignored: [], debounce: 0 }) + await ctx.plugin(Hmr, { root, ignored: [], debounce: 0, usePolling }) return ctx } @@ -32,7 +32,9 @@ describe('HMR exact config paths', () => { const aliasFilename = join(alias, 'module.ts') symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') writeFileSync(aliasFilename, 'export const generation = 0\n') - const ctx = await bootHmr(alias, ['.']) + // This acceptance owns alias-to-cache identity. Other cases below exercise + // native events; polling keeps Windows fs.watch queue pressure out of it. + const ctx = await bootHmr(alias, ['.'], true) const filename = join(realpathSync(target), 'module.ts') const expected = pathToFileURL(filename).href const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) From dcb45aae65b9ac4b37dd57712e62619daecd9040 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:43:01 +0800 Subject: [PATCH 35/57] test(windows): preserve watcher defaults --- packages/boot/app-boot/tests/hmr-config.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index b555645da8..9a1bc250cd 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -13,7 +13,12 @@ async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): ctx.baseUrl = pathToFileURL(dir).href + '/' await ctx.plugin(Loader) await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root, ignored: [], debounce: 0, usePolling }) + await ctx.plugin(Hmr, { + root, + ignored: [], + debounce: 0, + ...usePolling === undefined ? {} : { usePolling }, + }) return ctx } From c5237fd20f7b39f81d5cea2824171d1990139bca Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:09:48 +0800 Subject: [PATCH 36/57] ci(windows): reduce coverage fan-out to two --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.zh.md | 4 ++-- .github/workflows/ci.yml | 2 +- scripts/ci-workflow.spec.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 99f2ef84f3..1c4da274d8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 17b6a4018993d3a48b984b07eb459c7b1e16985a -2026-08-08-native-windows-pull-request-ci.zh.md: 851982ebb2374d8603ee363c19440bff57372761 +2026-08-08-native-windows-pull-request-ci.md: ab37c8421f2387a93bc08f38c46613b0c6dcc0ec +2026-08-08-native-windows-pull-request-ci.zh.md: 7a61c83d502bdf6e1d278b7e69f740da1d2b9d41 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 17b6a40189..ab37c8421f 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,9 +18,9 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a four-worker budget, split into three instrumented workers and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Two asynchronous fixtures whose real process or lazy grammar startup can exceed Vitest's default polling window use explicit five-second waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a three-worker budget, split into two instrumented workers and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Two asynchronous fixtures whose real process or lazy grammar startup can exceed Vitest's default polling window use explicit five-second waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. -The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed an unreliable watcher fixture under four concurrent instrumented workers. The selected budget therefore reduces that fan-out to three while retaining two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. +The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four and then three concurrent instrumented workers. The selected budget therefore reduces that fan-out to two while retaining two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 851982ebb2..7a61c83d50 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,9 +18,9 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 4 个工作线程,其中 3 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项异步 fixture 的真实进程或延迟语法启动可能超过 Vitest 的默认轮询窗口,因此改用显式的 5 秒等待,且不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 3 个工作线程,其中 2 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项异步 fixture 的真实进程或延迟语法启动可能超过 Vitest 的默认轮询窗口,因此改用显式的 5 秒等待,且不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 -16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑发现,在 4 个插桩工作线程并发时,一项 watcher fixture 表现不稳定。因此,所选预算将这一扇出降至 3,同时保留两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 +16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个和 3 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 2,同时保留两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9aacc16cb..73abdbf2f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -433,7 +433,7 @@ jobs: name: windows node 24 / native complete timeout-minutes: 60 env: - DSH_COVERAGE_MAX_WORKERS: '4' + DSH_COVERAGE_MAX_WORKERS: '3' DSH_GATE_CONCURRENCY: '2' DSH_PUBLINT_CONCURRENCY: '8' steps: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 62fc25209f..ffa387804f 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -56,7 +56,7 @@ describe('CI workflow', () => { expect(windowsNative['timeout-minutes']).toBe(60) expect(windowsNative.if).toBe("github.event_name == 'pull_request'") expect(windowsNative.env).toMatchObject({ - DSH_COVERAGE_MAX_WORKERS: '4', + DSH_COVERAGE_MAX_WORKERS: '3', DSH_GATE_CONCURRENCY: '2', DSH_PUBLINT_CONCURRENCY: '8', }) From 480dd9292a01571209a4d695e42f3ad4fe831226 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:39:41 +0800 Subject: [PATCH 37/57] test(windows): budget instrumented integration waits --- .../tool-session-query/tests/sqlite-integration.spec.ts | 2 +- scripts/change-scope.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index 09c9c2e812..a00049eb3a 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -31,7 +31,7 @@ function fakeAgent(session: Session): Agent { } describe('tool-session-query with the real SQLite provider', () => { - it('searches live prior-step history and a persisted same-workspace log', async () => { + it('searches live prior-step history and a persisted same-workspace log', { timeout: 20_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-')) temporaryDirectories.push(root) const ctx = new Context() diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index b230c8de12..423df6ea6b 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -97,7 +97,7 @@ function repositoryState(root: string): Record { } describe('change-scope', () => { - it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => { + it('uses an explicit base on a fresh branch without a same-name remote and after its first push', { timeout: 20_000 }, () => { const { root } = fixture() git(root, ['switch', '-c', 'feature']) git(root, ['branch', '--set-upstream-to=origin/master']) From 455e6ec583d3bdbc5deebf229af5e0b7b488348e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:40:13 +0800 Subject: [PATCH 38/57] fix(hmr): await module watcher readiness --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- vendor/README.md | 2 +- vendor/hmr/src/index.ts | 41 ++++++++++++++----- 5 files changed, 36 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 8cc2763444..75d7f8b670 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: e98de723dfe118a297a2e9074d94d52dd6dddc47 -2026-08-08-native-windows-pull-request-ci.zh.md: 02ad94b095c8fd1361c4b113d486f8091bb9cf5a +2026-08-08-native-windows-pull-request-ci.md: 4e2ea07e90656f212f0613ffa17184ff2c3c8c45 +2026-08-08-native-windows-pull-request-ci.zh.md: 306243af3f3e0b280dd25caa5c99df2176590f3b diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index e98de723df..4e2ea07e90 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -16,7 +16,7 @@ Every pull request also starts an independent `windows-native` job named `window The native gate keeps workspace build and production-site failures blocking inside its own job while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. -The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. +The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 02ad94b095..306243af3f 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -16,7 +16,7 @@ Status: implemented 原生门禁在其自身作业内继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 -首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 +首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。 下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture(测试前置数据)通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 diff --git a/vendor/README.md b/vendor/README.md index a5e998ef39..c2004c36e0 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. -9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper makes pnpm's nested Git-package install reinvoke the same bundled entry with `--ignore-workspace`, so the selected package installs its own manifest dependencies instead of joining an enclosing source workspace. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 7c36201b13..169ee2e00a 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -214,6 +214,17 @@ class Hmr extends Service { const match = picomatch(ignored) const watchBaseDir = await realpath(this.baseDir) + + // Collect externals before opening the watcher so every post-ready change + // is observed by listeners that already have their classification state. + const mainUrl = pathToFileURL(resolve(process.argv[1])).href + const mainJob = this.internal.loadCache.get(mainUrl) + if (mainJob) { + this.externals = await loadDependencies(mainJob) + } else { + this.externals = new Set() + } + this.watcher = watch(root, { ...this.config, cwd: watchBaseDir, @@ -228,16 +239,6 @@ class Hmr extends Service { ignoreInitial: true, }) - // Collect externals: framework modules reachable from the main entry. - // Changes to these files require a full process restart, not HMR. - const mainUrl = pathToFileURL(resolve(process.argv[1])).href - const mainJob = this.internal.loadCache.get(mainUrl) - if (mainJob) { - this.externals = await loadDependencies(mainJob) - } else { - this.externals = new Set() - } - const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce) const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { @@ -271,6 +272,26 @@ class Hmr extends Service { this.watcher.on('add', path => onChange('add', path)) this.watcher.on('change', path => onChange('change', path)) this.watcher.on('unlink', path => onChange('unlink', path)) + + const ready = Promise.withResolvers() + let readyState: 'pending' | 'resolved' | 'rejected' = root.length === 0 ? 'resolved' : 'pending' + if (root.length === 0) { + ready.resolve() + } else { + this.watcher.once('ready', () => { + readyState = 'resolved' + ready.resolve() + }) + } + this.watcher.on('error', (error) => { + if (readyState === 'pending') { + readyState = 'rejected' + ready.reject(error) + } else { + this.ctx.logger.warn(error) + } + }) + await ready.promise } private refreshConfig(key: object, filename: string, refresh: () => Promise | void) { From db5833e77dea02187b06eb65d1ef80ec95363bd7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:44:19 +0800 Subject: [PATCH 39/57] ci(windows): serialize instrumented coverage --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.zh.md | 4 ++-- .github/workflows/ci.yml | 2 +- scripts/ci-workflow.spec.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 1c4da274d8..dd363f66d5 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: ab37c8421f2387a93bc08f38c46613b0c6dcc0ec -2026-08-08-native-windows-pull-request-ci.zh.md: 7a61c83d502bdf6e1d278b7e69f740da1d2b9d41 +2026-08-08-native-windows-pull-request-ci.md: 7c8fe478b86d4e05c9d4ee87c30d0cc828e52047 +2026-08-08-native-windows-pull-request-ci.zh.md: a54eed5dee54f5331b6f38b2853eb856ed7bf00b diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index ab37c8421f..7c8fe478b8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,9 +18,9 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a three-worker budget, split into two instrumented workers and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Two asynchronous fixtures whose real process or lazy grammar startup can exceed Vitest's default polling window use explicit five-second waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. -The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four and then three concurrent instrumented workers. The selected budget therefore reduces that fan-out to two while retaining two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. +The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 7a61c83d50..a54eed5dee 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,9 +18,9 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 3 个工作线程,其中 2 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项异步 fixture 的真实进程或延迟语法启动可能超过 Vitest 的默认轮询窗口,因此改用显式的 5 秒等待,且不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 -16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个和 3 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 2,同时保留两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 +16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73abdbf2f2..81266fc900 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -433,7 +433,7 @@ jobs: name: windows node 24 / native complete timeout-minutes: 60 env: - DSH_COVERAGE_MAX_WORKERS: '3' + DSH_COVERAGE_MAX_WORKERS: '2' DSH_GATE_CONCURRENCY: '2' DSH_PUBLINT_CONCURRENCY: '8' steps: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index ffa387804f..2269c7cbee 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -56,7 +56,7 @@ describe('CI workflow', () => { expect(windowsNative['timeout-minutes']).toBe(60) expect(windowsNative.if).toBe("github.event_name == 'pull_request'") expect(windowsNative.env).toMatchObject({ - DSH_COVERAGE_MAX_WORKERS: '3', + DSH_COVERAGE_MAX_WORKERS: '2', DSH_GATE_CONCURRENCY: '2', DSH_PUBLINT_CONCURRENCY: '8', }) From e94cacd088273a31a3ae19ea33f95ffc90c237f1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:50:20 +0800 Subject: [PATCH 40/57] test(hmr): separate repeated watcher failures --- packages/boot/app-boot/tests/hmr-config.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index 9a1bc250cd..dbb3a8ca83 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -185,6 +185,9 @@ describe('HMR exact config paths', () => { expect(observed.error).toBeInstanceOf(Error) expect(observed.error.message).toBe('42') + // Let Chokidar's atomic-write window close before requiring a distinct + // second notification from the same path. + await new Promise(resolve => setTimeout(resolve, 250)) writeFileSync(filename, 'invalid again') await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected') } finally { From 95eb4bcf40e62a5f22434fe9feaa2ba6f1b0845d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:20:58 +0800 Subject: [PATCH 41/57] test(windows): vary HMR polling writes --- packages/boot/app-boot/tests/hmr-config.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index dbb3a8ca83..d21e222104 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -48,9 +48,13 @@ describe('HMR exact config paths', () => { try { const deadline = Date.now() + 20_000 for (let generation = 1; !observed.includes(expected); generation += 1) { - if (Date.now() >= deadline) throw new Error('HMR did not observe a module change through the alias') + if (Date.now() >= deadline) { + throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`) + } // The watch base, not the writer spelling, is the alias under test. - writeFileSync(filename, `export const generation = ${generation}\n`) + // Grow the file on every write: polling must not depend on timestamp + // precision when several generations land inside one filesystem tick. + writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`) // Leave Chokidar's atomic-write window idle so one coalesced change can publish. await new Promise(resolve => setTimeout(resolve, 250)) } From ae7d6b6398c919d749122758550901213ecf9cb0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:21:07 +0800 Subject: [PATCH 42/57] test(windows): avoid fixture bin shims --- packages/boot/app-boot/tests/repository-cache.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index 13b67c4361..390d2cc9e8 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -166,7 +166,14 @@ describe('RepositoryCache', () => { await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ name: 'repository-plugin-fixture', version: '1.0.0', - scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' }, + scripts: { + // The fixture owns dependency installation, not platform-specific + // node_modules/.bin shim generation during pnpm's Git preparation. + prepack: [ + 'node ./node_modules/repository-build-helper/index.js', + 'node ./node_modules/repository-prepare-helper/index.js', + ].join(' && '), + }, devDependencies: { 'repository-build-helper': 'file:./build-helper', 'repository-prepare-helper': 'file:./prepare-helper', From 9b686a420320b41c8a822535f8aae25c12de9967 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:01:44 +0800 Subject: [PATCH 43/57] ci(wine): bound Node distribution downloads --- ...8-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../2026-08-08-native-windows-pull-request-ci.md | 2 +- ...26-08-08-native-windows-pull-request-ci.zh.md | 2 +- scripts/wine-windows-gates.sh | 16 ++++++++++++---- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 75d7f8b670..19c54de94b 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 4e2ea07e90656f212f0613ffa17184ff2c3c8c45 -2026-08-08-native-windows-pull-request-ci.zh.md: 306243af3f3e0b280dd25caa5c99df2176590f3b +2026-08-08-native-windows-pull-request-ci.md: a1f294b2b9e73a5d14a00780c7b0adfaf58e4702 +2026-08-08-native-windows-pull-request-ci.zh.md: 2c91c21118f5ebbd9f0e341f7d31ec71481db4dc diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 4e2ea07e90..a1f294b2b9 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -10,7 +10,7 @@ The required pull-request Windows verdict needs a fast win32 toolchain signal wi ## Decision -The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that run the workspace build and production site. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. +The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries so a stalled download fails or recovers before consuming the job timeout. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. Every pull request also starts an independent `windows-native` job named `windows node 24 / native complete` on GitHub's standard `windows-2025` image. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. The job is deliberately absent from `all-checks-passed.needs`: the aggregate neither waits for it nor changes conclusion because of it, while the native job retains its own unmasked success or failure result. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 306243af3f..2c91c21118 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 +[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试,使停滞的下载在耗尽作业超时前失败或恢复。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 每个拉取请求还会在 GitHub 标准 `windows-2025` 镜像上启动一个独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。该作业被刻意排除在 `all-checks-passed.needs` 之外:聚合流程既不等待它,也不会因它改变结论;原生作业则保留自身未被掩盖的成功或失败结果。 diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 9bf904a767..d4798fdd7b 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -74,19 +74,27 @@ trap cleanup EXIT mkdir -p "$cache_dir" "$scratch/logs" # ---- provision Windows Node, boot Wine, snapshot + install concurrently ---- +curl_download_args=( + --fail --silent --show-error --location + --retry 3 --retry-all-errors --retry-delay 2 + --connect-timeout 10 --max-time 90 --retry-max-time 240 +) + 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. + # dist directory. Bound and retry every transfer so a stalled nodejs.org + # response cannot consume the entire CI job. 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 \ + version="$(curl "${curl_download_args[@]}" 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 "${curl_download_args[@]}" -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip" local expected - expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \ + expected="$(curl "${curl_download_args[@]}" "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" From dec4d1ffdb1d71c345bb32e72d3fbc039c4d45d6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:13:04 +0800 Subject: [PATCH 44/57] ci(wine): resume partial Node downloads --- scripts/wine-windows-gates.sh | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index d4798fdd7b..34e58274ce 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -74,27 +74,44 @@ trap cleanup EXIT mkdir -p "$cache_dir" "$scratch/logs" # ---- provision Windows Node, boot Wine, snapshot + install concurrently ---- -curl_download_args=( +curl_metadata_args=( --fail --silent --show-error --location --retry 3 --retry-all-errors --retry-delay 2 - --connect-timeout 10 --max-time 90 --retry-max-time 240 + --connect-timeout 10 --max-time 30 --retry-max-time 120 ) +download_node_archive() { + local url="$1" output="$2" attempt status=0 + for attempt in 1 2 3; do + if curl --fail --silent --show-error --location \ + --continue-at - --connect-timeout 10 --max-time 300 \ + --speed-limit 1024 --speed-time 30 \ + -o "$output" "$url"; then + return 0 + else + status=$? + fi + (( attempt < 3 )) || break + echo "wine-windows-gates: Windows Node archive transfer failed (exit $status) on attempt $attempt; resuming partial download" >&2 + done + return "$status" +} + provision_node() { # Latest release of the primary line, checksum-verified against the same # dist directory. Bound and retry every transfer so a stalled nodejs.org # response cannot consume the entire CI job. Offline runs fall back to the # newest cached zip, loudly. local version zip - version="$(curl "${curl_download_args[@]}" https://nodejs.org/dist/index.json 2> /dev/null \ + version="$(curl "${curl_metadata_args[@]}" 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 "${curl_download_args[@]}" -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip" + download_node_archive "https://nodejs.org/dist/$version/node-$version-win-x64.zip" "$zip.tmp" local expected - expected="$(curl "${curl_download_args[@]}" "https://nodejs.org/dist/$version/SHASUMS256.txt" \ + expected="$(curl "${curl_metadata_args[@]}" "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" From 548726eede3efdee74bda3a8da0b3b1e87fb3e3c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:58:29 +0800 Subject: [PATCH 45/57] ci(wine): add verified archive mirror fallback --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 ++-- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- scripts/wine-windows-gates.sh | 22 ++++++++++++++----- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 19c54de94b..cff610e481 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: a1f294b2b9e73a5d14a00780c7b0adfaf58e4702 -2026-08-08-native-windows-pull-request-ci.zh.md: 2c91c21118f5ebbd9f0e341f7d31ec71481db4dc +2026-08-08-native-windows-pull-request-ci.md: 258bca96db04c44ca2a3509c297028e31ef5375e +2026-08-08-native-windows-pull-request-ci.zh.md: dd0031630eea9bb09a4a4db0125933ba0c7bac2f diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index a1f294b2b9..258bca96db 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -10,7 +10,7 @@ The required pull-request Windows verdict needs a fast win32 toolchain signal wi ## Decision -The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries so a stalled download fails or recovers before consuming the job timeout. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. +The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. Every pull request also starts an independent `windows-native` job named `windows node 24 / native complete` on GitHub's standard `windows-2025` image. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. The job is deliberately absent from `all-checks-passed.needs`: the aggregate neither waits for it nor changes conclusion because of it, while the native job retains its own unmasked success or failure result. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 2c91c21118..dd0031630e 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试,使停滞的下载在耗尽作业超时前失败或恢复。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 +[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 每个拉取请求还会在 GitHub 标准 `windows-2025` 镜像上启动一个独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。该作业被刻意排除在 `all-checks-passed.needs` 之外:聚合流程既不等待它,也不会因它改变结论;原生作业则保留自身未被掩盖的成功或失败结果。 diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 34e58274ce..5e46b75f91 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -77,22 +77,32 @@ mkdir -p "$cache_dir" "$scratch/logs" curl_metadata_args=( --fail --silent --show-error --location --retry 3 --retry-all-errors --retry-delay 2 - --connect-timeout 10 --max-time 30 --retry-max-time 120 + --http1.1 --connect-timeout 10 --max-time 30 --retry-max-time 120 ) download_node_archive() { - local url="$1" output="$2" attempt status=0 + local version="$1" output="$2" attempt status=0 + local archive="node-$version-win-x64.zip" + local primary_url="https://nodejs.org/dist/$version/$archive" + local mirror_url="https://npmmirror.com/mirrors/node/$version/$archive" + + if curl --fail --silent --show-error --location --http1.1 \ + --connect-timeout 10 --max-time 300 --speed-limit 1024 --speed-time 30 \ + -o "$output" "$primary_url"; then + return 0 + fi + echo 'wine-windows-gates: nodejs.org archive transfer stalled; resuming from the checksum-untrusted transport mirror' >&2 for attempt in 1 2 3; do - if curl --fail --silent --show-error --location \ + if curl --fail --silent --show-error --location --http1.1 \ --continue-at - --connect-timeout 10 --max-time 300 \ --speed-limit 1024 --speed-time 30 \ - -o "$output" "$url"; then + -o "$output" "$mirror_url"; then return 0 else status=$? fi (( attempt < 3 )) || break - echo "wine-windows-gates: Windows Node archive transfer failed (exit $status) on attempt $attempt; resuming partial download" >&2 + echo "wine-windows-gates: mirror transfer failed (exit $status) on attempt $attempt; resuming partial download" >&2 done return "$status" } @@ -109,7 +119,7 @@ provision_node() { if [ -n "$version" ]; then zip="$cache_dir/node-$version-win-x64.zip" if [ ! -f "$zip" ]; then - download_node_archive "https://nodejs.org/dist/$version/node-$version-win-x64.zip" "$zip.tmp" + download_node_archive "$version" "$zip.tmp" local expected expected="$(curl "${curl_metadata_args[@]}" "https://nodejs.org/dist/$version/SHASUMS256.txt" \ | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')" From 6f27e6c2cea0ceeb86ef8887e3e2518e898567f7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:44:10 +0800 Subject: [PATCH 46/57] test(windows): compare canonical HMR identities --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 2 +- .../2026-08-08-native-windows-pull-request-ci.zh.md | 2 +- packages/boot/app-boot/tests/hmr-config.spec.ts | 7 ++++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index cff610e481..cb77330b29 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 258bca96db04c44ca2a3509c297028e31ef5375e -2026-08-08-native-windows-pull-request-ci.zh.md: dd0031630eea9bb09a4a4db0125933ba0c7bac2f +2026-08-08-native-windows-pull-request-ci.md: b8de631f96f7ff27c122971448f115d198b24a60 +2026-08-08-native-windows-pull-request-ci.zh.md: 433904ef513d3f92a6350eeb99f071711b49daf6 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 258bca96db..b8de631f96 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -16,7 +16,7 @@ Every pull request also starts an independent `windows-native` job named `window The native gate keeps workspace build and production-site failures blocking inside its own job while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. -The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. +The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index dd0031630e..433904ef51 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -16,7 +16,7 @@ Status: implemented 原生门禁在其自身作业内继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 -首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。 +首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture(测试前置数据)通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index d21e222104..82cbd71c28 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -1,4 +1,5 @@ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -40,7 +41,7 @@ describe('HMR exact config paths', () => { // This acceptance owns alias-to-cache identity. Other cases below exercise // native events; polling keeps Windows fs.watch queue pressure out of it. const ctx = await bootHmr(alias, ['.'], true) - const filename = join(realpathSync(target), 'module.ts') + const filename = join(await realpath(target), 'module.ts') const expected = pathToFileURL(filename).href const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) const observed: string[] = [] @@ -73,7 +74,7 @@ describe('HMR exact config paths', () => { const ctx = await bootHmr(alias) try { await ctx.hmr.registerConfig('plugins.yml', () => {}) - await expect(ctx.hmr.registerConfig(join(realpathSync(target), 'plugins.yml'), () => {})) + await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {})) .rejects.toThrow('config path already registered') } finally { await ctx.fiber.dispose() From e308268abcbd1adb7ec52c1261f9f7a6bb7266e6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:50:13 +0800 Subject: [PATCH 47/57] fix(windows): force isolated repository pnpm wrapper --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- .../app-boot/tests/repository-cache.spec.ts | 16 +++++++++++- vendor/README.md | 2 +- vendor/loader/src/repository.ts | 25 ++++++++++++++++--- 6 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 9351bdbac1..32ed0e8042 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 58380cbf2f4347b573a1646f0e1350c2a2bc40e9 -2026-08-08-native-windows-pull-request-ci.zh.md: 2e13b0d4ce0acdf9aef9856ea39ee6cd20fc406c +2026-08-08-native-windows-pull-request-ci.md: 7b80f9fbe49b32de19be51b3a0c0e40156ed00ea +2026-08-08-native-windows-pull-request-ci.zh.md: 734ebbf1c1b71d9fbbd4ccf4c0b60f802ad261e9 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 58380cbf2f..7b80f9fbe4 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -26,7 +26,7 @@ Portable filesystem fixtures derive paths with `node:path`, compare native realp Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary. -Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration so a nested Git-package install cannot rejoin an enclosing workspace when Windows lifecycle setup takes precedence over the transaction-owned command wrapper. +Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration, retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT`; nested Git-package installation therefore cannot rejoin the enclosing workspace or let an inherited Windows pnpm executable outrank the transaction-owned wrapper. Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 2e13b0d4ce..734ebbf1c1 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -26,7 +26,7 @@ Status: implemented 原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。 -Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,因此当 Windows 生命周期设置的 PATH 优先级绕过由事务持有的命令包装层时,嵌套的 Git 包安装也不会重新加入外层 workspace。 +Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,保留 `PNPM_HOME` 作为 pnpm 数据配置,同时从生命周期 `PATH` 中移除该目录,并在 `PATHEXT` 中优先选择 `.CMD`;因此,嵌套 Git 包安装既不会重新加入外层 workspace,也不会让继承的 Windows pnpm 可执行文件抢在事务持有的 wrapper 之前。 启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index 390d2cc9e8..71aa90a440 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -2,7 +2,7 @@ import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, join } from 'node:path' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -122,6 +122,10 @@ describe('RepositoryCache', () => { await mkdir(join(repository, '.dsh-plugin', 'build-helper'), { recursive: true }) await mkdir(join(repository, '.dsh-plugin', 'prepare-helper'), { recursive: true }) await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) + const shadowPnpm = join(root, 'shadow-pnpm') + await mkdir(shadowPnpm) + await writeFile(join(shadowPnpm, 'pnpm'), '#!/bin/sh\nexit 99\n', { mode: 0o700 }) + await writeFile(join(shadowPnpm, 'pnpm.bat'), '@exit /b 99\r\n') await writeFile(join(repository, 'package.json'), `${JSON.stringify({ name: 'repository-fixture', private: true, @@ -160,6 +164,7 @@ describe('RepositoryCache', () => { "cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", "writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')", "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}|${process.env.PNPM_CONFIG_IGNORE_WORKSPACE ?? 'absent'}\\n`)", + "writeFileSync('environment.json', `${JSON.stringify({ path: process.env.PATH, pathExt: process.env.PATHEXT })}\\n`)", '', ].join('\n'), { mode: 0o700 }) await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') @@ -191,10 +196,19 @@ describe('RepositoryCache', () => { const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin` vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') + vi.stubEnv('PNPM_HOME', shadowPnpm) + vi.stubEnv('PATH', [shadowPnpm, ...(process.env.PATH === undefined ? [] : [process.env.PATH])].join(delimiter)) + vi.stubEnv('PATHEXT', '.BAT;.CMD;.EXE') const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n') await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent|true\n') + const environment = JSON.parse(await readFile(join(installed, 'environment.json'), 'utf8')) as { + path: string + pathExt: string + } + expect(environment.path.split(delimiter)).not.toContain(shadowPnpm) + expect(environment.pathExt.split(';')[0]?.toUpperCase()).toBe('.CMD') await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))) .toBe('repository skill source\n') diff --git a/vendor/README.md b/vendor/README.md index fa31982101..75f93b2b88 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper and inherited `PNPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace, including when Windows lifecycle setup prepends its own command directory to `PATH`. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper and exported `PNPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace. The child retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT` so a later inherited pnpm executable cannot outrank the wrapper on Windows. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile, obtains both its build and prepare commands from declared dependencies, and rejects an inherited shadow pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts index b24525554b..c85b84ccfc 100644 --- a/vendor/loader/src/repository.ts +++ b/vendor/loader/src/repository.ts @@ -36,16 +36,35 @@ function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS. return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) } +function normalizedEnvironmentPath(value: string): string { + const unquoted = value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value + const normalized = resolve(unquoted) + return process.platform === 'win32' ? normalized.toUpperCase() : normalized +} + function installEnvironment(commandDirectory: string): NodeJS.ProcessEnv { const scrubbed = scrubEnvironment() const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1] + const pathExt = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATHEXT')?.[1] + const pnpmHome = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PNPM_HOME')?.[1] + const normalizedPnpmHome = pnpmHome === undefined ? undefined : normalizedEnvironmentPath(pnpmHome) + const inheritedPath = path === undefined ? [] : path.split(delimiter).filter((entry) => { + return normalizedPnpmHome === undefined || normalizedEnvironmentPath(entry) !== normalizedPnpmHome + }) + const pathExtensions = pathExt?.split(';') + const prioritizedPathExt = pathExtensions === undefined ? undefined : [ + ...pathExtensions.filter(extension => extension.toUpperCase() === '.CMD'), + ...pathExtensions.filter(extension => extension.toUpperCase() !== '.CMD'), + ].join(';') const withoutOverrides = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => { - return name.toUpperCase() !== 'PATH' && name.toUpperCase() !== 'PNPM_CONFIG_IGNORE_WORKSPACE' + return !['PATH', 'PATHEXT', 'PNPM_CONFIG_IGNORE_WORKSPACE'].includes(name.toUpperCase()) })) return { ...withoutOverrides, - PATH: [commandDirectory, ...(path === undefined ? [] : [path])].join(delimiter), - // pnpm prepends its own command directory to lifecycle PATH on Windows. + PATH: [commandDirectory, ...inheritedPath].join(delimiter), + // cmd.exe tests PATHEXT before later PATH entries, so the transaction's + // pnpm.cmd must precede an inherited pnpm executable from PNPM_HOME. + ...(prioritizedPathExt === undefined ? {} : { PATHEXT: prioritizedPathExt }), PNPM_CONFIG_IGNORE_WORKSPACE: 'true', } } From 4619f183ff297c7ec42b6c26cd0fee2a5a08c374 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:44:57 +0800 Subject: [PATCH 48/57] fix(cli): drain native handles on normal exit --- ...3-cli-signal-shutdown-escalation.i18n.yaml | 4 +- ...26-08-03-cli-signal-shutdown-escalation.md | 10 ++- ...08-03-cli-signal-shutdown-escalation.zh.md | 10 ++- apps/cli/src/process-shutdown.ts | 51 ++++++++---- apps/cli/tests/process-shutdown.spec.ts | 78 +++++++++++++++---- 5 files changed, 114 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 5c7deb9700..34f0f1457e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: c8aac6e2be927bd1f4a445c00e0aaa870b10a465 -2026-08-03-cli-signal-shutdown-escalation.zh.md: 14e2149eb5153b3743e188c6b233d000b36d60db +2026-08-03-cli-signal-shutdown-escalation.md: 55917400fac2728d13dc2cdd799a7e234b6ed661 +2026-08-03-cli-signal-shutdown-escalation.zh.md: c7897a8d77e8c2ebad43cec4e12170b04c837350 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index c8aac6e2be..55917400fa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -20,11 +20,13 @@ The fix has two ownership layers. The OTel backend adds `shutdownTimeoutMillis` Web and headless share `createProcessShutdown`, one process-level controller around root disposal: -- Normal shutdown calls coalesce onto one disposal and retain the first requested exit code; they never escalate one another. +- Normal shutdown calls coalesce onto one disposal and retain the first requested exit code; they never escalate one another. Successful disposal records that code through `process.exitCode` and lets Node drain its remaining handles naturally. Disposal failure still forces process exit because the launcher cannot assume the failed tree reached quiescence. - The first signal starts the same graceful disposal and a referenced five-second exit backstop. Disposal success or failure exits once; neither can cancel the process exit. - A signal received while shutdown is pending forces immediate exit with that signal path's code. This includes the first `Ctrl+C` after headless normal completion has already entered disposal, and a second signal after a signal initiated the drain. - The five-second bound is a process-safety invariant, not a deployment tunable. It is long enough for the telemetry deployment's ordinary drain ceiling while still bounding any wedged disposer at the launcher boundary. +Normal completion deliberately avoids `process.exit()`: an immediately forced exit after an Undici request can hit Node's [Windows libuv async-handle assertion](https://github.com/nodejs/node/issues/56645) before the completed request's native handle cleanup drains. A signal can still force exit after normal disposal has completed if another handle keeps the process alive. + Headless preserves exit 0 for a completed turn, exit 1 for another turn-end reason or API business error, 130 for SIGINT, and 143 for SIGTERM. Web preserves its existing SIGTERM exit 0 and SIGINT exit 130 behavior. This supersedes the [telemetry deployment Note's](../feature/2026-07-31-web-telemetry-default-mount.md) assumption that SDK exporter/processor timeouts bound complete provider shutdown, and its earlier decision to defer a process-level backstop. The backend owns its export loss/latency policy and closes the known SDK `forceFlush()` gap; the launcher owns the outer guarantee that no plugin can trap the process indefinitely. @@ -37,15 +39,17 @@ This supersedes the [telemetry deployment Note's](../feature/2026-07-31-web-tele **Add only the five-second timeout.** Rejected because a user pressing `Ctrl+C` again is asking to stop waiting now. Swallowing that intent for the rest of the grace period recreates the reported behavior at a shorter duration. +**Always call `process.exit()` after successful disposal.** Rejected because root disposal proves the application tree is quiescent, not that Node and its native dependencies have finished retiring every asynchronous handle. Setting `process.exitCode` preserves the requested status while letting the runtime finish that work. + ## Consequences -A healthy exit still disposes the complete Cordis tree. The known telemetry wait releases after at most three seconds; any other wedged exit lasts at most five seconds without further input, and a repeated signal ends it immediately. Forced or deadline-bounded exit can interrupt telemetry export or remaining cleanup, which is intentional only after the graceful contract has failed or the user has explicitly escalated. +A healthy normal exit still disposes the complete Cordis tree and then waits for Node's event loop to drain. The known telemetry wait releases after at most three seconds; any other wedged exit lasts at most five seconds without further input, and a signal ends a lingering normal completion or pending shutdown immediately. Forced or deadline-bounded exit can interrupt telemetry export or remaining cleanup, which is intentional only after the graceful contract has failed or the user has explicitly escalated. The controller is launcher infrastructure rather than a Cordis plugin: it makes no claim that disposal completed, and it does not weaken the lifecycle rule that ordinary disposers must reach quiescence. ## Testing -`apps/cli/tests/process-shutdown.spec.ts` pins resolved and rejected disposal, the five-second backstop, normal-call coalescing, a signal interrupting normal disposal, and second-signal escalation. +`apps/cli/tests/process-shutdown.spec.ts` pins natural completion after resolved disposal, forced exit after rejected disposal, the five-second backstop, normal-call coalescing, signal-owned disposal, a signal interrupting normal disposal or post-disposal handle draining, and second-signal escalation. `apps/cli/tests/headless-shutdown.e2e.ts` boots the real shipped Web/headless Loader tree in a PTY with a test-only plugin whose disposer announces entry and never settles. The test sends SIGINT after the observation URL, waits for proof that disposal started, sends SIGINT again, and requires exit 130. The source/artifact launch resolver keeps the same regression on both execution planes. This PTY case covers the user-visible process state; no model-output snapshot changes. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index 14e2149eb5..c7897a8d77 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -20,11 +20,13 @@ Status: implemented Web 与 headless 共用 `createProcessShutdown`,它是围绕根级 dispose 建立的进程级控制器: -- 多次正常关闭调用会汇合到同一次 dispose,并保留首次请求的退出码;这些调用不会相互触发强制退出。 +- 多次正常关闭调用会汇合到同一次 dispose,并保留首次请求的退出码;这些调用不会相互触发强制退出。dispose 成功后,控制器通过 `process.exitCode` 记录该退出码,让 Node 自然排空剩余句柄;dispose 失败时仍强制退出,因为启动器不能假定失败的插件树已经完全停稳。 - 第一个信号会启动同一次优雅 dispose,并设置一个带引用的 5 秒退出兜底。dispose 无论成功或失败都会触发且仅触发一次退出;任何一种结果都无法取消进程退出。 - 关闭待结算期间收到信号时,会立即按该信号路径的退出码强制退出。这既包括 headless 正常完成已经进入 dispose 后收到的第一次 `Ctrl+C`,也包括由信号启动排空后收到的第二个信号。 - 5 秒上限是进程安全不变式,而不是部署调节项。它足以覆盖遥测部署的常规排空时限,同时仍在启动器边界为任何卡死的 disposer 设置等待上限。 +正常完成会刻意避免调用 `process.exit()`:Undici 请求刚完成后立即强制退出,可能会在原生句柄清理尚未排空时触发 Node 的 [Windows libuv 异步句柄断言](https://github.com/nodejs/node/issues/56645)。如果正常 dispose 已经完成,但仍有其他句柄让进程保持存活,信号依然可以强制退出。 + headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API 业务错误仍以 1 退出,对 SIGINT 以 130 退出,对 SIGTERM 以 143 退出。Web 保留现有行为:SIGTERM 以 0 退出,SIGINT 以 130 退出。 这项决策取代了[遥测部署 Agent Note](../feature/2026-07-31-web-telemetry-default-mount.md) 中 SDK 导出器/处理器超时能够限制提供方完整关闭流程的假设,也取代了其中暂缓进程级退出兜底的决定。后端负责导出数据丢失与延迟策略,并封住已知的 SDK `forceFlush()` 缺口;启动器负责最外层保证,确保任何插件都无法无限期困住进程。 @@ -37,15 +39,17 @@ headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API **只增加 5 秒超时。** 不予采纳:用户再次按下 `Ctrl+C`,就是要求立即停止等待。若在剩余宽限期内继续吞掉这一意图,只是缩短了报告中故障的持续时间,并未解决问题。 +**dispose 成功后仍总是调用 `process.exit()`。** 不予采纳:根级 dispose 只能证明应用插件树已经完全停稳,不能证明 Node 及其原生依赖已经回收所有异步句柄。设置 `process.exitCode` 既保留请求的状态码,也允许运行时完成这部分工作。 + ## 后果 -健康的退出流程仍会对整棵 Cordis 插件树执行 dispose。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒,再次收到信号则立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭约定已经失败,或用户明确要求强制退出时,才会有意接受这一结果。 +健康的正常退出流程仍会对整棵 Cordis 插件树执行 dispose,随后等待 Node 事件循环自然排空。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒;收到信号时,仍在排空句柄的正常完成流程或待结算的关闭流程都会立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭约定已经失败,或用户明确要求强制退出时,才会有意接受这一结果。 该控制器属于启动器基础设施,而不是 Cordis 插件:它不会声称 dispose 已经完成,也不会削弱普通 disposer 必须达到完全停稳状态的生命周期规则。 ## 测试 -`apps/cli/tests/process-shutdown.spec.ts` 固定了 dispose 成功与失败、5 秒退出兜底、正常调用汇合、信号中断正常 dispose,以及第二次信号强制退出的行为。 +`apps/cli/tests/process-shutdown.spec.ts` 固定了 dispose 成功后的自然完成、dispose 失败后的强制退出、5 秒退出兜底、正常调用汇合、信号拥有的 dispose、信号中断正常 dispose 或 dispose 后句柄排空,以及第二次信号强制退出的行为。 `apps/cli/tests/headless-shutdown.e2e.ts` 在 PTY 中启动真实交付的 Web/headless Loader 插件树,并挂载一个仅用于测试的插件;该插件的 disposer 会声明已经进入清理流程,但永不结算。测试在观察地址出现后发送 SIGINT,等待 dispose 已启动的证据,再次发送 SIGINT,并要求进程以 130 退出。源码/产物启动解析器使两个执行平面都覆盖同一项回归。该 PTY 用例覆盖用户可见的进程状态;模型输出快照没有变化。 diff --git a/apps/cli/src/process-shutdown.ts b/apps/cli/src/process-shutdown.ts index afc7d6fa3d..7ef9d32632 100644 --- a/apps/cli/src/process-shutdown.ts +++ b/apps/cli/src/process-shutdown.ts @@ -5,54 +5,73 @@ export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000 /** Process-exit controller shared by normal completion and Unix signal handlers. */ export interface ProcessShutdown { - /** Start or join graceful disposal before exiting with `code`. */ + /** Start or join graceful disposal before allowing natural completion with `code`. */ shutdown(code: number): Promise - /** Start graceful disposal, or force exit when a shutdown is already running. */ + /** Start graceful disposal followed by exit, or force exit when shutdown is already running. */ interrupt(code: number): void } /** * Create one process-exit controller around an application disposer. * @param dispose - Whole-application teardown that resolves at quiescence. - * @param exit - Process exit boundary, replaceable by tests. + * @param forceExit - Forced process exit boundary, replaceable by tests. + * @param complete - Natural process completion boundary, replaceable by tests. * @param timeoutMs - Grace before forced exit, replaceable by tests. * @returns A controller whose normal calls coalesce and whose repeated signal call escalates. */ export function createProcessShutdown( dispose: () => Promise, - exit: (code: number) => void = (code) => { process.exit(code) }, + forceExit: (code: number) => void = (code) => { process.exit(code) }, + complete: (code: number) => void = (code) => { process.exitCode = code }, timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS, ): ProcessShutdown { let pending: Promise | undefined let timeout: ReturnType | undefined - let exited = false + let completed = false + let forceExited = false - const exitOnce = (code: number): void => { - if (exited) return - exited = true + const clearExitTimeout = (): void => { /* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */ if (timeout !== undefined) clearTimeout(timeout) - exit(code) } - const shutdown = (code: number): Promise => { + const forceExitOnce = (code: number): void => { + if (forceExited) return + forceExited = true + clearExitTimeout() + forceExit(code) + } + + const completeOnce = (code: number): void => { + if (completed || forceExited) return + completed = true + clearExitTimeout() + complete(code) + } + + const start = (code: number, forceAfterDispose: boolean): Promise => { if (pending !== undefined) return pending - timeout = setTimeout(() => { exitOnce(code) }, timeoutMs) + timeout = setTimeout(() => { forceExitOnce(code) }, timeoutMs) pending = Promise.resolve().then(dispose).then( - () => { exitOnce(code) }, - () => { exitOnce(code) }, + () => { + if (forceAfterDispose) forceExitOnce(code) + else completeOnce(code) + }, + () => { forceExitOnce(code) }, ) return pending } return { - shutdown, + shutdown(code) { + return start(code, false) + }, interrupt(code) { if (pending !== undefined) { - exitOnce(code) + forceExitOnce(code) return } - void shutdown(code) + void start(code, true) }, } } diff --git a/apps/cli/tests/process-shutdown.spec.ts b/apps/cli/tests/process-shutdown.spec.ts index feceba708f..95e54fe754 100644 --- a/apps/cli/tests/process-shutdown.spec.ts +++ b/apps/cli/tests/process-shutdown.spec.ts @@ -20,35 +20,50 @@ afterEach(() => { }) describe('process shutdown', () => { - it('exits once after graceful disposal resolves or rejects', async () => { + it('completes naturally after disposal resolves and forces exit when it rejects', async () => { const resolvedExit = vi.fn() - const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit) + const resolvedComplete = vi.fn() + const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete) await resolved.shutdown(0) - expect(resolvedExit).toHaveBeenCalledOnce() - expect(resolvedExit).toHaveBeenCalledWith(0) + expect(resolvedComplete).toHaveBeenCalledOnce() + expect(resolvedComplete).toHaveBeenCalledWith(0) + expect(resolvedExit).not.toHaveBeenCalled() const rejectedExit = vi.fn() - const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit) + const rejectedComplete = vi.fn() + const rejected = createProcessShutdown( + () => Promise.reject(new Error('dispose failed')), + rejectedExit, + rejectedComplete, + ) await rejected.shutdown(1) expect(rejectedExit).toHaveBeenCalledOnce() expect(rejectedExit).toHaveBeenCalledWith(1) + expect(rejectedComplete).not.toHaveBeenCalled() }) - it('uses process.exit as the default process boundary', async () => { + it('uses process.exitCode for default normal completion', async () => { const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never) + const originalExitCode = process.exitCode + process.exitCode = undefined const shutdown = createProcessShutdown(() => Promise.resolve()) - await shutdown.shutdown(7) + try { + await shutdown.shutdown(7) - expect(exit).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(7) + expect(process.exitCode).toBe(7) + expect(exit).not.toHaveBeenCalled() + } finally { + process.exitCode = originalExitCode + } }) it('forces exit when graceful disposal reaches its bound', async () => { vi.useFakeTimers() const disposal = deferred() const exit = vi.fn() - const shutdown = createProcessShutdown(() => disposal.promise, exit) + const complete = vi.fn() + const shutdown = createProcessShutdown(() => disposal.promise, exit, complete) const pending = shutdown.shutdown(0) await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1) @@ -60,13 +75,14 @@ describe('process shutdown', () => { disposal.resolve() await pending expect(exit).toHaveBeenCalledOnce() + expect(complete).not.toHaveBeenCalled() }) it('honors a caller-supplied grace period', async () => { vi.useFakeTimers() const disposal = deferred() const exit = vi.fn() - const shutdown = createProcessShutdown(() => disposal.promise, exit, 25) + const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25) const pending = shutdown.shutdown(0) await vi.advanceTimersByTimeAsync(24) @@ -81,7 +97,8 @@ describe('process shutdown', () => { it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => { const disposal = deferred() const exit = vi.fn() - const shutdown = createProcessShutdown(() => disposal.promise, exit) + const complete = vi.fn() + const shutdown = createProcessShutdown(() => disposal.promise, exit, complete) const pending = shutdown.shutdown(0) shutdown.interrupt(130) @@ -91,13 +108,29 @@ describe('process shutdown', () => { disposal.resolve() await pending expect(exit).toHaveBeenCalledOnce() + expect(complete).not.toHaveBeenCalled() + }) + + it('forces exit after disposal started by a signal', async () => { + const disposal = deferred() + const exit = vi.fn() + const complete = vi.fn() + const shutdown = createProcessShutdown(() => disposal.promise, exit, complete) + + shutdown.interrupt(143) + disposal.resolve() + await shutdown.shutdown(0) + + expect(exit).toHaveBeenCalledOnce() + expect(exit).toHaveBeenCalledWith(143) + expect(complete).not.toHaveBeenCalled() }) it('drains on the first signal and forces on the second signal', async () => { const disposal = deferred() const dispose = vi.fn(() => disposal.promise) const exit = vi.fn() - const shutdown = createProcessShutdown(dispose, exit) + const shutdown = createProcessShutdown(dispose, exit, vi.fn()) shutdown.interrupt(143) await Promise.resolve() @@ -116,7 +149,8 @@ describe('process shutdown', () => { it('coalesces normal shutdown calls without treating them as escalation', async () => { const disposal = deferred() const exit = vi.fn() - const shutdown = createProcessShutdown(() => disposal.promise, exit) + const complete = vi.fn() + const shutdown = createProcessShutdown(() => disposal.promise, exit, complete) const first = shutdown.shutdown(0) const second = shutdown.shutdown(1) @@ -125,7 +159,21 @@ describe('process shutdown', () => { disposal.resolve() await first + expect(complete).toHaveBeenCalledOnce() + expect(complete).toHaveBeenCalledWith(0) + expect(exit).not.toHaveBeenCalled() + }) + + it('lets a signal force exit while natural completion drains remaining handles', async () => { + const exit = vi.fn() + const complete = vi.fn() + const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete) + + await shutdown.shutdown(0) + shutdown.interrupt(130) + + expect(complete).toHaveBeenCalledOnce() expect(exit).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(0) + expect(exit).toHaveBeenCalledWith(130) }) }) From 3fb06728be4a2784d3f54eae930f9c93409497d0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:45:06 +0800 Subject: [PATCH 49/57] test(windows): cover portable credential stat errors --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 4 ++-- .../2026-08-08-native-windows-pull-request-ci.zh.md | 4 ++-- packages/credentials/credentials-local/tests/local.spec.ts | 7 +++++++ 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 32ed0e8042..47608756de 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 7b80f9fbe49b32de19be51b3a0c0e40156ed00ea -2026-08-08-native-windows-pull-request-ci.zh.md: 734ebbf1c1b71d9fbbd4ccf4c0b60f802ad261e9 +2026-08-08-native-windows-pull-request-ci.md: ddc66b433874b0a95c4dd30669f1f0d2804cdd3e +2026-08-08-native-windows-pull-request-ci.zh.md: 45518cd1a83b35bd441c00b5666f61f04e9a0705 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 7b80f9fbe4..ddc66b4338 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -22,13 +22,13 @@ The standard native lane gives coverage and the top-level gate scheduler one wor The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. -Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. +Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary. Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration, retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT`; nested Git-package installation therefore cannot rejoin the enclosing workspace or let an inherited Windows pnpm executable outrank the transaction-owned wrapper. -Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. +Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 734ebbf1c1..45518cd1a8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -22,13 +22,13 @@ Status: implemented 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 -可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 +可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。 Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,保留 `PNPM_HOME` 作为 pnpm 数据配置,同时从生命周期 `PATH` 中移除该目录,并在 `PATHEXT` 中优先选择 `.CMD`;因此,嵌套 Git 包安装既不会重新加入外层 workspace,也不会让继承的 Windows pnpm 可执行文件抢在事务持有的 wrapper 之前。 -启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 +启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 7130d85db4..65635a09c6 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -191,6 +191,13 @@ describe('layer ladder', () => { .rejects.toThrow(/ENOTDIR/) }) + it('propagates a permission check rejected before the OS lookup', async () => { + const dir = await tempDir() + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials\0.yaml'), watch: false })) + .rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) + }) + it('propagates a read that fails for a reason other than absence', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') From 681f7c1315026c70bf0cedca67ea1ba861466871 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:41:38 +0800 Subject: [PATCH 50/57] test(windows): terminate worker fixtures from host --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- .../tests/workflow-workerthread.spec.ts | 60 ++++++++----------- 4 files changed, 30 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 47608756de..62467124e2 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: ddc66b433874b0a95c4dd30669f1f0d2804cdd3e -2026-08-08-native-windows-pull-request-ci.zh.md: 45518cd1a83b35bd441c00b5666f61f04e9a0705 +2026-08-08-native-windows-pull-request-ci.md: 9c6663048683e2a0501622109d4e34c557af2c09 +2026-08-08-native-windows-pull-request-ci.zh.md: 51074b51abe228745af450741cbbdfd6a0fb7927 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index ddc66b4338..9c66630486 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -22,7 +22,7 @@ The standard native lane gives coverage and the top-level gate scheduler one wor The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. -Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. +Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 45518cd1a8..51074b51ab 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -22,7 +22,7 @@ Status: implemented 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 -可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 +可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。 原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。 diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 79aad3af42..de190349a4 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1239,22 +1239,18 @@ describe('dsh-workflow-workerthread', () => { await ctx.plugin(WorkerWorkflowEngine, { provider: 'doomed', maxConcurrentAgents: 2 }) const runEnds: WorkflowResultInfo[] = [] ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) + const childStarted = Promise.withResolvers() + ctx.on('workflow/agent-start', () => { childStarted.resolve(undefined) }) const handle = ctx.workflows.start({ - // The stray child's start RPC reaches the host, then the script kills - // its own worker through the documented vm escape — the host must - // settle `error` with the exit diagnostics and wind the child down. - ...scripted(` - agent('doomed') - const proc = ${ESCAPE} - const st = globalThis.constructor.constructor('return setTimeout')() - await new Promise(resolve => st(resolve, 200)) - proc.exit(7) - `), + ...scripted("return await agent('doomed')"), parent: fakeParent(), }) + const worker = (handle as unknown as { worker: Worker }).worker + await childStarted.promise + await worker.terminate() const result = await handle.result expect(result.stopReason).toBe('error') - expect(result.error).toContain('exit code 7') + expect(result.error).toContain('exit code 1') expect(result.agentsStarted).toBe(1) // A worker death is a stop reason like any other: workflow/end fires // with the error outcome — for a bus observer it is the only obituary. @@ -1306,26 +1302,22 @@ describe('dsh-workflow-workerthread', () => { }) ctx.on('workflow/end', () => { order.push('run-end') }) const handle = ctx.workflows.start({ - // Same choreography as the force-settle pairing test, but the worker - // DIES (the documented vm escape) instead of being terminated: the - // exit path must close slow's pair from the ledger too. The escaped - // setTimeout lets the already-posted messages flush before the kill. ...scripted(` const p = agent('slow') await agent('fast') - const proc = ${ESCAPE} - const st = globalThis.constructor.constructor('return setTimeout')() - await new Promise(resolve => st(resolve, 150)) - proc.exit(7) + await new Promise(() => {}) `), parent, }) + const worker = (handle as unknown as { worker: Worker }).worker await waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) }) const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')! fast.settle(text('fast done')) + await waitFor(() => { expect(ends).toContainEqual({ seq: 2, outcome: 'completed' }) }) + await worker.terminate() const result = await handle.result expect(result.stopReason).toBe('error') - expect(result.error).toContain('exit code 7') + expect(result.error).toContain('exit code 1') expect(ends).toEqual([ { seq: 2, outcome: 'completed' }, { seq: 1, outcome: 'cancelled' }, @@ -1340,21 +1332,22 @@ describe('dsh-workflow-workerthread', () => { // guard in post()). const { ctx, parent, provider } = await setup({ disposeDelayMs: 300 }) const handle = ctx.workflows.start({ - // The STRAY child settles instantly, so its wrapper starts the slow - // host-side disposal concurrently while the script goes on to kill - // its own worker — the ack then resolves into a dead thread. ...scripted(` agent('stray, never awaited') - const proc = ${ESCAPE} - const st = globalThis.constructor.constructor('return setTimeout')() - await new Promise(resolve => st(resolve, 150)) - proc.exit(5) + await new Promise(() => {}) `), parent, }) + const worker = (handle as unknown as { worker: Worker }).worker + await waitFor(() => { + expect(provider.runs).toHaveLength(1) + expect(provider.runs[0]!.disposeCalls).toBe(1) + expect(provider.runs[0]!.disposed).toBe(false) + }) + await worker.terminate() const result = await handle.result expect(result.stopReason).toBe('error') - expect(result.error).toContain('exit code 5') + expect(result.error).toContain('exit code 1') // Result already settled — this is the reap's promptness (bounded // above the mock's fixed 300ms dispose delay, not a cold-start race); // tight explicit bound (see the helper's doc comment). @@ -1366,20 +1359,19 @@ describe('dsh-workflow-workerthread', () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } }) const handle = ctx.workflows.start({ ...scripted(` - const proc = ${ESCAPE} - const st = globalThis.constructor.constructor('return setTimeout')() log('armed') - await new Promise(resolve => st(resolve, 400)) - proc.exit(3) + await new Promise(() => {}) `), parent, }) + const worker = (handle as unknown as { worker: Worker }).worker const logs: string[] = [] ctx.on('workflow/log', (_info, message) => { logs.push(message) }) await waitFor(() => { expect(logs).toContain('armed') }) handle.cancel('stop it') - // The grace is deliberately huge: only the worker's own death (exit 3, - // unreachable by the cancel — the script ignores hooks) settles this. + // The grace is deliberately huge: only the host-triggered worker death, + // not the cancellation timer, settles this. + await worker.terminate() const result = await handle.result expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('stop it') From 4a7d4375628dc24d3ff9d09b7fe28161e487d7b7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:50:26 +0800 Subject: [PATCH 51/57] docs(skills): fix dsh-trim-cot-leakage review findings from #2117 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The battery self-exclusion widens from references/ to the whole skill directory (SKILL.md quotes the same leaked shapes), and the invocation rules name the owning note's evidence self-hits; natural-language battery lines gain -i so sentence-initial capitals hit; the actually/ wait false-positive family states that no committed line probes it. The one test now says resolvability clears only this skill's bar — current-state surfaces still reject resolvable change stories; the never-deletion-alone rule is scoped to passages that carry factual clauses. The first few-shot Fixed example links the real web input-machine note path. The citations note's Verification exemption (+zh, pair re-recorded) names the skill's files and the note's own quoted evidence, making the claim mechanically honest. --- ...026-08-09-committed-artifact-citations.i18n.yaml | 4 ++-- .../2026-08-09-committed-artifact-citations.md | 2 +- .../2026-08-09-committed-artifact-citations.zh.md | 2 +- .agents/skills/dsh-trim-cot-leakage/SKILL.md | 6 +++--- .../dsh-trim-cot-leakage/references/examples.md | 6 +++--- .../references/recall-batteries.md | 13 +++++++------ 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml index 04c3e6a7df..0813498ae6 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md -2026-08-09-committed-artifact-citations.md: 181378aab12a4423d9ac5216bac5304f3cfca11c -2026-08-09-committed-artifact-citations.zh.md: 36f32bb1fd0d6991919959e1ebe6482bf7b19b32 +2026-08-09-committed-artifact-citations.md: a578b1e32268af2669ee26c59d19c738bc9f707b +2026-08-09-committed-artifact-citations.zh.md: d525dc0f58884bdf808b597213a7e23c1d6a0301 diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md index 181378aab1..a578b1e322 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md @@ -28,7 +28,7 @@ One repo-wide purge applied these rules across the prose surfaces, including the ## Verification -The audit's grep batteries (English and Chinese, comments and prose, `--hidden` for `.agents/`) return no design-ordinal citations outside recorded fixtures, archived notes, and the trim skill's own calibration examples; `verify-type-equiv`, the `gen-*` freshness checks, and `verify-translation-pairing` pin the regenerated and re-recorded surfaces. Coverage gap: no gate rejects a new ordinal citation — review owns the rule. +The audit's grep batteries (English and Chinese, comments and prose, `--hidden` for `.agents/`) return no design-ordinal citations outside recorded fixtures, archived notes, the trim skill's own files, and this note's quoted evidence; `verify-type-equiv`, the `gen-*` freshness checks, and `verify-translation-pairing` pin the regenerated and re-recorded surfaces. Coverage gap: no gate rejects a new ordinal citation — review owns the rule. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md index 36f32bb1fd..d525dc0f58 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md @@ -28,7 +28,7 @@ Status: implemented ## 验证 -审计的成批 grep 检索(英文与中文、注释与行文、对 `.agents/` 加 `--hidden`)在已录制 fixture、已归档 Agent Note 与该清理技能自带的校准示例之外没有命中任何设计序号引用;`verify-type-equiv`、各 `gen-*` 新鲜度检查与 `verify-translation-pairing` 把重新生成与重新记录的表面固定下来。覆盖缺口:没有门禁会拒绝新的序号引用,这条规则由评审把关。 +审计的成批 grep 检索(英文与中文、注释与行文、对 `.agents/` 加 `--hidden`)在已录制 fixture、已归档 Agent Note、该清理技能自身文件与本笔记引用的证据之外没有命中任何设计序号引用;`verify-type-equiv`、各 `gen-*` 新鲜度检查与 `verify-translation-pairing` 把重新生成与重新记录的表面固定下来。覆盖缺口:没有门禁会拒绝新的序号引用,这条规则由评审把关。 ## 后果 diff --git a/.agents/skills/dsh-trim-cot-leakage/SKILL.md b/.agents/skills/dsh-trim-cot-leakage/SKILL.md index 1ce776c28e..c80dfbd465 100644 --- a/.agents/skills/dsh-trim-cot-leakage/SKILL.md +++ b/.agents/skills/dsh-trim-cot-leakage/SKILL.md @@ -5,11 +5,11 @@ description: Use when auditing or fixing prose that reads like a leaked reasonin # Trimming Chain-of-Thought Leakage -Chain-of-thought leakage is prose whose vantage is the authoring session rather than the repository: it cites artifacts only that session could see, narrates the change instead of the state, or argues with a reviewer who has left. The fix is never deletion alone — restate every factual clause so it stands at HEAD, then delete the transcript around it. **REQUIRED BACKGROUND:** [dsh-prose-standard](../dsh-prose-standard/SKILL.md) owns the complete-proposition rule this skill applies; the [committed-artifact-citations note](../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) owns the citation rule's rationale. It is guidance, not a script. +Chain-of-thought leakage is prose whose vantage is the authoring session rather than the repository: it cites artifacts only that session could see, narrates the change instead of the state, or argues with a reviewer who has left. The fix is never deletion alone when a passage carries factual clauses — restate each so it stands at HEAD, then delete the transcript around it; a passage carrying none (an audit code, control-flow narration) is deleted outright. **REQUIRED BACKGROUND:** [dsh-prose-standard](../dsh-prose-standard/SKILL.md) owns the complete-proposition rule this skill applies; the [committed-artifact-citations note](../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) owns the citation rule's rationale. It is guidance, not a script. ## The one test -For every suspect passage ask: **could a reader at HEAD, with no access to any session transcript, PR thread, or uncommitted draft, resolve every reference and verify every claim?** If yes, it is not leakage, however historical it sounds. If no, restate the surviving facts from the repository's vantage and delete the rest. +For every suspect passage ask: **could a reader at HEAD, with no access to any session transcript, PR thread, or uncommitted draft, resolve every reference and verify every claim?** If no, restate the surviving facts from the repository's vantage and delete the rest. If yes, it is not leakage, however historical it sounds — but resolvability only clears this skill's bar: on current-state surfaces (READMEs, docs, JSDoc) a resolvable change story is still change narration, and class 3 routes it to its sanctioned home. ## Taxonomy @@ -42,4 +42,4 @@ The citation boundary is where unaided passes fail in both directions — deleti 2. Audit read-only first: run the [recall batteries](references/recall-batteries.md) (with `--hidden` so `.agents/` is searched), then judge every hit semantically. The batteries are probes, not the definition — each review round of the original purge surfaced shapes the batteries missed, so also read the densest prose in scope (module JSDoc, READMEs, Agent Notes) without a pattern in hand. 3. Fix owner-first per surface: generated catalogs → fix the source JSDoc or generator template, then regenerate; type-equivalence fences → fix the source JSDoc, then re-paste both bilingual pages (`verify-type-equiv` pins them); bilingual pairs → update the counterpart and re-record per [dsh-translate-docs](../dsh-translate-docs/SKILL.md); model-visible strings → wording is behavior, so flag for a snapshot-backed change instead of silently rewording. 4. Before deleting anything, enumerate the passage's propositions (prose-standard) and check the [overcorrection traps](references/examples.md#overcorrection-traps): trims that flip an obligation into an endorsement, promote a hypothetical to a shipped feature, delete a true fact, or drop provenance. -5. Verify: re-run the batteries expecting only sanctioned keeps and this skill's own references; confirm every remaining citation resolves at HEAD; run the gates for touched surfaces (`doc-sync` for docs, `verify-type-equiv`, `verify-translation-pairing`). +5. Verify: re-run the batteries expecting only sanctioned keeps, this skill's own directory, and the owning note's quoted evidence; confirm every remaining citation resolves at HEAD; run the gates for touched surfaces (`doc-sync` for docs, `verify-type-equiv`, `verify-translation-pairing`). diff --git a/.agents/skills/dsh-trim-cot-leakage/references/examples.md b/.agents/skills/dsh-trim-cot-leakage/references/examples.md index f478d90b13..22e73e10f4 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/examples.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/examples.md @@ -1,6 +1,6 @@ # Few-shot leakage examples -Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to identify the governing principle, not as text templates. This file deliberately quotes leaked shapes as calibration material — the [recall batteries](recall-batteries.md) exclude this directory, and its wording is not a license elsewhere. +Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to identify the governing principle, not as text templates. This file deliberately quotes leaked shapes as calibration material — the [recall batteries](recall-batteries.md) exclude the skill's directory, and its wording is not a license elsewhere. ## Dead citations @@ -8,9 +8,9 @@ Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to id **Leaked:** "Slash input resolves against the visible catalog (decision 21)." -**Fixed:** "Slash input resolves against the visible catalog — the plain-text-reference decision, owned by the web input-machine note." +**Fixed:** "Slash input resolves against the visible catalog — the plain-text-reference decision, owned by [the web input-machine note](../../../notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)." -The ordinal resolves nowhere at HEAD; the decision's name and owning note path do. Name the owning note's path at least once per file; later mentions may use the searchable name alone. +The ordinal resolves nowhere at HEAD; the decision's name and owning note path do. Name the owning note's path at least once per file — as a link where the surface supports one — and later mentions may use the searchable name alone. ### Decision ordinal without an owner diff --git a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md index bacb434d16..8f9cce4aad 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md @@ -5,18 +5,19 @@ Probes for [the taxonomy](../SKILL.md#taxonomy), tuned during the 2026-08 purge. ## Invocation rules - Add `--hidden --glob '!.git/**'` so `.agents/` is searched; ripgrep skips dot-directories by default and the purge's biggest miss risk was Agent Notes. -- Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/references/**'` (this directory quotes leaked shapes as calibration), plus recorded fixture and snapshot directories in scope. +- Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/**'` (the skill's own files quote leaked shapes as calibration), plus recorded fixture and snapshot directories in scope. The [owning note](../../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) also self-hits through its quoted evidence; judge it as evidence, not usage. +- Natural-language lines carry `-i` so sentence-initial capitals hit ("This PR adds…", "Probably fine…"); the code-shaped first line stays case-sensitive — `-i` would turn `\bT\d\b` and `\bP-I\b` into noise. - A zero-hit pattern proves nothing until you have seen it match: test it against a known-positive string before trusting the negative. ## English battery ```sh rg -n --hidden '\(decision \d|\(audit [A-Z]\d|design §|plan §|design ledger|\(B ruling|\bP-I\b|\bW\d\b|\bT\d\b' ... -rg -n --hidden 'this PR|this branch|this stack|later PR|previous commit|this commit' ... -rg -n --hidden 'used to |no longer|previously|the old |was renamed|was moved' ... -rg -n --hidden '\bv1\b|this cut|\bcut \d|\btoday\b|\bfor now\b|roadmap' ... +rg -n --hidden -i 'this PR|this branch|this stack|later PR|previous commit|this commit' ... +rg -n --hidden -i 'used to |no longer|previously|the old |was renamed|was moved' ... +rg -n --hidden -i '\bv1\b|this cut|\bcut \d|\btoday\b|\bfor now\b|roadmap' ... rg -n --hidden -i 'rejected in review|review round|reviewer|as of v\d' ... -rg -n --hidden 'probably |should be enough|should suffice|it simply|is safe —|is safe --' ... +rg -n --hidden -i 'probably |should be enough|should suffice|it simply|is safe —|is safe --' ... rg -n --hidden '§\d' ... ``` @@ -36,7 +37,7 @@ Judged and kept during the purge; expect them again: - **"This PR" in process docs** — documentation *about* PR workflow ("the PR body should…", templates, this repo's process notes) legitimately says "PR"; the ban is on a doc adopting one PR's vantage about the code. - **`v1` as protocol or path segment** — `/v1/chat` endpoints and wire-format names are identifiers, not version stamps. - **`§N` with a committed owner** — external standards (RFC 9110 §10.1.5) and committed docs that own their §-numbering stay citable by section. -- **Contrastive "actually" and noun "wait"** — ordinary English, not hedging. +- **Contrastive "actually" and noun "wait"** — ordinary English, not hedging; no committed line probes them, so they surface only when you extend the battery with broader hedging patterns. - **"Today" in generated timestamps and CLI output samples** — recorded output keeps its voice. - **本版本 in zh prose** — a legitimate rendering of "this release" in versioned-artifact contexts; the banned indexical is 本版 as a bare stamp mirroring "this cut". - **Alternatives-considered sections** — "rejected" inside an Agent Note's genre slot is the sanctioned home, not review choreography. From 5ba7455091ba046c9b89cf550e0ea4fb63e4e227 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:25:38 +0800 Subject: [PATCH 52/57] test(settings): make invalid watcher edit atomic --- packages/settings/settings-local/tests/local.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 967091c89b..8e89cd4643 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -397,7 +397,9 @@ describe('watch', () => { const ctx = await boot({ path, debounceMs: 10 }) const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) - await writeFile(path, 'ui-theme: [unclosed\n') + // Replace the external edit atomically so this case observes one complete + // invalid document instead of a transient empty file during truncation. + await writeFileAtomic(path, 'ui-theme: [unclosed\n', { mode: 0o600 }) // The bad edit must never take the live tree down or reset the value. await new Promise(resolve => setTimeout(resolve, 300)) expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) From 723e428c3bbc545d01bb0b8407cc775758a1cf8d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:50:46 +0800 Subject: [PATCH 53/57] test(web): await fork title projection --- apps/web/tests/message-actions.e2e.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 6c4f450529..7555be866b 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -176,6 +176,12 @@ describe('web e2e: message IconActions and clocks on settled history', () => { () => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }, ).toBe(1) + // The child row is published before its inherited title rename settles; + // wait for that second RPC projection before freezing the ARIA tree. + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').textContent(), + { timeout: 10_000 }, + ).toContain('Use the read tool twice (2)') const tree = await captureStableAria( page, '[role="tree"][aria-label="Sessions"]', From 1eb9acaba9350577a8ffd6584aa2ad753bdec39e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 11:17:04 +0800 Subject: [PATCH 54/57] refactor(scope): bind the parent link once and gate re-linking behind the binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setScopeParent could re-link any key from anywhere, leaving the blank-session-only recompose rule entirely to caller discipline. The relation now binds once — a second bind throws — and re-linking exists only on the ScopeParentBinding returned to the original binder, the private-capability shape the package conventions prescribe for a single-caller operation. The preset roster keeps each composed agent's binding in a WeakMap keyed by the agent, making it the sole authority that can move an agent between standing compositions; the blank-session contract itself stays with the gateway, which alone can see what a session logged. --- ...08-08-per-preset-standing-mounts.i18n.yaml | 4 +- .../2026-08-08-per-preset-standing-mounts.md | 4 +- ...026-08-08-per-preset-standing-mounts.zh.md | 4 +- packages/core/scope/README.i18n.yaml | 4 +- packages/core/scope/README.md | 6 +- packages/core/scope/README.zh.md | 6 +- packages/core/scope/src/index.ts | 56 +++++++++++++------ packages/core/scope/tests/scope.spec.ts | 20 +++++-- packages/preset/agent-presets/src/index.ts | 18 +++++- 9 files changed, 83 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml index 6ec968a410..3ba2ead6da 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 19a53926e9f07b01115cfbb4ddc89eb7ee0c59a0 -2026-08-08-per-preset-standing-mounts.zh.md: 856fbb742a843935902f9aa7578ba6821a9a13c3 +2026-08-08-per-preset-standing-mounts.md: 3889b57ca133ac33877bb1be9e1663512f8edbbe +2026-08-08-per-preset-standing-mounts.zh.md: b9b6b4c079ea4dd9d266cceb6c7304d8a3402bab diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md index 19a53926e9..3889b57ca1 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -10,7 +10,7 @@ Per-session preset mounts made the model-facing registry surface per-agent while ## Decision -A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by `setScopeParent(agentKey, standingKey)`. Two `dsh-scope` mechanisms carry everything: registration views walk the parent chain (`agent → preset → global`, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf. +A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by binding its scope key to the mount's (`bindScopeParent(agentKey, standingKey)`). Two `dsh-scope` mechanisms carry everything: registration views walk the parent chain (`agent → preset → global`, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf. ## Consequences @@ -25,7 +25,7 @@ Standing mounts fix the class, not the instances: the registrations a reader nee - **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer. - **A settled mount is permanent for the process.** The composition a running session joined must survive its file changing or disappearing; deletion and edits reach only future generations (the authoring layer swaps the map pointer, never disposes a joined generation), and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations. - **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it). -- **Re-linking a key (`setScopeParent` on a live agent) is the blank-session recompose path** — valid only while nothing produced under the old parent is retained, which the caller must uphold; the relation cannot see session logs. +- **Re-linking runs only through the `ScopeParentBinding` the mount's one bind returned** — the roster holds it privately, so the blank-session recompose path is the sole re-link and no other caller can move a composed agent; it stays valid only while nothing produced under the old parent is retained, which the holder must uphold because the relation cannot see session logs. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md index 856fbb742a..b9b6b4c079 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -一个 preset 是**每进程**一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过 `setScopeParent(agentKey, standingKey)` 加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。 +一个 preset 是**每进程**一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过把自己的 scope key 绑定到挂载的 key(`bindScopeParent(agentKey, standingKey)`)加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。 ## Consequences @@ -25,7 +25,7 @@ Status: implemented - **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。 - **挂载一旦成功即进程级永久。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;删除与编辑只影响未来的代际(创作层替换 map 指针,绝不 dispose 已被加入的代际),被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 - **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。 -- **对活 agent 重新认父(`setScopeParent`)是空白会话 recompose 的路径**——仅当旧父之下的产出一概不被保留时才合法,由调用方保证;该关系看不见会话日志。 +- **重新认父只能经由挂载首绑返回的 `ScopeParentBinding`**——roster 私藏该句柄,空白会话 recompose 因此是唯一的重链路径,其他调用方无法挪动已组合的 agent;其合法性仍以旧父之下产出一概不被保留为前提,由持有方保证,因为该关系看不见会话日志。 ## Alternatives considered diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml index 3ceb7931b6..7df5df403c 100644 --- a/packages/core/scope/README.i18n.yaml +++ b/packages/core/scope/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/core/scope/README.md -README.md: b73f99fdffb7e3dba5e4eb31b35ff623e8f3d57c -README.zh.md: dd54ef053a8b5d8507c5049dd924b3fbe85bf5ee +README.md: a8fbe97ae3b59f223bb52e44860439803fda420c +README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8 diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index b73f99fdff..a8fbe97ae3 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -2,12 +2,12 @@ English | [中文](README.zh.md) -Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`setScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either. +Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`bindScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either. ## Public API -- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` records the enclosing scope via `setScopeParent` before the scope is usable. -- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Ordinarily written once at mint; re-linking an existing key is the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the caller's contract — this relation cannot see what a session logged). A link closing a cycle throws. `scopeChainOf` returns `[key, parent, …]` nearest-first. +- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` binds the enclosing scope via `bindScopeParent` before the scope is usable; the binding stays internal. +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Binding is once: a key that already has a parent throws, and only the returned binding's `rebind(parent)` may re-link it — the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the holder's contract — this relation cannot see what a session logged). Both the bind and every rebind reject a link closing a cycle. `scopeChainOf` returns `[key, parent, …]` nearest-first. - `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins). - `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). - `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md index dd54ef053a..af23823298 100644 --- a/packages/core/scope/README.zh.md +++ b/packages/core/scope/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`setScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。 +带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`bindScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。 ## 公开 API -- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `setScopeParent` 记录其外围作用域。 -- `setScopeParent(key, parent)` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。通常在创建时写入一次;对已有键重新认父是空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是调用方的约定——该关系看不见会话记录了什么)。会闭环的链接直接抛错。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。 +- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `bindScopeParent` 绑定其外围作用域;绑定句柄不外泄。 +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。绑定仅此一次:已有父级的键直接抛错,只有返回的绑定句柄的 `rebind(parent)` 才能重新认父——即空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是持有方的约定——该关系看不见会话记录了什么)。绑定与每次 rebind 都拒绝会闭环的链接。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。 - `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber)继承标签;嵌套作用域会遮蔽外层标签(最近的标签生效)。 - `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式(generator)effect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置(Cordis 按函数标识去重嵌套 effect;yield 一个包装函数会使作用域 teardown 成为并行的同级操作)。 - `Scope.dispose(): Promise`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。 diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 7a23a97bcd..b5f58dbdf0 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -38,25 +38,49 @@ const carrierKeys = new WeakMap() */ const scopeParents = new WeakMap() -/** - * Record `parent` as `key`'s enclosing scope. - * - * Ordinarily set once when the child scope is minted ({@link createScope}'s - * `parent` option). Re-linking an existing key to a different parent is the - * blank-session recompose operation: valid only while nothing produced under - * the old parent is retained, which is the caller's contract to uphold — this - * relation cannot see what a session logged. A link that would close a cycle - * is rejected, because every chain consumer walks parents to the root. - * @param key - the child scope key. - * @param parent - its enclosing scope key. - */ -export function setScopeParent(key: ScopeKey, parent: ScopeKey): void { +/** The privileged handle to move one scope key's parent link. */ +export interface ScopeParentBinding { + /** + * Re-link the bound key to a different parent, with the same cycle check as + * the bind. Valid only while nothing produced under the old parent is + * retained — the blank-session recompose contract, which the holder upholds + * because this relation cannot see what a session logged. + * @param parent - the new enclosing scope key. + */ + rebind(parent: ScopeKey): void +} + +/** Cycle-checked write shared by the bind and every rebind. */ +function linkScopeParent(key: ScopeKey, parent: ScopeKey): void { for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) { if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle') } scopeParents.set(key, parent) } +/** + * Bind `parent` as `key`'s enclosing scope, once. + * + * A key that already has a parent throws: there is no open re-link path, so a + * scope's ancestry cannot be moved by anyone but the original binder, who + * alone receives the {@link ScopeParentBinding}. A link that would close a + * cycle is rejected, because every chain consumer walks parents to the root. + * @param key - the child scope key. + * @param parent - its enclosing scope key. + * @returns the binding that alone may re-link this key. + */ +export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding { + if (scopeParents.has(key)) { + throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind') + } + linkScopeParent(key, parent) + return { + rebind(next: ScopeKey): void { + linkScopeParent(key, next) + }, + } +} + /** * Read one key's enclosing scope. * @param key - the scope key to inspect. @@ -98,7 +122,7 @@ function scope(): void {} /** Options accepted by {@link createScope}. */ export interface CreateScopeOptions { - /** Enclosing scope recorded via {@link setScopeParent} before the scope is usable. */ + /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */ parent?: ScopeKey } @@ -111,7 +135,7 @@ export interface CreateScopeOptions { * @returns the scoped context and exact/shared disposal boundaries. */ export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope { - if (options?.parent !== undefined) setScopeParent(key, options.parent) + if (options?.parent !== undefined) bindScopeParent(key, options.parent) const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) let disposing: Promise | undefined @@ -134,7 +158,7 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { /** * Build an opaque receiver that preserves the base filter, admits untagged * listeners globally, and admits tagged listeners for a matching key or any - * of its ancestors ({@link setScopeParent}): a listener owned by an enclosing + * of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing * scope receives every descendant scope's events, which is what lets one * standing composition observe each of the agents composed under it. A tag * BELOW the dispatch key stays excluded — events flow up the chain, never diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 8e31fe1522..7007624d53 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget, setScopeParent } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' declare module 'cordis' { @@ -166,22 +166,30 @@ describe('scope parent chain', () => { expect(scopeParentOf(preset)).toBeUndefined() expect(scopeChainOf(agent)).toEqual([agent, preset]) expect(scopeChainOf(undefined)).toEqual([]) - expect(() => { setScopeParent(preset, agent) }).toThrow(/cycle/) - expect(() => { setScopeParent(preset, preset) }).toThrow(/cycle/) + expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/) + expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/) }) - it('re-links to a different parent (the blank-session recompose path)', () => { + it('re-links only through the binding held by the original binder', () => { const ctx = new Context() const presetA = { id: 'a' } const presetB = { id: 'b' } const agent = { id: 'agent' } createScope(ctx, presetA) createScope(ctx, presetB) - createScope(ctx, agent, { parent: presetA }) + const binding = bindScopeParent(agent, presetA) + createScope(ctx, agent) - setScopeParent(agent, presetB) + // A bound key cannot be re-bound from the outside; only the binding moves it. + expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/) + binding.rebind(presetB) expect(scopeChainOf(agent)).toEqual([agent, presetB]) + // The rebind keeps the cycle check: a parent may not adopt its ancestor. + const child = { id: 'child' } + const childBinding = bindScopeParent(child, agent) + void childBinding + expect(() => { binding.rebind(child) }).toThrow(/cycle/) }) it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 3d99e69182..70f0d3775c 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -8,7 +8,7 @@ * projection units exist exactly once, keyed per session inside the plugins * themselves (they predate presets and were written for a shared world). An * agent joins by having its scope key parented to the mount's - * ({@link setScopeParent}), which makes the mount's registrations visible to + * ({@link bindScopeParent}), which makes the mount's registrations visible to * that agent's views and the mount's listeners receive that agent's events — * and a host reader with no agent at all (a cold transcript read) resolves * the same standing registrations by preset id. @@ -23,7 +23,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' -import { createScope, scopeOf, setScopeParent, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' import { discoverPresets } from './discovery.ts' import { mountPreset } from './mount.ts' import type { AgentPreset, Config } from './types.ts' @@ -113,6 +113,14 @@ export class AgentPresets extends Service { */ private readonly standing = new Map>() + /** + * Parent bindings of the agents this roster composed, keyed by the agent's + * scope key. The binding is dsh-scope's only re-link capability; holding it + * here makes this service the sole authority that can move an agent between + * standing compositions. WeakMap: entries die with their agents. + */ + private readonly bindings = new WeakMap() + /** * Compose one agent from a preset: ensure the preset's standing mount, then * parent the agent's scope key to it so the mount's registrations and @@ -133,7 +141,11 @@ export class AgentPresets extends Service { } const preset = await this.resolve(id) const standing = await this.ensureStanding(preset) - setScopeParent(agentKey, standing.key) + // The one bind of this agent's ancestry. The binding is the only re-link + // authority, held privately so nothing outside this roster can move a + // composed agent to another preset; a later recompose layer re-links + // through it under the caller-owned blank-session contract. + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) return preset } From bdcf940eea0f2b7a9c58fac22850b630cf0b5beb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 11:20:55 +0800 Subject: [PATCH 55/57] fix(agent-presets): re-link recompose through the mount-time binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-merge kept recompose on the removed open re-link. It now moves the binding this roster kept from the agent's mount; an agent that never composed one has nothing to re-link, so the switch is its first bind — exactly a mount — and once bound only the kept binding can move it. --- packages/preset/agent-presets/src/index.ts | 12 ++++++++++-- packages/preset/agent-presets/tests/mount.spec.ts | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 176c6129d6..ba00cdf5e0 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -225,7 +225,10 @@ export class AgentPresets extends Service { * and permanent, so the old composition stays for its other agents and the * new one is ensured BEFORE the link moves. An unknown or unusable preset * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. @@ -238,7 +241,12 @@ export class AgentPresets extends Service { } const preset = await this.resolve(id) const standing = await this.ensureStanding(preset) - setScopeParent(agentKey, standing.key) + const binding = this.bindings.get(agentKey) + if (binding === undefined) { + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + } else { + binding.rebind(standing.key) + } return preset } diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 308318bd95..47a9794ef8 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -368,9 +368,19 @@ describe('replacing a composition', () => { expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) }) - it('leaves an agent that never composed one with nothing to restore', async () => { + it('binds an agent that never composed one as its first mount', async () => { + // No binding exists to re-link, so the switch is the agent's first bind + // — and once bound, only this roster's kept binding can move it again. const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') }) + await ctx.agentPresets.recompose(handle.agent.ctx, 'standard') + + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + it('leaves an agent that never composed one with nothing to restore', async () => { + const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare-broken') }) + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) .rejects.toThrow(/failed to mount/) From 75d140265b3b4ce11f043caeaedac09343cae64c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 11:41:07 +0800 Subject: [PATCH 56/57] test(web): address the hermetic skills probe through the layered registry The scaffold's ambient-root isolation proof still resolved a preset-realm service; under the host registry it is a scope read on the composed agent's view, and what it proves is unchanged. --- apps/web/tests/scaffold-hermetic.e2e.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/scaffold-hermetic.e2e.ts b/apps/web/tests/scaffold-hermetic.e2e.ts index 8b7c2ccea3..c504913b9b 100644 --- a/apps/web/tests/scaffold-hermetic.e2e.ts +++ b/apps/web/tests/scaffold-hermetic.e2e.ts @@ -40,19 +40,18 @@ it('isolates replay skill discovery from every ambient host root', async () => { try { scaffold = await launchWebScaffold() const ctx = scaffold.ctx - // The skill registry belongs to one agent's preset, behind an `isolate` - // realm the host cannot resolve by name — so the roots under test are only - // reachable through a composed agent, which is also the only shape that - // ever asks. `serviceFor` is the same addressing the gateway's `skill.list` - // uses for a browser request about a session. + // Local skill discovery belongs to the agent's preset LAYER of the host + // registry, so the roots under test are only reachable through a composed + // agent's view — the same scope the gateway's `skill.list` resolves for a + // browser request about a session. const handle = await ctx.agents.create({ sessionId: SessionId('hermetic-skills'), setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), }) try { - const skills = ctx.agentPresets.serviceFor(handle.agent, 'skills') - if (skills === undefined) throw new Error('composed agent mounts no skill registry') - const names = (await skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name) + const skills = ctx.get('skills') + if (skills === undefined) throw new Error('the composition mounts no skill registry') + const names = (await skills.list({ cwd: scaffold.workspaceCwd, scope: handle.agent })).map(skill => skill.name) expect(names).not.toContain('ambient-dsh') expect(names).not.toContain('ambient-agents') expect(names).not.toContain('ambient-bundled') From 0d9b97bf7b610b85946b539e4d1d0144b2210a18 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 11:42:06 +0800 Subject: [PATCH 57/57] docs(core): regenerate the cordis catalog for the recompose binding contract The re-link now runs through the mount-time binding, and the method's JSDoc says so; the generated core surface and the runtime API catalog follow it. --- docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 5 ++++- docs/subsystems/core.zh.md | 5 ++++- packages/self-modification/tool-cordis/src/api-catalog.ts | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 73053900d7..34556dac27 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/core.md -core.md: 8915fefcc3e9e507f379df3703573fe666e69a17 -core.zh.md: cc31612922a48f42eddaa2b0afa057dfe88fe927 +core.md: ecbc9907167a932a65ad270250840c1dd1357bde +core.zh.md: b46bd1e457e4f6c22faf56065cfd9e43c9d81705 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 8915fefcc3..ecbc990716 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -444,7 +444,10 @@ serviceFor(agent: { ctx: Context }, name: K): * and permanent, so the old composition stays for its other agents and the * new one is ensured BEFORE the link moves. An unknown or unusable preset * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index cc31612922..b46bd1e457 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -452,7 +452,10 @@ serviceFor(agent: { ctx: Context }, name: K): * and permanent, so the old composition stays for its other agents and the * new one is ensured BEFORE the link moves. An unknown or unusable preset * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 8e156f49ce..a524d65cae 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -116,7 +116,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async recompose(agentCtx: Context, id: string): Promise', - jsDoc: '/**\n * Re-link one agent to a different preset\'s standing composition.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot\n * make. The CALLER owns that check — this method does not read session\n * history.\n *\n * The swap is a parent re-link, not an unmount: standing mounts are shared\n * and permanent, so the old composition stays for its other agents and the\n * new one is ensured BEFORE the link moves. An unknown or unusable preset\n * therefore throws with the agent exactly as it was — there is no torn-down\n * state to restore.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable.\n */', + jsDoc: '/**\n * Re-link one agent to a different preset\'s standing composition.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot\n * make. The CALLER owns that check — this method does not read session\n * history.\n *\n * The swap is a parent re-link, not an unmount: standing mounts are shared\n * and permanent, so the old composition stays for its other agents and the\n * new one is ensured BEFORE the link moves. An unknown or unusable preset\n * therefore throws with the agent exactly as it was — there is no torn-down\n * state to restore. The re-link runs through the binding this roster kept\n * from the agent\'s mount — dsh-scope\'s only re-link authority. An agent\n * that never composed one has nothing to re-link: the switch is then the\n * agent\'s first bind, exactly a mount.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, { signature: 'async standingKeyFor(id?: string): Promise',