Merge master at 782f67a into localized Chinese links

# Conflicts:
#	.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
#	.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
This commit is contained in:
pku-xht
2026-08-19 01:55:07 +08:00
48 changed files with 1104 additions and 196 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md
2026-07-30-settings-write-path-integrity.md: c01f04a9b88417115505a8fc9fd3641055e95472
2026-07-30-settings-write-path-integrity.zh.md: 081486895ab57054cc23e1ff2779187f6cc17107
2026-07-30-settings-write-path-integrity.md: 7a2d377586ff2bfa7caeb9d4196ee99f70d3e63f
2026-07-30-settings-write-path-integrity.zh.md: 596f4814674e6fc471959a6c1637dd7a818c50dc
@@ -14,7 +14,7 @@ The provider's write path could destroy state it never observed, and the Service
**One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active.
**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config.
**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. `EEXIST` identifies contention directly; `EPERM` identifies it only when `lstat` confirms that the lock path exists, because Windows may report permission denial for an exclusive create against that existing path. An unrelated permission failure remains loud. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config.
**Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is.
@@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删
**单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其「告警并保留最后可用值」策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。
**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。
**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。`EEXIST` 直接表示竞争;只有 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,因为 Windows 可能把针对该现有路径的独占创建报告为权限拒绝。无关的权限故障仍会响亮失败。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。
**观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件约定现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。
@@ -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-06-parallel-pre-push-gates.md
2026-07-06-parallel-pre-push-gates.md: 538e52c5318fb6d4eab2e8786513a08c1ff0ec55
2026-07-06-parallel-pre-push-gates.zh.md: 853bf28e62eafc4daf455747acff1cb1b21328f1
2026-07-06-parallel-pre-push-gates.md: 2ae08b8c87939085a0f8c7e0cb3ac69fb3ab8e91
2026-07-06-parallel-pre-push-gates.zh.md: 0b59eccf9490992705e9a795c863f6a5f04aefe5
@@ -12,9 +12,11 @@ Aggregate jobs such as documentation synchronization hide long sequential chains
## Decision
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound.
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output by default, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. A `needs` edge requires the predecessor to pass and skips its dependent otherwise; an `after` edge waits for any terminal outcome and then permits the follower to run. A gate marked `allowFailure` still reports its result but does not fail the aggregate.
The Node 24 consumer job is one seven-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count while dependencies control readiness: `publint` precedes built-package invariant validation, and snapshot replay, NodeNext type checks, built-bin smokes, and lint wait for that validation. Lint waits because the invariant verifier temporarily stages package views that the linter must not traverse; source compatibility checks can overlap the validation chain.
Long coordinator gates whose own subprocesses preserve useful attribution may opt into `streamOutput`. Their stdout and stderr reach the parent immediately without being buffered or printed again at completion. Partitioned coverage and parallel Web snapshots use this mode so a mid-run failure is visible without waiting for sibling work.
The Node 24 consumer job is one ten-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count, while pull-request CI caps active gates at eight and dependencies control readiness. Build and source compatibility start immediately; after build, `publint` and built-package invariant validation run in parallel. Lint, both snapshot suites, documentation typechecking, NodeNext type checks, and built-bin smokes wait for the invariant validator to remove its temporary package views.
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages/<group>/<pkg>` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block.
@@ -22,13 +24,14 @@ The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygie
## Verification
[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins the consumer inventory and dependency edges, and exercises signal termination through a real child process. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run.
[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins pass-required and settle-only ordering, pins the consumer and native Windows inventories and their failure semantics, exercises signal termination through a real child process, and proves that streamed output is immediate and unbuffered. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run.
## Alternatives considered
- **Keep aggregate jobs serial** — simpler execution but makes wall clock equal the sum of independent checks and repeats command-wrapper startup.
- **Declare one CI job per leaf gate** — exposes maximum workflow parallelism but repeats checkout, setup, and install overhead and duplicates the scheduler inventory in YAML.
- **Background subcommands inside shell scripts** — parallelizes work but loses per-gate timing, deterministic failure grouping, and straightforward signal handling.
- **Inherit stdio for every gate** — exposes progress immediately but interleaves ordinary independent gates and discards the scheduler's attributable output record. Streaming remains an explicit gate property.
- **Declare one `publint` job per package** — exposes maximum package parallelism but creates a hand-maintained package inventory that drifts when packages change.
- **Run `publint` with unbounded concurrency** — minimizes elapsed time on small repositories only by gambling with process count, memory pressure, package tarball creation, and readable logs.
@@ -36,6 +39,8 @@ The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygie
Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. Invalid graphs fail before partial execution. The cost is a custom scheduler with an explicit mode inventory.
The consumer validation chain delays restored-artifact consumers and lint until the shared artifact view is known-good and transient staging is gone; those downstream gates can still overlap one another.
The consumer validation chain delays validated-artifact consumers and lint until the shared artifact view is known-good and transient staging is gone; those downstream gates can still overlap one another. `publint` needs the build but not the staged validation view, so it overlaps the validator instead of extending that chain.
Most gates retain deterministic output blocks. Selected long coordinators trade cross-gate ordering and buffered logs for immediate diagnostics, while their final status remains available to the aggregate summary.
`publint-all.ts` is asynchronous and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning.
@@ -12,9 +12,11 @@ Status: implemented
## 决策
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和按需启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,缓冲可归因的输出,分别报告进程退出与信号终止结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和按需启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,默认缓冲可归因的输出,分别报告进程退出与信号终止结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY``needs` 边要求前置门禁通过,否则跳过依赖方;`after` 边只等待前置门禁以任意结果结算,随后仍允许后继门禁运行。标记为 `allowFailure` 的门禁仍会报告结果,但不会使聚合流程失败。
Node 24 消费方任务采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 linter 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行
自身子进程能够保留有效归因的长时间协调门禁可以选择 `streamOutput`。其 stdout 与 stderr 会立即到达父进程,不会被缓冲,也不会在结束时重复打印。分区覆盖率与并行 Web 快照使用该模式,使运行中途的失败无需等待兄弟工作结束就能显示
Node 24 消费方任务采用单个包含 10 道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,拉取请求 CI 则把活动门禁限制为 8 道,并由依赖关系控制就绪状态。构建与源码兼容性立即启动;构建完成后,`publint` 与已构建包不变式验证并行运行。lint、两套快照、文档类型检查、NodeNext 类型检查和 built-bin 冒烟测试等待不变式验证器清除临时包视图。
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages/<group>/<pkg>` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint``DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。
@@ -22,13 +24,14 @@ Node 24 消费方任务采用单个包含七道门禁的模式,而非由 shell
## 验证
[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定消费方清单和依赖边,并通过真实子进程验证信号终止。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。
[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定必须通过与只等结算两种顺序,锁定消费方与原生 Windows 清单及其失败语义,通过真实子进程验证信号终止,并证明流式输出会立即显示且不被缓冲。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。
## 曾考虑的替代方案
- **保持聚合 job 串行**:执行更简单,但墙钟时间等于各独立检查之和,并重复启动命令包装器。
- **每个叶子门禁声明一个 CI job**:暴露最大工作流并行度,但会重复 checkout、设置和安装开销,并在 YAML 中复制调度器清单。
- **在 shell 脚本内后台运行子命令**:可以并行处理,但会失去各门禁计时、确定性的失败分组和直接的信号处理。
- **让所有门禁继承 stdio**:可以立即显示进度,但会交错普通独立门禁的输出,并丢失调度器可归因的输出记录。流式输出仍是显式的门禁属性。
- **每个包声明一个 `publint` job**:暴露最大包级并行度,但会创建手工维护的包清单,包发生变化时就会漂移。
- **以无界并发运行 `publint`**:虽能最大限度缩短小型仓库的耗时,却会拿进程数量、内存压力、包 tarball 创建开销和日志可读性冒险。
@@ -36,6 +39,8 @@ Node 24 消费方任务采用单个包含七道门禁的模式,而非由 shell
由调度器支持的命令耗时取决于最慢的依赖链,而非各独立门禁耗时之和,并会报告决定总耗时的门禁。无效图会直接失败,不会先执行其中一部分。代价是维护一个具有显式模式清单的定制调度器。
这条验证链会让使用已恢复产物的下游消费方和 lint 延后启动,直至共享产物视图经确认有效且临时暂存已清除;这些下游门禁仍可彼此重叠运行。
这条验证链会让使用已验证产物的下游消费方和 lint 延后启动,直至共享产物视图经确认有效且临时暂存已清除;这些下游门禁仍可彼此重叠运行。`publint` 需要构建,却不依赖暂存的验证视图,因此它会与验证器重叠,而不会延长这条依赖链。
大多数门禁仍保留确定性的输出块。少数长时间协调器用跨门禁输出顺序和缓冲日志换取即时诊断,而其最终状态仍可供聚合摘要使用。
`publint-all.ts` 采用异步执行并缓冲命令输出,而不是实时继承 stdio。换来的是具有稳定输出顺序的包级并行,以及用于资源调节的单一环境变量。
@@ -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: e0d919851d99eac6539a25c63c9baeb49f76335f
2026-07-22-evidence-based-larger-hosted-runners.zh.md: aecc0aef4897a49ba5b9aae256a40f079d9ba166
2026-07-22-evidence-based-larger-hosted-runners.md: b3310988decb2916ac895aaf154dbc106c51ed48
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 4ad6976dfc2d53f8f048f2a008555cdc3145458c
@@ -12,19 +12,19 @@ Larger runners make it possible to pay setup once and parallelize inside the rep
## Decision
The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit.
The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests run the three primary Linux jobs on the 16-core Ubuntu 24.04 pool and the independent native Windows signal on the 16-core Windows 2025 pool. The required Wine signal remains on standard hosted Linux. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit.
The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work.
The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture.
The former gate-level and coarse primary shard jobs are absent from the workflow. Their workflow-facing static, lint, coverage, snapshot, and scenario selectors are also absent, so an unused diagnostic path cannot preserve a second CI architecture. Instrumented coverage may use [process-local partitions inside its existing job](2026-08-18-in-job-partitioned-coverage.md); that coordinator neither selects workflow jobs nor transfers reports between runners.
Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler owns source and documentation gates that do not consume emitted output. The third job owns the single Linux build, then starts lint, Node 24 runtime compatibility, build-backed snapshots, documentation typechecking, and all artifact consumers against that tree. This [independent consumer build](2026-07-30-independent-ci-consumer-build.md) lets all three jobs request runners immediately without duplicating compilation or transferring a run-scoped artifact. Generated NodeNext consumer directories are excluded from Oxlint discovery because the artifact check removes them while these processes overlap. The pnpm store is restored without putting cache uploads on the pull-request critical path; Oxlint has no repository-managed result cache. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
Linux primary work uses three independent 16-core jobs. Coverage runs alone and partitions its instrumented work inside that job with an explicit process bound; the static scheduler owns source and documentation gates that do not consume emitted output. The third job owns the single Linux build, then starts lint, Node 24 runtime compatibility, build-backed snapshots, documentation typechecking, and all artifact consumers against that tree. This [independent consumer build](2026-07-30-independent-ci-consumer-build.md) lets all three jobs request runners immediately without duplicating compilation or transferring a run-scoped artifact. Generated NodeNext consumer directories are excluded from Oxlint discovery because the artifact check removes them while these processes overlap. The pnpm store is restored without putting cache uploads on the pull-request critical path; Oxlint has no repository-managed result cache. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking consumes the consumer lane's complete project-reference output. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count.
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.
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.
The [dual Windows pull-request topology](2026-08-08-native-windows-pull-request-ci.md) keeps the required build and production-site verdict under Wine on standard hosted Linux. A separate non-blocking 16-core native job shares one Windows setup across workspace build, production-site validation, supported-source coverage, and the complete portability inventory. Linux owns the blocking verdict for duplicate static, documentation, package, built-artifact, lint, and snapshot checks; the native aggregate keeps those checks observational.
An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction:
@@ -40,7 +40,7 @@ The same benchmark measured the required Windows build surfaces across every pro
|---|---:|---:|---:|---:|---:|---:|
| Active time | 152 s | 104 s | 104 s | 92 s | 103 s | 110 s |
Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A retargeted production validation completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated.
Repository work gains little above 16 Windows cores. The native lane keeps blocking build, production-site validation, and coverage together with the observational portability inventory in one 16-core job; a 32-core comparison improved its aggregate gate time by only 1.47 seconds and failed inside Node's CJS lexer. The required Wine job remains separate because it owns critical-path status rather than native-runner scaling.
The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In one exact-head candidate run, Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A cacheless all-size trace completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing.
@@ -72,15 +72,15 @@ An additional serial Linux reference runs on the in-house self-hosted pool (`vm-
**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path.
**Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process.
**Keep blocking and observational native Windows checks in separate jobs.** This would preserve their distinction at the workflow level but pay Windows setup twice. `run-gates` preserves the same blocking versus observational result inside one job.
**Install Bubblewrap through the system package manager.** This uses the host's package database and can dominate the job even when the payload is tiny. Pinned extraction plus a confinement probe preserves the runtime contract without mutating the hosted image.
## Consequences
The required topology pays one setup wave per 32-core lane and retains no shard selectors. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful.
The primary topology pays one setup wave per 16-core lane and retains no workflow-level shard jobs or selectors. Process-local coverage partitions share that one setup and workspace. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful.
GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice, but the consumer lane owns the only built tree and coverage, static gates, and post-build consumers enter runner allocation independently; consolidating Windows avoids repeating its slower setup.
GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice, but the consumer lane owns the only built tree and coverage, static gates, and post-build consumers enter runner allocation independently. Native Windows keeps its blocking and observational inventory in one setup, while Wine remains separate to preserve the required critical path.
Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
@@ -12,19 +12,19 @@ Status: implemented
## 决策
企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 约定。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。
企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求在 16 核 Ubuntu 24.04 池上运行 3 个 Linux 主作业,并在 16 核 Windows 2025 池上运行独立的原生 Windows 信号。必需的 Wine 信号仍位于标准托管 Linux。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。
必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性约定,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.zh.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.zh.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。
原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。
原有的门禁级和粗粒度主流程分片 job 已从工作流中移除。面向工作流的静态、lint、覆盖率、快照和场景选择器也已移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。插桩覆盖率可以在[既有 job 内使用进程本地分区](2026-08-18-in-job-partitioned-coverage.zh.md);该协调器既不选择工作流 job,也不在 runner 之间传输报告。
Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器负责不消费生成输出的源码和文档门禁。第三个作业负责唯一一次 Linux 构建,随后让 lint、Node 24 运行时兼容性、依赖构建产物的快照、文档类型检查和所有产物消费方基于该目录树启动。这种[消费方独立构建](2026-07-30-independent-ci-consumer-build.zh.md)使 3 个作业都能立即请求运行器,而无需重复编译或传输仅供本次运行使用的产物。生成的 NodeNext 消费方目录不会纳入 Oxlint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 会得到恢复,但缓存上传不会进入拉取请求关键路径;Oxlint 没有由仓库管理的结果缓存。性能报告采用每个作业`startedAt``completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
Linux 主流程使用 3 个相互独立的 16 核 job。覆盖率单独运行,并按显式进程上限在该 job 内划分插桩工作;静态调度器负责不消费生成输出的源码和文档门禁。第 3 个 job 负责唯一一次 Linux 构建,随后让 lint、Node 24 运行时兼容性、依赖构建产物的快照、文档类型检查和所有产物消费方基于该目录树启动。这种[消费方独立构建](2026-07-30-independent-ci-consumer-build.zh.md)使 3 个 job 都能立即请求 runner,而无需重复编译或传输仅供本次运行使用的产物。生成的 NodeNext 消费方目录不会纳入 Oxlint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 会得到恢复,但缓存上传不会进入拉取请求关键路径;Oxlint 没有由仓库管理的结果缓存。性能报告采用每个 job `startedAt``completedAt` 的区间;runner 排队延迟是容量证据,而非仓库执行时间。
门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查以消费方通道的完整 project-reference 输出为输入。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。
产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布约定只要遗漏一个运行时分片,检查仍会失败。
在这项企业级必需拓扑中,Windows 通过一次 32 核环境设置同时承载阻塞性构建生产网站与观测性构建产物约定,重复的 lint、覆盖率和快照清单则由 Linux 负责。后续的[拉取请求双 Windows 拓扑](2026-08-08-native-windows-pull-request-ci.zh.md)新增一个独立且不阻断的标准托管原生作业;该作业会独立强制执行受支持源码覆盖率,同时不延长这条付费必需路径
[拉取请求双 Windows 拓扑](2026-08-08-native-windows-pull-request-ci.zh.md)把必需的构建生产网站判定保留在标准托管 Linux 上的 Wine 中。独立且不阻断的 16 核原生作业通过一次 Windows 设置共同执行工作区构建、生产网站验证、受支持源码覆盖率与完整的可移植性清单。重复的静态检查、文档、包、构建产物、lint 与快照检查由 Linux 提供阻断性判定,原生聚合流程则保留这些观测性检查
一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程:
@@ -40,7 +40,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行
|---|---:|---:|---:|---:|---:|---:|
| 活动耗时 | 152 秒 | 104 秒 | 104 秒 | 92 秒 | 103 秒 | 110 秒 |
Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次重新定向的生产验证在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式
Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性的构建、生产网站验证与覆盖率和观测性可移植清单保留在同一个 16 核 job 内;32 核对比仅将其聚合门禁耗时缩短 1.47 秒,且在 Node CJS lexer 内失败。必需的 Wine job 保持独立,因为它负责关键路径状态,而非原生运行器扩缩
客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在一次分支头精确的候选运行中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次无缓存的全规格运行轨迹在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。
@@ -72,15 +72,15 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。
**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别
**把阻断性与观测性原生 Windows 检查在不同 job** 此方案会在工作流层保留二者的区别,却要承担两次 Windows 设置开销。`run-gates` 在一个 job 内保留了相同的阻断与观测结果
**通过系统包管理器安装 Bubblewrap。** 此方案会使用主机的包数据库,即使包内容很小,也可能主导整个作业耗时。固定版本的解压方式配合隔离探针,无需修改托管映像即可保留运行时约定。
## 后果
必需拓扑中的每个 32 核通道只承担 1 轮设置开销,且不保留分片选择器。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows 运行器分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。
拓扑中的每个 16 核通道只承担 1 轮设置开销,且不保留工作流级分片 job 或选择器。进程本地 coverage 分区共享这 1 轮设置与同一个工作区。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows runner 分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。
GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置,但消费方通道拥有唯一一份已构建目录树,且覆盖率、静态门禁与构建后消费方分别进入运行器分配;合并 Windows 则避免重复其耗时更长的设置
GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置,但消费方通道拥有唯一一份已构建目录树,且覆盖率、静态门禁与构建后消费方分别进入运行器分配。原生 Windows 让阻断性与观测性清单共享一次设置,Wine 则保持独立以保留必需关键路径
性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
2026-07-26-ci-failover-runbook.md: b4522e623ffb76f3fd33d242b21c2d1d9ff2eadf
2026-07-26-ci-failover-runbook.zh.md: 58ba7ffee013d38f06afe097362f5c23f04b8121
2026-07-26-ci-failover-runbook.md: e8a1d1dc339cc5d9be3db3be395e2cddad93b6fc
2026-07-26-ci-failover-runbook.zh.md: 8f92b7b60c075f21b6f2c83dc46a6e0e5d8acce2
@@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym
## Decision
Each of the three required Linux worker jobs, the independent native Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through a repository variable, and the switch is split by platform so an outage on one platform does not retarget the other. The three Linux workers and the `all checks passed` verdict (whose `needs` are the required Linux workers and which runs on the `vm-backup` pool) resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows job resolves through `DSH_CI_FAILOVER_WINDOWS`. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, the corresponding jobs retarget onto the in-house self-hosted pool: under `DSH_CI_FAILOVER_LINUX`, the Linux jobs and verdict move onto the `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped; under `DSH_CI_FAILOVER_WINDOWS`, the native Windows job moves onto the `dsh-win-ci` pool. Each switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push.
Each of the three required Linux worker jobs, the independent native Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through a repository variable, and the switch is split by platform so an outage on one platform does not retarget the other. The three Linux workers and the `all checks passed` verdict (whose `needs` are the required Linux workers and which runs on the `vm-backup` pool) resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows job resolves through `DSH_CI_FAILOVER_WINDOWS`. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, the corresponding jobs retarget onto the in-house self-hosted pool: under `DSH_CI_FAILOVER_LINUX`, the Linux jobs and verdict move onto the `vm-backup` pool, snapshot concurrency drops to the shared-VM bound, and the hosted-path pnpm cache restores are skipped; under `DSH_CI_FAILOVER_WINDOWS`, the native Windows job moves onto the `dsh-win-ci` pool. Each switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push.
`ci.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check.
@@ -32,7 +32,7 @@ The two switches are independent: flip only the one whose platform is degraded.
1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`.
2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
3. That is the entire switch. Under Linux failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). The Windows switch has no such concurrency or cache branches; it only retargets the native Windows job's pool.
3. That is the entire switch. Under Linux failover the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows job's pool.
#**Dependabot exception.** Both switches' selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers.
@@ -59,4 +59,4 @@ The variables are writer-manageable repository state; a pull request event itsel
## Consequences
Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: the standby lanes exercise them on every master push so the failover targets never go stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform.
Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: the standby lanes exercise them on every master push so the failover targets never go stale, and the snapshot-concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform.
@@ -10,7 +10,7 @@ Status: implemented
## 决策
三个必需的 Linux 工作作业、独立的原生 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量解析运行器池,且开关按平台拆分,使一个平台的故障不会重定向另一个平台。三个 Linux 工作作业与 `all checks passed` 判定作业(其 `needs` 是必需的 Linux 工作作业,且运行在 `vm-backup` 池上)通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,对应作业切换到公司自有的自托管池:`DSH_CI_FAILOVER_LINUX` 下,Linux 作业与判定作业切到 `vm-backup` 池,覆盖率与快照并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;`DSH_CI_FAILOVER_WINDOWS` 下,原生 Windows 作业切到 `dsh-win-ci` 池。每个开关都是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)``serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。
三个必需的 Linux 工作作业、独立的原生 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量解析运行器池,且开关按平台拆分,使一个平台的故障不会重定向另一个平台。三个 Linux 工作作业与 `all checks passed` 判定作业(其 `needs` 是必需的 Linux 工作作业,且运行在 `vm-backup` 池上)通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,对应作业切换到公司自有的自托管池:`DSH_CI_FAILOVER_LINUX` 下,Linux 作业与判定作业切到 `vm-backup` 池,快照并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;`DSH_CI_FAILOVER_WINDOWS` 下,原生 Windows 作业切到 `dsh-win-ci` 池。每个开关都是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)``serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。
`ci.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。
@@ -32,7 +32,7 @@ Status: implemented
1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`Windows 池故障),值 `selfhosted`
2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
3. 切换到此完成。Linux 故障切换状态下工作流还会自动:`DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏情况下,6 × 8 = 48 个覆盖率工作进程运行在 64 核虚拟机上)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复虚拟机的持久 store 直接提供热安装。Windows 开关没有这类并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
3. 切换到此完成。Linux 故障切换状态下工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
#**Dependabot 例外。**两个开关的选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。
@@ -59,4 +59,4 @@ Status: implemented
## 后果
从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它们,避免故障切换目标变得陈旧;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。
从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它们,避免故障切换目标变得陈旧;而 `ci.yml` 中的快照并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。
@@ -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-31-coverage-exempt-heavy-suites.md
2026-07-31-coverage-exempt-heavy-suites.md: 7235a5193554947ecf71f62d522d09f4e21cb1da
2026-07-31-coverage-exempt-heavy-suites.zh.md: e3d6e335ecb069dadeebb06d760cf70c9b0c1fd4
2026-07-31-coverage-exempt-heavy-suites.md: 1f468a69321b451593a9279cfebc1b457fb08a47
2026-07-31-coverage-exempt-heavy-suites.zh.md: 7e519f44c8321b6b99c04c6af56c4cfa5b641663
@@ -17,6 +17,8 @@ The `ci-coverage` aggregate splits into two parallel gates; every test still run
- **Instrumented gate** (`test:coverage`): sets `DSH_COVERAGE_EXEMPT_HEAVY=1`, which makes `vitest.config.ts` drop the exempt suites from both projects' excludes; every remaining file runs instrumented and carries the entire threshold proof. The variable is injected through the gate's own env (the existing `Gate.env` mechanism), not the workflow-global environment, so the uninstrumented gate beside it and any local `vitest run` never see it and behave unchanged.
- **Uninstrumented gate** (`test:coverage-exempt-heavy`): runs exactly the exempt suites through paired positional filters, keeping the correctness signal whole.
Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged.
`scripts/coverage-exempt.ts` is the single roster point, holding the membership contract and the filter/exclude pairs so the two sides cannot drift.
### The roster, reconciled entry by entry
@@ -27,7 +29,7 @@ A suite contributes to coverage exactly when it executes measured files in-proce
| --- | --- | --- |
| All 6 typert generator specs | The generator's own src | Generator src is threshold-excluded as a package (`vitest.config.ts`) — outside the threshold scope to begin with |
| tools-catalog.spec additionally imports | `typert-registry` and `tool-cordis` src | Each package's own tests cover them fully (verified with focused coverage runs, zero threshold errors) |
| `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry |
| `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts`, `scripts/translation-pairing-merge.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry |
### Membership contract
@@ -46,7 +48,7 @@ Coverage-result invariance therefore does not rest on humans maintaining the ros
- **CLI `--exclude` to drop the exempt suites from the instrumented gate.** Proven ineffective: vitest 4's `cliExclude` does not participate in per-project include resolution, so under a multi-project config the exempt suites stayed selected; the env + config route replaced it.
- **Lowering worker counts or raising gate concurrency.** Measured ineffective during the incident: the lane's wall clock was pinned by the longest tail files (aggregate/wall ≈ 4× effective parallelism), and the concurrency knobs moved nothing in either direction.
- **Cross-runner sharding (`--shard` + blob merge).** Would compress the wall clock further but adds matrix, artifact-pipeline, and merge-job complexity; with the split landed the lane sits near 2 minutes, which does not justify the cost. Revisit if the suite grows substantially.
- **Cross-runner sharding (`--shard` + blob merge).** Rejected because a matrix, artifact pipeline, and merge job would add a second workflow topology. The selected [in-job partitioning](2026-08-18-in-job-partitioned-coverage.md) uses Vitest shards only as local single-worker processes inside the existing job.
- **Deleting or skipping the heavy suites.** Rejected: they are the sole correctness evidence for the typert generator and the scripts tooling; running them uninstrumented in parallel preserves the full signal.
## Verification
@@ -55,7 +57,7 @@ Measured on CI (16-core runner): the gate segment went from 424 seconds to the t
## Consequences
- The coverage lane's gate segment drops from about 7 minutes to about 96 seconds with no change in threshold outcome or executed test set.
- The exempt suites execute without adding instrumentation cost to the thresholded gate; partitioned wall-clock measurements belong to the [in-job partitioning decision](2026-08-18-in-job-partitioned-coverage.md).
- `DSH_GATE_CONCURRENCY` has two schedulable gates in this lane again, so the aggregate scheduler is no longer a pass-through.
- Adding a heavy suite to the roster requires the membership audit above; a wrong entry fails the instrumented gate loudly rather than eroding coverage silently.
- The exempt suites no longer appear in the coverage report's file list of contributors; their correctness signal lives solely in the uninstrumented gate's pass/fail.
@@ -17,6 +17,8 @@ CI 覆盖率 lane`check:ci:coverage`)的墙钟被少数几个重型测试
- **插桩 gate**`test:coverage`):设 `DSH_COVERAGE_EXEMPT_HEAVY=1``vitest.config.ts` 据此从两个 project 的 exclude 中剔除豁免套件,其余全部文件照旧插桩并承担全部阈值证明。经 gate 自带 env 注入(既有 `Gate.env` 机制),不进 workflow 全局环境,因此并排的无插桩 gate 和本地直跑 `vitest run` 都看不到该变量、行为不变。
- **无插桩 gate**`test:coverage-exempt-heavy`):用配对的 positional filter 恰好运行豁免套件,保证正确性信号不缩水。
Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。
`scripts/coverage-exempt.ts` 是唯一名单点,集中持有成员资格约定与 filter/exclude 配对,防止两侧漂移。
### 豁免名单与逐项对账
@@ -27,7 +29,7 @@ CI 覆盖率 lane`check:ci:coverage`)的墙钟被少数几个重型测试
| --- | --- | --- |
| typert generator 全部 6 个 spec | generator 自身 src | generator src 已整包 threshold-excluded`vitest.config.ts`),本不在阈值口径内 |
| 其中 tools-catalog.spec 额外 import | `typert-registry``tool-cordis` 的 src | 两包各自的测试独立满覆盖(focused coverage 实测无阈值错误) |
| `scripts/install-lefthook.spec.ts``scripts/oxlint-contract.spec.ts``scripts/change-scope.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 |
| `scripts/install-lefthook.spec.ts``scripts/oxlint-contract.spec.ts``scripts/change-scope.spec.ts``scripts/translation-pairing-merge.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 |
### 成员资格约定
@@ -46,7 +48,7 @@ per-file 100% 阈值本身就是豁免名单的守卫,名单错误无法静默
- **CLI `--exclude` 从插桩 gate 剔除豁免套件。** 实证无效:vitest 4 的 `cliExclude` 不参与 per-project include 解析,多 project 配置下豁免套件仍被选中,故改走 env + config。
- **降低 worker 数或提高 gate 并发。** 事故期间实测无效:lane 墙钟被尾部最长文件钉死(聚合/墙钟 ≈ 4× 有效并行),并发旋钮两个方向都动不了尾巴。
- **跨 runner 分片(`--shard` + blob 合并)。** 能进一步压墙钟但引入 matrix、artifact 管道与合并 job 的复杂度;拆分落地后 lane 已到约 2 分钟,不值得付。若未来套件规模再涨可重新评估
- **跨 runner 分片(`--shard` + blob 合并)。** 不予采用,因为 matrix、产物流水线和合并 job 会引入第二套工作流拓扑。所选的 [job 内分区](2026-08-18-in-job-partitioned-coverage.zh.md)只把 Vitest shard 用作既有 job 内的本地单 worker 进程
- **直接删除或跳过重型套件。** 拒绝:它们是 typert generator 与 scripts 工具的唯一正确性证据,无插桩并排执行保住全部信号。
## Verification
@@ -55,7 +57,7 @@ CI 实测(16 核 runner):拆分前 gate 段 424 秒,拆分后两 gate
## Consequences
- 覆盖率 lane 的 gate 段从约 7 分钟降到约 96 秒,阈值结果与执行测试集均无变化
- 豁免套件在执行时不会向阈值门禁叠加插桩开销;分区墙钟数据由 [job 内分区决策](2026-08-18-in-job-partitioned-coverage.zh.md)负责记录
- `DSH_GATE_CONCURRENCY` 在本 lane 重新拥有两个可调度对象,聚合调度器不再是直通。
- 向名单新增重型套件必须完成上述成员资格对账;错误条目会让插桩 gate 大声失败,而不是静默侵蚀覆盖率。
- 豁免套件不再出现在覆盖率报告的贡献文件列表中;其正确性信号完全由无插桩 gate 的红绿承载。
@@ -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: 31a1a1893b0c6248a30ac6e12b409282f608a689
2026-08-08-native-windows-pull-request-ci.zh.md: 81b540fe231a485e25a2930226a1b751604c76cc
2026-08-08-native-windows-pull-request-ci.md: d4883cf1363a33a444f1172829149c0c41f21c10
2026-08-08-native-windows-pull-request-ci.zh.md: 557478888890a33a44984f2dcd1c3c69324e8a5e
@@ -16,11 +16,11 @@ The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) rem
Every pull request also starts an ordinary 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. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
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 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. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict.
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. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 60-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
The 16-core lane admits four concurrent outer gates. Workspace build, production-site validation, and instrumented coverage start immediately. Exempt-heavy coverage waits for the build to pass, so its temporary Oxlint contract probes cannot race source compilation. Every observational gate waits for both coverage gates to settle, regardless of outcome, before entering an available slot; its own `needs` edges still require their predecessors to pass. This also keeps later static gates that create temporary contract files from racing either coverage scan. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`. The initial phase therefore has about ten active execution units; after build, starting exempt-heavy while build leaves keeps the peak near eleven when site and instrumented coverage are still running. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), 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, 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.
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Sixteen-shard samples reduced instrumented coverage to 112.66122.01 seconds, but used the whole host before the exempt, build, and site work was counted; eight shards deliberately trade some latency for headroom. 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 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.
@@ -16,11 +16,11 @@ Status: implemented
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率
原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责
16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture 可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入免覆盖率项较多的门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,60 分钟的作业截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证与插桩覆盖率会立即启动。豁免重型覆盖率等待构建通过后再启动,使其临时 Oxlint 约定探针不会与源码编译竞态。每道观测性门禁只等待两道覆盖率门禁以任意结果结算后再进入可用槽位;各门禁自身的 `needs` 边仍要求前置门禁通过。这也使随后创建临时约定文件的静态门禁不会与任一覆盖率扫描竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker。因此初始阶段约有 10 个活动执行单元;构建结束并启动豁免重型门禁后,如果网站与插桩覆盖率仍在运行,峰值约为 11 个。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒,但还未计入豁免、构建与网站工作就已经占满整台宿主;8 个分片刻意用部分延迟换取余量。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 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 别名。
@@ -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-18-in-job-partitioned-coverage.md
2026-08-18-in-job-partitioned-coverage.md: f86c2dffb6d3d30fdccfa445c57043c5217e439b
2026-08-18-in-job-partitioned-coverage.zh.md: 62bb77511dd2c28b31e470288ff5c906dd346aeb
@@ -0,0 +1,51 @@
# Agent Note: In-job partitioned coverage
Status: implemented
English | [中文](2026-08-18-in-job-partitioned-coverage.zh.md)
## Problem
Native Windows coverage was the longest feedback path in the complete pull-request inventory. Keeping the instrumented suite in one single-worker Vitest process avoided the worker loss and Node 24 CJS lexer failures seen with larger in-process pools, but a failure could take more than fourteen minutes to appear and the gate runner withheld the child output until completion.
The optimization must retain every test and the merged per-file 100% thresholds. It must also stay inside the existing coverage job: splitting one suite across multiple workflow jobs would add checkout, installation, artifact transfer, and a merge job to the required topology.
## Decision
The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 8; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate beside the instrumented work.
When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker and one `--shard=<index>/<count>` option. Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, and writes one blob report per process.
The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory.
`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. Build, production-site validation, and instrumented coverage start immediately; exempt-heavy coverage starts only after build passes, preventing its temporary Oxlint probes from racing source compilation. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker.
## Failure and output semantics
Partition children stream stdout and stderr through the coordinator. The coverage gate opts into `run-gates` streaming, so test progress and failures reach CI logs as they occur without buffering the complete log in the scheduler. The coordinator also retains a bounded 64 KiB combined tail per child; when a child settles unsuccessfully, it prints the spawn error, exit code, or signal and repeats that tail before validating the complete blob set, keeping the specific Vitest failure beside the final partition diagnostic.
A normal failed test still emits a blob through `--coverage.reportOnFailure`, allowing the merge to report the complete coverage state before the coordinator returns failure. Spawn failure, signal termination, non-zero exit, a missing or extra blob, or a failed merge all make the gate fail. The coordinator removes only its owned coverage tree and unlinks a link-shaped path instead of recursively following it.
## Verification
`scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, the complete Windows inventory with its blocking split, and unbuffered streamed output.
Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66122.01 seconds, but the sixteen-way schedule could put more than twenty active execution units beside build and exempt coverage on a 16-core runner. Eight partitions keep separate-process isolation while accepting a longer feedback path for a materially lower peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency.
## Alternatives considered
**Use workflow-level sharding.** Rejected because multiple jobs repeat setup and need artifact upload, download, and a merge dependency. The selected partitioning uses multiple processes inside one job and one workspace.
**Raise the Vitest worker count inside one instrumented process.** Rejected because completed Windows trials at higher fan-out exposed worker exits, fixture instability, and Node 24 CJS lexer failures. Separate single-worker processes preserve isolation while still executing the selected partitions concurrently.
**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's eight-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence.
**Apply thresholds independently in each partition.** Rejected because every partition intentionally sees only part of the suite and would report false uncovered files. Threshold ownership belongs to the merged report.
## Consequences
Coverage pays one Vitest startup/configuration cost per partition and one report-merge cost, but it avoids another workflow topology and keeps one final threshold verdict. Partition output may interleave, while the partition start labels and Vitest file identities retain attribution.
Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one.
Future tuning starts from completed runs at one fixed configuration. Slow progress alone never raises partition count or outer concurrency, because repeated restarts would erase the only evidence needed to choose a stable setting.
@@ -0,0 +1,51 @@
# Agent Note: 单 job 分区覆盖率
Status: implemented
[English](2026-08-18-in-job-partitioned-coverage.md) | 中文
## 问题
原生 Windows 覆盖率是拉取请求完整清单中反馈最慢的路径。把插桩套件保留在单个 Vitest 进程内并只使用 1 个 worker,可以避开较大进程内 worker 池曾出现的 worker 丢失和 Node 24 CJS lexer 故障,但一次失败可能超过 14 分钟才会显现,而且门禁调度器会在子进程结束前扣住输出。
这项优化必须保留全部测试以及合并后的逐文件 100% 阈值,也必须留在既有覆盖率 job 内:若把同一套件拆到多个工作流 job,就会向必需拓扑增加 checkout、安装、产物传输和合并 job。
## 决策
普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 8;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.zh.md)仍作为独立的无插桩门禁与插桩工作并排运行。
启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned``scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker,并各自接收一个 `--shard=<index>/<count>` 选项。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,并让每个进程写出 1 份 blob 报告。
协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。
`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。构建、生产网站验证与插桩覆盖率会立即启动;豁免重型覆盖率只在构建通过后启动,避免其临时 Oxlint 探针与源码编译竞态。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。
## 失败与输出语义
分区子进程通过协调器流式传递 stdout 与 stderr。覆盖率门禁选择 `run-gates` 流式输出,因此测试进度与失败会在发生时进入 CI 日志,调度器不会缓冲完整日志。协调器还会为每个子进程保留一份有界的 64 KiB 混合输出尾部;子进程以失败状态结算时,它会打印 spawn 错误、退出码或信号,并在校验完整 blob 集合前重印这份尾部,使具体 Vitest 失败与最终分区诊断相邻。
普通测试失败仍通过 `--coverage.reportOnFailure` 产出 blob,使合并步骤可以先报告完整覆盖率状态,再由协调器返回失败。spawn 失败、信号终止、非零退出、blob 缺失或多余,以及合并失败都会让门禁失败。协调器只删除自己拥有的覆盖率目录树;若该路径是链接,则只 unlink,不递归跟随。
## 验证
`scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。
已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66122.01 秒,但 16 路调度与构建、豁免覆盖率并行时,会在 16 核运行器上形成超过 20 个活动执行单元。8 个分区继续保留独立进程隔离,同时接受更长的反馈路径,以显著降低峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。
## 曾考虑的替代方案
**使用工作流级分片。** 不予采用,因为多个 job 会重复设置工作,并需要上传、下载产物以及合并依赖。所选分区方案只在同一个 job 和工作区内使用多个进程。
**提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。
**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。
**在每个分区内独立应用阈值。** 不予采用,因为每个分区有意只看到套件的一部分,会误报未覆盖文件。阈值归合并报告所有。
## 后果
每个分区都要支付 1 次 Vitest 启动与配置开销,最后还要执行 1 次报告合并,但它不引入另一套工作流拓扑,并保留唯一的最终阈值判定。分区输出可能交错,但分区启动标签和 Vitest 文件标识仍可用于归因。
Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。
未来调优从一个固定配置的完整运行开始。进度缓慢本身绝不会提高分区数量或外层并发,因为反复重启会抹掉选择稳定设置所需的唯一证据。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md
2026-07-30-web-browser-snapshot-ci-gate.md: 14402485034cd85ec5781477ce67481165d47e62
2026-07-30-web-browser-snapshot-ci-gate.zh.md: fc66e539265d7c8636128e2144e33b1f10609fb4
2026-07-30-web-browser-snapshot-ci-gate.md: 72a7e33d0e84105f7680429443df41661ced288a
2026-07-30-web-browser-snapshot-ci-gate.zh.md: d78d582af000cffca8b7ba3d22f3e2cedc0ba5b2
@@ -10,15 +10,17 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs
## Decision
For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing.
For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. When `DSH_WEB_SNAPSHOT_WORKERS` is configured, `scripts/run-gates.ts` registers `test:web:ci` as the `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing.
The consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions.
Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written.
Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written.
CI's `scripts/run-web-snapshots.ts` first runs `hmr-live.e2e.ts` and `cordis-tool-round.e2e.ts` as separate serial Vitest invocations. The HMR scenario mutates built workspace state, while the Cordis scenario owns a lifecycle-sensitive approval and steering sequence whose turn grouping is made deterministic by waiting for the initial turn to settle before approval. After both pass, one six-worker Vitest pool runs every remaining file. Every child inherits stdio, and the enclosing gate streams that output through `run-gates`.
For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
An observed self-hosted consumer run measured `web-snapshot` at 112.15 seconds and the full consumer aggregate at 114.97 seconds. The gate scheduler starts it as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule.
Completed local replays measured the six-worker browser command at about 6571 seconds. A twelve-worker comparison completed in about 50 seconds, so halving the browser worker budget adds about 1520 seconds rather than doubling wall time. The gate scheduler starts browser snapshots as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule.
## Alternatives considered
@@ -28,8 +30,10 @@ An observed self-hosted consumer run measured `web-snapshot` at 112.15 seconds a
**Create a standalone browser job and rebuild the entire repository.** Rejected: it would duplicate dependency installation and the publishable build. The existing Linux consumer job already owns that build and is part of the unified required verdict.
**Run HMR and Cordis inside the parallel pool.** Rejected because HMR mutates shared built state and the Cordis approval continuation requires a serial preflight. Every other file shares one bounded pool; dedicated long-file processes add scheduling code and leave part of a reduced worker budget idle after those files complete.
**Replace real Chromium with jsdom snapshots.** Rejected: jsdom does not cover the browser, HTTP/SSE carriage, or the composition of real client plugin bundles. It remains useful for fast lower-layer feedback, but cannot replace the assembled browser chain.
## Consequences
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium provisioning, two serial scenarios, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
@@ -10,15 +10,17 @@ Status: implemented
## 决策
Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts``test:web:built` `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`CI 永不以 `record``refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。
Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。配置 `DSH_WEB_SNAPSHOT_WORKERS` 后,`scripts/run-gates.ts``test:web:ci` 登记`ci-consumers` 门禁,并显式注入 `DSH_SNAPSHOT=replay`CI 永不以 `record``refresh` 模式运行,因此提交的预期输出与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。
消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.zh.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。
本地 `pnpm run test:web` 仍先构建运行完整浏览器套件;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。
本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。
CI 的 `scripts/run-web-snapshots.ts` 先用相互独立的 Vitest 调用串行运行 `hmr-live.e2e.ts``cordis-tool-round.e2e.ts`。HMR 场景会修改已构建工作区状态;Cordis 场景则拥有一条对生命周期时序敏感的批准与 steering(中途引导)序列,它通过在批准前等待初始轮次结束来确定轮次分组。两者通过后,其余全部文件进入同一个 6-worker Vitest 池。所有子进程都继承 stdio,外围门禁再通过 `run-gates` 流式传递输出。
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
一次自托管消费方运行中,`web-snapshot` 实测耗时 112.15 秒,完整消费方聚合实测耗时 114.97 秒。gate 调度器会在 `built-package-invariants` 成功后立即启动,并发运行彼此独立的 gate,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。
完整本地 replay 中,6-worker 浏览器命令耗时约 6571 秒。12-worker 对比约为 50 秒,因此把浏览器 worker 预算减半只增加约 15–20 秒,而不是让墙钟时间翻倍。门禁调度器会在 `built-package-invariants` 成功后立即启动浏览器快照,并发运行彼此独立的门禁,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。
## 曾考虑的替代方案
@@ -28,8 +30,10 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览
**新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux 消费方 job 已负责该构建,并已被统一的 required verdict 聚合。
**把 HMR 与 Cordis 也放进并行池。** 不予采用,因为 HMR 会修改共享的已构建状态,Cordis 批准 continuation 则需要串行预检。其余全部文件共用一个有界池;专用长文件进程会增加调度代码,并在这些文件结束后让缩减后的部分 worker 预算闲置。
**用 jsdom 快照代替真实 Chromium。** 已否决:jsdom 不覆盖浏览器、HTTP/SSE 承载及真实客户端插件包的组合;它仍可用于快速的下层反馈,但不能替代组装后的浏览器链路。
## 后果
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是消费方 job 需要安装 Chromium,并串行运行一轮浏览器场景;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。门禁不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致漏刷会在改变该组装的同一个 PR 失败。成本是消费方 job 需要安装 Chromium串行运行 2 个场景并执行 1 个有界 6-worker 池;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。并行文件的失败会立即流式显示,但 worker 预算的任何变化仍需要完整端到端测量,而不能依据运行中耗时猜测。门禁不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。
+8 -7
View File
@@ -121,11 +121,10 @@ jobs:
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / coverage
env:
# The hosted 16-core runner uses six coverage workers. The failover pool
# shares one 64-core VM across six always-on runner instances, so each
# instance may use eight while keeping the worst case at 8 × 6 = 48
# workers; process-bound suites remain isolated in forks.
DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '6' }}
# Partitioning replaces the instrumented share; this budget gives the
# exempt-heavy gate two workers on both hosted and failover runners.
DSH_COVERAGE_MAX_WORKERS: '6'
DSH_COVERAGE_PARTITIONS: '4'
DSH_GATE_CONCURRENCY: '3'
steps:
- uses: actions/checkout@v6
@@ -188,6 +187,7 @@ jobs:
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
DSH_OXLINT_THREADS: '8'
DSH_PUBLINT_CONCURRENCY: '8'
DSH_WEB_SNAPSHOT_WORKERS: '6'
# Failover halves snapshot concurrency for the shared 64-core VM.
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
steps:
@@ -454,11 +454,12 @@ jobs:
name: windows node 24 / native complete
timeout-minutes: 120
env:
DSH_COVERAGE_MAX_WORKERS: '2'
DSH_COVERAGE_MAX_WORKERS: '6'
DSH_COVERAGE_PARTITIONS: '8'
# Instrumented process and polling fixtures can exceed Vitest's defaults
# under the complete lane's concurrent gate load.
DSH_COVERAGE_TEST_TIMEOUT_MS: '30000'
DSH_GATE_CONCURRENCY: '2'
DSH_GATE_CONCURRENCY: '4'
DSH_PUBLINT_CONCURRENCY: '8'
steps:
- uses: actions/checkout@v6
+2 -2
View File
@@ -27,9 +27,9 @@ const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
const MODE = webSnapshotMode()
// The question composer replaces the textarea, so fill → Queue row → Steer
// must finish inside the first replay chunk window. At 15 ms that window is
// shorter than Playwright's round trips; 100 ms supplies test-only headroom,
// shorter than Playwright's round trips; 50 ms supplies test-only headroom,
// while larger values lengthen all three replay scenarios linearly.
const REPLAY_PACE_MS = 100
const REPLAY_PACE_MS = 50
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.'
const STEER = 'Interjection: include the word BANANA in your final reply.'
+3 -2
View File
@@ -52,8 +52,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Edit path' }).click()
await dialog.getByLabel('Edit path').fill(path)
await dialog.getByLabel('Edit path').press('Enter')
const pathInput = dialog.locator('input[aria-label="Edit path"]')
await pathInput.fill(path)
await pathInput.press('Enter')
return dialog
}
+2
View File
@@ -33,6 +33,7 @@
"duplication": "jscpd --config .jscpd.json packages scripts",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:coverage:partitioned": "tsx scripts/run-coverage-partitions.ts",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:issue-management": "node .github/issue-management/policy.test.mjs",
"test:snapshot": "vitest run --config vitest.snapshot.config.ts",
@@ -42,6 +43,7 @@
"test:web": "npm run build && npm run test:web:built",
"test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts",
"test:web:built": "vitest run --config vitest.web.config.ts",
"test:web:ci": "tsx scripts/run-web-snapshots.ts",
"test:web:perf": "npm run build && npm run test:web:perf:built",
"test:web:perf:built": "DSH_SNAPSHOT=replay vitest run --config vitest.web.perf.config.ts",
"test:web:stress": "npm run build && vitest run --config vitest.web-stress.config.ts",
@@ -3268,7 +3268,7 @@ describe('dynamic nested workspace context injection', () => {
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'canonical nested rule')
await write(join(root, 'pkg/CLAUDE.md'), 'divergent nested rule')
await write(join(root, 'pkg/CLAUDE.md'), 'initial divergent nested rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
@@ -3280,7 +3280,7 @@ describe('dynamic nested workspace context injection', () => {
})
const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
expect(firstText).toContain('canonical nested rule')
expect(firstText).toContain('divergent nested rule')
expect(firstText).toContain('initial divergent nested rule')
await appendAdditionalContexts(ctx, agent)
await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule')
await ctx.tools.execute({
@@ -188,7 +188,10 @@ describe('real Loader composition', () => {
// behavior, not the chooser's); await that debounced write so it cannot
// race the temp-dir removal, and pin that the persisted row is the
// chooser itself — the resolved backend still never reaches the file.
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
await expect.poll(
async () => await readFile(configPath, 'utf8'),
{ timeout: 15_000 },
).toContain('disabled: true')
expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
})
@@ -10,7 +10,7 @@ import {
import { rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { delimiter, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { Context } from '@deepseek-ai/cordis'
@@ -65,17 +65,19 @@ interface RealInstanceFixture {
readonly workspace: string
}
type ResponsesScript = readonly ResponsesBehavior[] | ((workspace: string) => readonly ResponsesBehavior[])
async function realInstanceFixture(
script: readonly ResponsesBehavior[],
script: ResponsesScript,
): Promise<RealInstanceFixture> {
const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-'))
roots.push(root)
const workspace = join(root, 'workspace')
const codexHome = join(root, 'codex-home')
const fixture = await startResponsesFixture(script)
fixtures.push(fixture)
mkdirSync(workspace)
mkdirSync(codexHome)
const fixture = await startResponsesFixture(typeof script === 'function' ? script(workspace) : script)
fixtures.push(fixture)
writeFileSync(join(codexHome, 'config.toml'), [
'model = "fixture-model"',
'model_provider = "fixture"',
@@ -100,7 +102,7 @@ async function realInstanceFixture(
CODEX_HOME: codexHome,
HOME: root,
XDG_CONFIG_HOME: join(root, 'xdg'),
PATH: root,
PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`,
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
@@ -133,7 +135,7 @@ async function realRuntime(): Promise<RealRuntime> {
}
async function realHarness(
script: readonly ResponsesBehavior[],
script: ResponsesScript,
permissionMode?: CodexPermissionMode,
): Promise<{
readonly harness: RealHarness
@@ -385,27 +387,30 @@ describe('real @openai/codex 0.147.0 product', () => {
it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => {
const sideEffect = 'bypass-side-effect'
const command = process.platform === 'win32'
? `cmd /c echo bypass>${sideEffect}`
: `printf bypass > ${sideEffect}`
const commandCalls = [
{
name: 'exec_command',
arguments: {
cmd: command,
const { harness, fixture } = await realHarness((workspace): readonly ResponsesBehavior[] => {
const target = join(workspace, sideEffect)
const command = process.platform === 'win32'
? `powershell.exe -NoLogo -NoProfile -NonInteractive -Command "Set-Content -LiteralPath '${target.replaceAll("'", "''")}' -Value 'bypass' -NoNewline"`
: `printf bypass > ${JSON.stringify(target)}`
const commandCalls = [
{
name: 'exec_command',
arguments: {
cmd: command,
},
},
},
{
name: 'shell_command',
arguments: {
command,
{
name: 'shell_command',
arguments: {
command,
},
},
},
] as const
const { harness } = await realHarness([
{ kind: 'advertisedFunctionCall', choices: commandCalls },
{ kind: 'complete', text: 'bypass complete' },
], 'dangerously-bypass-approvals-and-sandbox')
] as const
return [
{ kind: 'advertisedFunctionCall', choices: commandCalls },
{ kind: 'complete', text: 'bypass complete' },
]
}, 'dangerously-bypass-approvals-and-sandbox')
const target = join(harness.workspace, sideEffect)
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: 'Create the fixture side effect.' }],
@@ -416,6 +421,7 @@ describe('real @openai/codex 0.147.0 product', () => {
output: [{ type: 'text', text: 'bypass complete' }],
stopReason: 'completed',
})
expect(existsSync(target), JSON.stringify(fixture.requests.at(-1)?.body.input)).toBe(true)
expect(readFileSync(target, 'utf8').trim()).toBe('bypass')
await run.dispose()
await expectQuiescent(harness.handles)
@@ -13,7 +13,6 @@ if ((kind !== 'ordinary' && kind !== 'terminal')
}
const treeState = join(root, 'tree.json')
const ready = join(root, 'ready')
const proceed = join(root, 'proceed')
const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url))
@@ -58,7 +57,6 @@ const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unkn
if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) {
throw new Error('managed tree published invalid process ids')
}
await writeFile(ready, 'ready')
await waitForFile(proceed)
if (trigger === 'dispose') {
@@ -106,11 +106,9 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) {
let settled = false
let treeGone = false
try {
// The host validates tree.json before waiting for proceed, so observing it
// is sufficient readiness; a second marker only adds a redundant Windows poll.
state = await readTree(join(root, 'tree.json'))
await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), {
interval: 10,
timeout: scenarioTimeoutMs,
})
if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state)
await writeFile(join(root, 'proceed'), 'proceed')
const outcome = await child
+2 -2
View File
@@ -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/atomic-write/README.md
README.md: a767f24064c368b60d85fed6fa1d88349cab9587
README.zh.md: 6388e264898e0025fb6586acecab450be3eb9e55
README.md: 4d0b55291955c9d37f4788c7d37ad8e6ce728f70
README.zh.md: c2d7f0b49fa123befbb663ac43862a40b4ef19b4
+1 -1
View File
@@ -28,7 +28,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => {
- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic.
- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content.
`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `<filename>.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer.
`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `<filename>.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms that the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer.
## Model Experience
+1 -1
View File
@@ -28,7 +28,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => {
- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。
- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。
`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `<filename>.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。
`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `<filename>.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。
## 模型体验
+21 -9
View File
@@ -11,7 +11,7 @@
*/
import { randomBytes } from 'node:crypto'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
/**
@@ -63,9 +63,18 @@ export async function writeFileAtomic(filename: string, content: string, options
}
}
/** Whether an exclusive create failed because the path already exists. */
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
/** Whether an exclusive create found an existing lock. */
async function isLockContention(error: unknown, lockPath: string): Promise<boolean> {
const code = (error as NodeJS.ErrnoException | null)?.code
if (code === 'EEXIST') return true
if (code !== 'EPERM') return false
try {
await lstat(lockPath)
return true
} catch {
// Keep the original EPERM authoritative when lock existence is unproven.
return false
}
}
/**
@@ -82,10 +91,13 @@ const LOCK_TIMEOUT_MS = 2_000
* Hold the cross-process writer lock for `filename` around one operation. The
* lock is a `wx`-created sibling (`<filename>.lock`); paired with the
* rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
* only writers contend. Contention backs off exponentially and fails with a
* timed-out error after the deadline. The contender never removes an existing
* lock because file age cannot prove that its owner stopped; orphan recovery
* is an operator action. The parent directory must exist.
* only writers contend. `EEXIST` is contention directly; an `EPERM` is
* contention only when a fresh `lstat` confirms the lock path exists, covering
* Windows exclusive-create behavior without hiding an unrelated permission
* failure. Contention backs off exponentially and fails with a timed-out error
* after the deadline. The contender never removes an existing lock because
* file age cannot prove that its owner stopped; orphan recovery is an operator
* action. The parent directory must exist.
* @param filename - the file whose writers this lock serializes.
* @param operation - the read-render-commit cycle to run while holding the lock.
* @returns the operation's result; the lock releases on both outcomes.
@@ -102,7 +114,7 @@ export async function withFileLock<T>(
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
break
} catch (error) {
if (!isEEXIST(error)) throw error
if (!await isLockContention(error, lockPath)) throw error
}
if (Date.now() >= deadline) {
throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`)
@@ -1,9 +1,29 @@
import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises'
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { withFileLock, writeFileAtomic } from '../src/index.ts'
const state = vi.hoisted(() => ({ failLockCreateWithEPERM: false }))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
writeFile: (async (path: unknown, ...rest: never[]) => {
if (state.failLockCreateWithEPERM && String(path).endsWith('.lock')) {
state.failLockCreateWithEPERM = false
throw Object.assign(new Error('EPERM: injected exclusive-create failure'), { code: 'EPERM' })
}
return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
}) as typeof actual.writeFile,
}
})
afterEach(() => {
state.failLockCreateWithEPERM = false
})
async function scratch(): Promise<string> {
return mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
}
@@ -48,6 +68,32 @@ describe('writeFileAtomic', () => {
})
describe('withFileLock', () => {
it('retries EPERM only when the lock path currently exists', async () => {
const dir = await scratch()
const target = join(dir, 'document')
const lockPath = `${target}.lock`
await writeFile(lockPath, 'holder\n')
const release = setTimeout(() => { void rm(lockPath, { force: true }) }, 50)
state.failLockCreateWithEPERM = true
let called = false
try {
await withFileLock(target, async () => { called = true })
} finally {
clearTimeout(release)
}
expect(called).toBe(true)
})
it('preserves EPERM when no lock path exists', async () => {
const dir = await scratch()
const operation = vi.fn(async () => {})
state.failLockCreateWithEPERM = true
await expect(withFileLock(join(dir, 'document'), operation)).rejects.toMatchObject({ code: 'EPERM' })
expect(operation).not.toHaveBeenCalled()
})
it('rejects an invalid parent hierarchy before running the operation', async () => {
const dir = await scratch()
const parent = join(dir, 'not-a-directory')
+232
View File
@@ -0,0 +1,232 @@
import { access, mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
COVERAGE_PARTITION_MODE_ENV,
COVERAGE_PARTITIONS_ENV,
COVERAGE_TEST_TIMEOUT_ENV,
CoveragePartitionCoordinator,
coverageTestTimeoutArgs,
forwardedCoverageArgs,
parseCoveragePartitionCount,
type CoverageCommand,
type CoverageCommandResult,
} from './coverage-partitions.ts'
const passed: CoverageCommandResult = { exitCode: 0, signalCode: null }
afterEach(() => vi.restoreAllMocks())
async function writeBlob(command: CoverageCommand): Promise<void> {
if (command.blobPath === undefined) return
await mkdir(dirname(command.blobPath), { recursive: true })
await writeFile(command.blobPath, '{}')
}
async function temporaryRoot(): Promise<string> {
return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
}
describe('coverage partition count', () => {
it.each([
[undefined, undefined],
['', undefined],
['2', 2],
['3', 3],
])('parses %j as %j', (raw, expected) => {
expect(parseCoveragePartitionCount(raw)).toBe(expected)
})
it.each(['0', '1', '2.5', '02', 'many'])('rejects %j', (raw) => {
expect(() => parseCoveragePartitionCount(raw))
.toThrow(`${COVERAGE_PARTITIONS_ENV} must be an integer greater than 1`)
})
})
describe('coverage partition timeout', () => {
it('applies one configured timeout to tests and polling', () => {
expect(coverageTestTimeoutArgs('30000')).toEqual([
'--testTimeout=30000',
'--expect.poll.timeout=30000',
])
})
it('keeps Vitest defaults when the timeout is absent', () => {
expect(coverageTestTimeoutArgs(undefined)).toEqual([])
})
it('rejects invalid timeout input', () => {
expect(() => coverageTestTimeoutArgs('0'))
.toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`)
})
})
describe('coverage forwarded arguments', () => {
it('removes one package-script separator', () => {
expect(forwardedCoverageArgs(['--', 'scripts/example.spec.ts'])).toEqual(['scripts/example.spec.ts'])
})
it('preserves direct arguments and a subsequent Vitest separator', () => {
expect(forwardedCoverageArgs(['--testNamePattern=example'])).toEqual(['--testNamePattern=example'])
expect(forwardedCoverageArgs(['--', '--', 'example'])).toEqual(['--', 'example'])
})
})
describe('coverage partition coordinator', () => {
it('runs every single-worker partition before one merged threshold check', async () => {
const root = await temporaryRoot()
const commands: CoverageCommand[] = []
const runCommand = vi.fn(async (command: CoverageCommand) => {
commands.push(command)
await writeBlob(command)
return passed
})
const coordinator = new CoveragePartitionCoordinator({
root,
partitions: 3,
pnpmEntrypoint: '/pnpm.cjs',
vitestArgs: ['--testTimeout=30000'],
runCommand,
})
await expect(coordinator.run()).resolves.toBe(0)
expect(commands.map(command => command.label)).toEqual([
'partition 1/3',
'partition 2/3',
'partition 3/3',
'merged coverage report',
])
for (const [index, command] of commands.slice(0, 3).entries()) {
expect(command.args).toEqual(expect.arrayContaining([
'--coverage',
'--coverage.reportOnFailure',
'--maxWorkers=1',
`--shard=${index + 1}/3`,
'--reporter=default',
'--reporter=blob',
'--testTimeout=30000',
]))
expect(command.env).toEqual({
[COVERAGE_PARTITIONS_ENV]: undefined,
[COVERAGE_PARTITION_MODE_ENV]: '1',
})
}
const mergeCommand = commands[3]
if (mergeCommand === undefined) throw new Error('coverage merge command was not observed')
expect(mergeCommand.args).toContain('--coverage')
expect(mergeCommand.args.some(argument => argument.startsWith('--merge-reports='))).toBe(true)
expect(mergeCommand.env).toEqual({
[COVERAGE_PARTITIONS_ENV]: undefined,
[COVERAGE_PARTITION_MODE_ENV]: undefined,
})
})
it('merges normal test failures and returns their failed status', async () => {
const root = await temporaryRoot()
const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const runCommand = vi.fn(async (command: CoverageCommand) => {
await writeBlob(command)
return command.label === 'partition 2/2'
? { exitCode: 1, signalCode: null, outputTail: 'specific Vitest failure' }
: passed
})
const coordinator = new CoveragePartitionCoordinator({
root,
partitions: 2,
pnpmEntrypoint: '/pnpm.cjs',
runCommand,
})
await expect(coordinator.run()).resolves.toBe(1)
expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (exit 1)')
expect(reported).toHaveBeenCalledWith(
'coverage-partitions: output tail for partition 2/2:\nspecific Vitest failure',
)
expect(runCommand).toHaveBeenCalledTimes(3)
})
it('rejects a missing partition blob before merge', async () => {
const root = await temporaryRoot()
const runCommand = vi.fn(async (command: CoverageCommand) => {
if (command.label !== 'partition 2/2') await writeBlob(command)
return passed
})
const coordinator = new CoveragePartitionCoordinator({
root,
partitions: 2,
pnpmEntrypoint: '/pnpm.cjs',
runCommand,
})
await expect(coordinator.run()).rejects.toThrow('coverage partitions produced')
expect(runCommand).toHaveBeenCalledTimes(2)
})
it('reports signal termination before missing-blob validation', async () => {
const root = await temporaryRoot()
const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const runCommand = vi.fn(async (command: CoverageCommand) => {
if (command.label === 'partition 1/2') await writeBlob(command)
return command.label === 'partition 2/2'
? { exitCode: null, signalCode: 'SIGTERM' as const }
: passed
})
const coordinator = new CoveragePartitionCoordinator({
root,
partitions: 2,
pnpmEntrypoint: '/pnpm.cjs',
runCommand,
})
await expect(coordinator.run()).rejects.toThrow('coverage partitions produced')
expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (signal SIGTERM)')
})
it('waits for every partition after one spawn failure', async () => {
const root = await temporaryRoot()
const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
let secondFinished = false
const runCommand = vi.fn(async (command: CoverageCommand) => {
await writeBlob(command)
if (command.label === 'partition 1/2') {
return { exitCode: null, signalCode: null, error: 'spawn unavailable' }
}
if (command.label === 'partition 2/2') secondFinished = true
return passed
})
const coordinator = new CoveragePartitionCoordinator({
root,
partitions: 2,
pnpmEntrypoint: '/pnpm.cjs',
runCommand,
})
await expect(coordinator.run()).resolves.toBe(1)
expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 1/2 (spawn unavailable)')
expect(secondFinished).toBe(true)
expect(runCommand).toHaveBeenCalledTimes(3)
})
it('unlinks a link-shaped coverage path without touching its target', async () => {
const root = await temporaryRoot()
const target = await temporaryRoot()
const marker = join(target, 'marker.txt')
await writeFile(marker, 'owned elsewhere')
await symlink(target, join(root, 'coverage'), process.platform === 'win32' ? 'junction' : 'dir')
const runCommand = vi.fn(async (command: CoverageCommand) => {
await writeBlob(command)
return passed
})
const coordinator = new CoveragePartitionCoordinator({
root,
partitions: 2,
pnpmEntrypoint: '/pnpm.cjs',
runCommand,
})
await expect(coordinator.run()).resolves.toBe(0)
await expect(access(marker)).resolves.toBeUndefined()
})
})
+269
View File
@@ -0,0 +1,269 @@
/** Coordinate single-worker Vitest coverage partitions and one merged report. */
import { spawn } from 'node:child_process'
import { lstat, mkdir, readdir, rm, unlink } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
/** Environment variable selecting the number of instrumented coverage processes. */
export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS'
/** Internal marker that suppresses reports and thresholds inside a partition process. */
export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE'
/** Environment variable overriding instrumented test and polling timeouts. */
export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS'
/** One child command owned by the coverage coordinator. */
export interface CoverageCommand {
/** Diagnostic identity. */
label: string
/** Node arguments; the first argument is pnpm's JavaScript entrypoint. */
args: string[]
/** Environment additions for the child. */
env: Record<string, string | undefined>
/** Working directory for the child. */
cwd: string
/** Blob the partition must produce; absent for the merge command. */
blobPath?: string
}
/** Observable child-process completion. */
export interface CoverageCommandResult {
/** Numeric process status, or `null` when a signal ended the child. */
exitCode: number | null
/** Terminating signal, or `null` after an ordinary exit. */
signalCode: NodeJS.Signals | null
/** Spawn failure recorded independently from process completion. */
error?: string
/** Bounded combined stdout/stderr tail repeated when the command fails. */
outputTail?: string
}
/** Execute one coordinator command with inherited output. */
export type CoverageCommandRunner = (command: CoverageCommand) => Promise<CoverageCommandResult>
/** Construction inputs for {@link CoveragePartitionCoordinator}. */
export interface CoveragePartitionCoordinatorOptions {
/** Repository root that owns coverage output. */
root: string
/** Number of concurrent single-worker Vitest processes. */
partitions: number
/** pnpm JavaScript entrypoint from `npm_execpath`. */
pnpmEntrypoint: string
/** Additional arguments shared by every partition. */
vitestArgs?: string[]
/** Child executor, injectable for scheduler tests. */
runCommand?: CoverageCommandRunner
}
/** Parse an optional coverage partition count. */
export function parseCoveragePartitionCount(raw: string | undefined): number | undefined {
if (raw === undefined || raw === '') return undefined
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 2 || String(parsed) !== raw) {
throw new Error(`${COVERAGE_PARTITIONS_ENV} must be an integer greater than 1, got ${JSON.stringify(raw)}.`)
}
return parsed
}
/** Resolve the paired Vitest timeout arguments used by coverage partitions. */
export function coverageTestTimeoutArgs(raw: string | undefined): string[] {
if (raw === undefined || raw === '') return []
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
throw new Error(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
}
return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`]
}
/** Remove pnpm's package-script separator before forwarding Vitest arguments. */
export function forwardedCoverageArgs(args: readonly string[]): string[] {
return [...args.slice(args[0] === '--' ? 1 : 0)]
}
/** Run instrumented partitions, validate their blobs, and merge once. */
export class CoveragePartitionCoordinator {
private readonly root: string
private readonly partitions: number
private readonly pnpmEntrypoint: string
private readonly vitestArgs: string[]
private readonly runCommand: CoverageCommandRunner
private readonly temporaryRoot: string
private readonly blobsRoot: string
/** Create a coordinator from validated process-independent inputs. */
public constructor(options: CoveragePartitionCoordinatorOptions) {
if (!Number.isSafeInteger(options.partitions) || options.partitions < 2) {
throw new Error(`coverage partitions must be an integer greater than 1, got ${String(options.partitions)}.`)
}
this.root = options.root
this.partitions = options.partitions
this.pnpmEntrypoint = options.pnpmEntrypoint
this.vitestArgs = options.vitestArgs ?? []
this.runCommand = options.runCommand ?? runCoverageCommand
this.temporaryRoot = join(this.root, 'coverage', '.partitioned')
this.blobsRoot = join(this.temporaryRoot, 'blobs')
}
/**
* Run every partition before one merged threshold check.
* @returns zero only when every partition and the merge command succeed.
*/
public async run(): Promise<number> {
await removeOwnedTree(join(this.root, 'coverage'))
await mkdir(this.blobsRoot, { recursive: true })
try {
const commands = Array.from(
{ length: this.partitions },
(_, index) => this.partitionCommand(index + 1),
)
const results = await Promise.all(commands.map(async (command) => {
console.log(`coverage-partitions: start ${command.label}`)
const result = await this.runCommand(command)
if (commandFailed(result)) {
console.error(`coverage-partitions: FAIL ${command.label} (${commandFailureReason(result)})`)
if (result.outputTail !== undefined && result.outputTail !== '') {
console.error(`coverage-partitions: output tail for ${command.label}:\n${result.outputTail}`)
}
}
return result
}))
await this.assertCompleteBlobSet(commands)
const mergeCommand = this.mergeCommand()
console.log(`coverage-partitions: start ${mergeCommand.label}`)
const mergeResult = await this.runCommand(mergeCommand)
return results.some(commandFailed) || commandFailed(mergeResult) ? 1 : 0
} finally {
await removeOwnedTree(this.temporaryRoot)
}
}
private partitionCommand(index: number): CoverageCommand {
const blobPath = join(this.blobsRoot, `partition-${index}.json`)
const reportsDirectory = join(this.temporaryRoot, `coverage-${index}`)
return {
label: `partition ${index}/${this.partitions}`,
args: [
this.pnpmEntrypoint,
'exec',
'vitest',
'run',
'--coverage',
'--coverage.reportOnFailure',
'--maxWorkers=1',
`--shard=${index}/${this.partitions}`,
'--reporter=default',
'--reporter=blob',
`--outputFile.blob=${this.relativePath(blobPath)}`,
`--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`,
...this.vitestArgs,
],
env: {
[COVERAGE_PARTITIONS_ENV]: undefined,
[COVERAGE_PARTITION_MODE_ENV]: '1',
},
cwd: this.root,
blobPath,
}
}
private mergeCommand(): CoverageCommand {
return {
label: 'merged coverage report',
args: [
this.pnpmEntrypoint,
'exec',
'vitest',
`--merge-reports=${this.relativePath(this.blobsRoot)}`,
'--coverage',
],
env: {
[COVERAGE_PARTITIONS_ENV]: undefined,
[COVERAGE_PARTITION_MODE_ENV]: undefined,
},
cwd: this.root,
}
}
private relativePath(path: string): string {
return relative(this.root, path).split(sep).join('/')
}
private async assertCompleteBlobSet(commands: CoverageCommand[]): Promise<void> {
const expected = commands.map((command) => {
if (command.blobPath === undefined) throw new Error(`${command.label} has no blob path.`)
return this.relativePath(command.blobPath)
}).sort()
const actual = (await readdir(this.blobsRoot))
.map(name => this.relativePath(join(this.blobsRoot, name)))
.sort()
if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
throw new Error(`coverage partitions produced ${JSON.stringify(actual)}; expected ${JSON.stringify(expected)}.`)
}
}
}
/** Spawn one pnpm-backed command without a platform shell. */
function runCoverageCommand(command: CoverageCommand): Promise<CoverageCommandResult> {
return new Promise((resolveCommand) => {
let outputTail = ''
const env = { ...process.env }
for (const [name, value] of Object.entries(command.env)) {
if (value === undefined) Reflect.deleteProperty(env, name)
else env[name] = value
}
const child = spawn(process.execPath, command.args, {
cwd: command.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
process.stdout.write(chunk)
outputTail = appendOutputTail(outputTail, chunk)
})
child.stderr.on('data', (chunk: string) => {
process.stderr.write(chunk)
outputTail = appendOutputTail(outputTail, chunk)
})
child.once('error', (error: Error) => {
resolveCommand({ exitCode: null, signalCode: null, error: error.message, outputTail })
})
child.once('close', (exitCode, signalCode) => {
resolveCommand({ exitCode, signalCode, outputTail })
})
})
}
function appendOutputTail(previous: string, chunk: string): string {
const combined = previous + chunk
return combined.length <= 65_536 ? combined : combined.slice(-65_536)
}
function commandFailed(result: CoverageCommandResult): boolean {
return result.exitCode !== 0 || result.signalCode !== null || result.error !== undefined
}
function commandFailureReason(result: CoverageCommandResult): string {
const facts = [
result.error,
result.exitCode === null ? undefined : `exit ${result.exitCode}`,
result.signalCode === null ? undefined : `signal ${result.signalCode}`,
].filter((fact): fact is string => fact !== undefined)
return facts.join(', ') || 'no exit code or signal'
}
async function removeOwnedTree(path: string): Promise<void> {
const metadata = await lstat(path).catch((error: unknown) => {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined
throw error
})
if (metadata === undefined) return
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
await unlink(path)
return
}
await rm(path, { recursive: true, force: true })
}
+6 -1
View File
@@ -529,7 +529,12 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
const lockPath = installLockPath(fixture)
const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running')
const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' })
await waitForPath(runningPath)
try {
await waitForPath(runningPath)
} catch (error) {
await install
throw error
}
const replacementRecord = 'replacement owner\n'
writeFileSync(lockPath, replacementRecord)
+30
View File
@@ -0,0 +1,30 @@
/** CLI entry for partitioned Vitest coverage. */
import { resolve } from 'node:path'
import {
COVERAGE_PARTITIONS_ENV,
COVERAGE_TEST_TIMEOUT_ENV,
CoveragePartitionCoordinator,
coverageTestTimeoutArgs,
forwardedCoverageArgs,
parseCoveragePartitionCount,
} from './coverage-partitions.ts'
const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV])
if (partitions === undefined) {
throw new Error(`${COVERAGE_PARTITIONS_ENV} is required by partitioned coverage.`)
}
const pnpmEntrypoint = process.env.npm_execpath
if (pnpmEntrypoint === undefined || pnpmEntrypoint === '') {
throw new Error('partitioned coverage must be invoked through a pnpm package script.')
}
const coordinator = new CoveragePartitionCoordinator({
root: resolve(import.meta.dirname, '..'),
partitions,
pnpmEntrypoint,
vitestArgs: [
...coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]),
...forwardedCoverageArgs(process.argv.slice(2)),
],
})
process.exitCode = await coordinator.run()
+75 -5
View File
@@ -101,13 +101,25 @@ describe('gate graph validation', () => {
},
)
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]))
it('keeps native Windows coverage blocking while retaining the observational inventory', () => {
const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational'))
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
const byId = new Map(complete.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)
expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build')
expect(observational).not.toHaveLength(0)
for (const gate of observational) {
const completeGate = byId.get(gate.id)
expect(completeGate?.allowFailure).toBe(true)
expect(completeGate?.after).toEqual(expect.arrayContaining([
'coverage',
'coverage-exempt-heavy',
]))
expect(completeGate?.needs).toEqual(gate.needs)
}
})
it('applies one configured test and polling timeout to both coverage gates', () => {
@@ -139,11 +151,30 @@ describe('gate graph validation', () => {
.toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer')
})
it('selects partitioned coverage only when explicitly configured', () => {
const coverage = withEnv('DSH_COVERAGE_PARTITIONS', '3', () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete').find(subject => subject.id === 'coverage')))
expect(coverage).toMatchObject({
displayCommand: 'DSH_COVERAGE_PARTITIONS=3 pnpm run test:coverage:partitioned',
args: ['/private/pnpm.cjs', 'run', 'test:coverage:partitioned'],
streamOutput: true,
})
})
it('rejects an invalid coverage partition count before starting a gate', () => {
expect(() => withEnv('DSH_COVERAGE_PARTITIONS', '1', () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))))
.toThrow('DSH_COVERAGE_PARTITIONS must be an integer greater than 1')
})
it.each([
['empty', [], /gate graph has no gates/],
['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/],
['unknown ordering predecessors', [gate('subject', { after: ['missing'] })], /waits for unknown gate "missing"/],
['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
['mixed cycles', [gate('first', { after: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
] as const)('rejects %s before starting a child', async (_label, invalid, message) => {
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
@@ -169,6 +200,29 @@ describe('gate graph validation', () => {
expect(execute).toHaveBeenCalledWith(root)
expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
})
it('runs an ordered follower after its predecessor fails', async () => {
const follower = gate('follower', { after: ['root'] })
const root = gate('root')
const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed'))
const results = await runGates([follower, root], 2, execute)
expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower'])
expect(results.map(result => result.status)).toEqual(['passed', 'failed'])
})
it('runs an ordered follower after its predecessor is skipped', async () => {
const follower = gate('follower', { after: ['dependent'] })
const dependent = gate('dependent', { needs: ['root'] })
const root = gate('root')
const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed'))
const results = await runGates([follower, dependent, root], 2, execute)
expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower'])
expect(results.map(result => result.status)).toEqual(['passed', 'skipped', 'failed'])
})
})
describe('Oxlint gate', () => {
@@ -290,7 +344,7 @@ describe('Node 24 lane ownership', () => {
'built-bin-smoke',
])
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['build'])
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
for (const id of [
'snapshot',
@@ -332,6 +386,22 @@ describe('Linux primary graph', () => {
})
describe('gate process outcomes', () => {
it('streams selected gate output without retaining it', async () => {
const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
try {
const result = await runGate(gate('streamed', {
args: ['-e', "process.stdout.write('live output')"],
streamOutput: true,
}))
expect(result.status).toBe('passed')
expect(result.output).toEqual([])
expect(write).toHaveBeenCalledWith('live output')
} finally {
write.mockRestore()
}
})
it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => {
const result = await runGate(gate('terminated', {
args: ['-e', "process.kill(process.pid, 'SIGTERM')"],
+96 -52
View File
@@ -10,6 +10,12 @@ import { availableParallelism } from 'node:os'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts'
import {
COVERAGE_PARTITIONS_ENV,
COVERAGE_TEST_TIMEOUT_ENV,
coverageTestTimeoutArgs,
parseCoveragePartitionCount,
} from './coverage-partitions.ts'
/** A named aggregate exposed by the gate runner. */
export type Mode =
@@ -39,8 +45,13 @@ export interface Gate {
command: string
args: string[]
needs?: string[]
/** Gate ids that must settle, regardless of outcome, before this gate starts. */
after?: string[]
env?: Record<string, string | undefined>
/** Keep a failure visible without failing the aggregate. */
allowFailure?: boolean
/** Write child output as it arrives instead of buffering it until completion. */
streamOutput?: boolean
}
/** The observed outcome of one gate process. */
@@ -395,7 +406,7 @@ function ciConsumerGates(): Gate[] {
pnpmScript('build', 'build'),
pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }),
pnpmScript('publint', 'publint', { needs: builtTree }),
builtPackageInvariantsGate(['publint']),
builtPackageInvariantsGate(builtTree),
pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', {
label: 'lint and duplication',
needs: validatedBuild,
@@ -415,6 +426,20 @@ function ciConsumerGates(): Gate[] {
}
function webSnapshotGate(needs: string[]): Gate {
const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS
if (workerRaw !== undefined && workerRaw !== '') {
const workers = Number.parseInt(workerRaw, 10)
if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) {
throw new Error(`run-gates: DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`)
}
return pnpmScript('web-snapshot', 'test:web:ci', {
label: 'web browser snapshot',
displayCommand: `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=${workers} pnpm run test:web:ci`,
env: { DSH_SNAPSHOT: 'replay' },
needs,
streamOutput: true,
})
}
return pnpmScript('web-snapshot', 'test:web:built', {
label: 'web browser snapshot',
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
@@ -431,15 +456,23 @@ function ciWindowsBlockingGates(): Gate[] {
}
function ciWindowsCompleteGates(): Gate[] {
const coverage = coverageGates().map(gate => gate.id === 'coverage-exempt-heavy'
? { ...gate, needs: [...new Set(['build', ...(gate.needs ?? [])])] }
: gate)
const coverageAfter = coverage.map(gate => gate.id)
const observational = ciWindowsObservationalGates()
// The required production site replaces the observational MPA build; both
// VitePress modes write the same output directory and cannot overlap.
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
.map(gate => ({ ...gate, allowFailure: true }))
.map(gate => ({
...gate,
allowFailure: true,
after: [...new Set([...coverageAfter, ...(gate.after ?? [])])],
}))
return [
pnpmScript('build', 'build'),
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
...coverageGates(),
...coverage,
...observational,
]
}
@@ -479,13 +512,14 @@ function lintGate(options: { needs?: string[] } = {}): Gate {
// under v8 instrumentation while contributing nothing the thresholds need
// (membership rules in scripts/coverage-exempt.ts).
//
// DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel
// gates split it instead of each claiming it whole (the failover pool's
// 8 x 6-instance bound assumes one lane never exceeds its value). The exempt
// DSH_COVERAGE_MAX_WORKERS is the ordinary lane's worker budget, so the two
// parallel gates split it instead of each claiming it whole. When
// DSH_COVERAGE_PARTITIONS is set, its single-worker processes replace the
// instrumented share while this budget still sizes the exempt gate. The exempt
// gate's wall clock is dominated by its longest single file, so it takes the
// small share. A budget of 1 gives each gate 1 worker; lanes that need a
// strict total of one (the serial reference jobs) also set
// DSH_GATE_CONCURRENCY=1, which keeps the gates from overlapping at all.
// small share. A budget of 1 gives each gate 1 worker; lanes that need a strict
// total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1,
// which keeps the gates from overlapping at all.
// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test and expect.poll
// defaults together for instrumented lanes whose scheduling overhead exceeds
// those defaults. Explicit fixture timeouts remain authoritative.
@@ -501,18 +535,12 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
}
}
function coverageTimeoutArgs(): string[] {
return [
...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--testTimeout'),
...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--expect.poll.timeout'),
]
}
function coverageGates(): Gate[] {
const workers = coverageWorkerArgs()
const timeouts = coverageTimeoutArgs()
return [
pnpmExec('coverage', [
const timeouts = coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV])
const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV])
const instrumented = partitions === undefined
? pnpmExec('coverage', [
'vitest',
'run',
'--coverage',
@@ -521,7 +549,15 @@ function coverageGates(): Gate[] {
], {
label: 'test:coverage',
env: { [COVERAGE_EXEMPT_ENV]: '1' },
}),
})
: pnpmScript('coverage', 'test:coverage:partitioned', {
label: 'test:coverage',
displayCommand: `${COVERAGE_PARTITIONS_ENV}=${partitions} pnpm run test:coverage:partitioned`,
env: { [COVERAGE_EXEMPT_ENV]: '1' },
streamOutput: true,
})
return [
instrumented,
pnpmExec('coverage-exempt-heavy', [
'vitest',
'run',
@@ -681,6 +717,11 @@ function validateGateGraph(gates: readonly Gate[]): void {
throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}.`)
}
}
for (const predecessor of gate.after ?? []) {
if (!ids.has(predecessor)) {
throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} waits for unknown gate ${JSON.stringify(predecessor)}.`)
}
}
}
const cycle = findDependencyCycle(gates)
@@ -702,8 +743,8 @@ function findDependencyCycle(gates: readonly Gate[]): string[] | undefined {
active.set(id, path.length)
path.push(id)
for (const dependency of gate.needs ?? []) {
const cycle = visit(dependency)
for (const predecessor of [...(gate.needs ?? []), ...(gate.after ?? [])]) {
const cycle = visit(predecessor)
if (cycle !== undefined) return cycle
}
path.pop()
@@ -744,7 +785,7 @@ export async function runGates(
for (;;) {
let madeProgress = false
while (running.length < maxActive) {
const ready = gates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states))
const ready = gates.find(gate => states.get(gate.id) === 'pending' && predecessorsReady(gate, states))
if (ready === undefined) break
states.set(ready.id, 'running')
running.push({ gate: ready, promise: execute(ready) })
@@ -753,32 +794,24 @@ export async function runGates(
}
if (running.length === 0) {
let pending = gates.filter(gate => states.get(gate.id) === 'pending')
while (pending.length > 0) {
const gate = pending.find(item => (item.needs ?? []).some((id) => {
const state = states.get(id)
return state === 'failed' || state === 'skipped'
}))
if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
const failedDeps = (gate.needs ?? []).filter((id) => {
const state = states.get(id)
return state === 'failed' || state === 'skipped'
})
const result: GateResult = {
gate,
status: 'skipped',
durationMs: 0,
output: [],
exitCode: null,
signalCode: null,
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
}
states.set(gate.id, 'skipped')
results.set(gate.id, result)
observe(result)
pending = pending.filter(item => item !== gate)
const pending = gates.filter(gate => states.get(gate.id) === 'pending')
if (pending.length === 0) break
const gate = pending.find(item => (item.needs ?? []).some(id => gateFailed(states.get(id))))
if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
const failedDeps = (gate.needs ?? []).filter(id => gateFailed(states.get(id)))
const result: GateResult = {
gate,
status: 'skipped',
durationMs: 0,
output: [],
exitCode: null,
signalCode: null,
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
}
break
states.set(gate.id, 'skipped')
results.set(gate.id, result)
observe(result)
continue
}
if (!madeProgress) {
@@ -797,8 +830,17 @@ export async function runGates(
})
}
function dependenciesPassed(gate: Gate, states: Map<string, GateState>): boolean {
function predecessorsReady(gate: Gate, states: Map<string, GateState>): boolean {
return (gate.needs ?? []).every(id => states.get(id) === 'passed')
&& (gate.after ?? []).every(id => gateSettled(states.get(id)))
}
function gateSettled(state: GateState | undefined): boolean {
return state === 'passed' || state === 'failed' || state === 'skipped'
}
function gateFailed(state: GateState | undefined): boolean {
return state === 'failed' || state === 'skipped'
}
/**
@@ -823,10 +865,12 @@ export async function runGate(gate: Gate): Promise<GateResult> {
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
output.push({ stream: 'stdout', text: chunk })
if (gate.streamOutput === true) process.stdout.write(chunk)
else output.push({ stream: 'stdout', text: chunk })
})
child.stderr.on('data', (chunk: string) => {
output.push({ stream: 'stderr', text: chunk })
if (gate.streamOutput === true) process.stderr.write(chunk)
else output.push({ stream: 'stderr', text: chunk })
})
child.on('error', (error) => {
spawnError = `failed to start command: ${error.message}`
@@ -880,7 +924,7 @@ function printResult(result: GateResult): void {
console.error(`command: ${result.gate.displayCommand}`)
console.error(`outcome: ${formatGateResultReason(result)}`)
}
printOutput(result.output)
if (result.gate.streamOutput !== true) printOutput(result.output)
}
function printSummary(results: GateResult[], durationMs: number): void {
+48
View File
@@ -0,0 +1,48 @@
/** Run serial browser owners before one bounded snapshot pool. */
import { spawn } from 'node:child_process'
const serialFiles = [
'apps/web/tests/hmr-live.e2e.ts',
'apps/web/tests/cordis-tool-round.e2e.ts',
]
const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS
const workers = Number.parseInt(workerRaw ?? '', 10)
if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) {
throw new Error(`DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`)
}
const pnpmEntrypoint = process.env.npm_execpath
if (pnpmEntrypoint === undefined || pnpmEntrypoint === '') {
throw new Error('parallel web snapshots must be invoked through a pnpm package script.')
}
const baseArgs = [pnpmEntrypoint, 'exec', 'vitest', 'run', '--config', 'vitest.web.config.ts']
let serialStatus = 0
for (const file of serialFiles) {
serialStatus = await run([...baseArgs, file])
if (serialStatus !== 0) break
}
if (serialStatus === 0) {
process.exitCode = await run([
...baseArgs,
...serialFiles.map(file => `--exclude=${file}`),
'--fileParallelism',
`--maxWorkers=${String(workers)}`,
])
} else {
process.exitCode = serialStatus
}
function run(args: string[]): Promise<number> {
return new Promise((resolveRun, reject) => {
const child = spawn(process.execPath, args, { stdio: 'inherit' })
child.once('error', reject)
child.once('exit', (exitCode, signalCode) => {
if (signalCode !== null) {
console.error(`web snapshots terminated by ${signalCode}`)
resolveRun(1)
return
}
resolveRun(exitCode ?? 1)
})
})
}
+2 -1
View File
@@ -294,11 +294,12 @@ function validateAppResolution(): string[] {
/**
* Discover workspace Bundle packages from their manifest declaration.
* @param repoRoot Repository root to scan.
* @returns Sorted repository-relative package manifest paths.
* @returns Sorted slash-normalized repository-relative package manifest paths.
*/
export function bundleManifestPaths(repoRoot: string = root): string[] {
return globSync('packages/*/*/package.json', { cwd: repoRoot })
.filter(path => typeof readManifest(path, repoRoot).dsh?.bundle?.patch === 'string')
.map(path => path.replaceAll('\\', '/'))
.sort()
}
+21 -10
View File
@@ -5,6 +5,7 @@ import { resolvePwshPath } from './packages/shell/pwsh-local/src/resolve.ts'
import { defineConfig } from 'vitest/config'
import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts'
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts'
import { COVERAGE_PARTITION_MODE_ENV } from './scripts/coverage-partitions.ts'
// Prints exact `path:line:col` records for every uncovered statement, branch
// path, and function when a file misses the per-file 100% gate — the built-in
@@ -100,6 +101,12 @@ const coverageExemptExcludes = coverageExemptRaw === '1'
? coverageExemptHeavySuites.map(suite => suite.exclude)
: []
const coveragePartitionRaw = process.env[COVERAGE_PARTITION_MODE_ENV]
if (coveragePartitionRaw !== undefined && coveragePartitionRaw !== '' && coveragePartitionRaw !== '1') {
throw new Error(`vitest config: ${COVERAGE_PARTITION_MODE_ENV} must be '1' or unset, got ${JSON.stringify(coveragePartitionRaw)}.`)
}
const coveragePartitionMode = coveragePartitionRaw === '1'
// These suites exercise process-global state, process APIs, or timing-sensitive process I/O
// 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.
@@ -270,16 +277,20 @@ export default defineConfig({
// Per-file so a well-covered big file can't subsidize a bare one.
// Every v8 ignore comment must carry a reason — see the quality-gates Agent Note
// (.agents/notes/implemented/process/2026-06-11-quality-gates.md).
thresholds: {
perFile: true,
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
reporter: process.env.CI
? ['text', uncoveredLocationsReporter]
: ['text', 'html', uncoveredLocationsReporter],
thresholds: coveragePartitionMode
? undefined
: {
perFile: true,
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
reporter: coveragePartitionMode
? []
: process.env.CI
? ['text', uncoveredLocationsReporter]
: ['text', 'html', uncoveredLocationsReporter],
},
},
})
+2 -1
View File
@@ -27,7 +27,8 @@ export default defineConfig({
'apps/web/tests/**/*.e2e.ts',
'apps/web/tests/**/*.snapshot.ts',
],
// Browser boot + real-model turns are slow; files share one browser, run serial.
// Local and record runs stay serial. CI runs workspace-mutating HMR and
// dynamic Cordis lifecycle coverage before parallelizing the remaining files.
testTimeout: 180_000,
hookTimeout: 120_000,
fileParallelism: false,