# 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 现在也固定为 4，以降低自托管高并发下的进程创建压力。运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](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。协调器通过 `vitest list --filesOnly` 收集插桩清单（调用方过滤器会先收窄清单；exempt 重型套件需在此剔除，因为 list 不应用其排除），从协调器持久化的 gitignore 文件读取逐文件耗时（windows-coverage job 通过 GitHub cache 恢复并保存该文件，因为 checkout 会删除它且 Vitest 自身缓存无法在 CI 存活），并借最小堆按最长处理时间把文件分配到各分区，使重量级子进程密集型套件分散到不同子进程，而不是全部落入路径 hash 恰好命中的那一个分片。每个分区获得一个临时 Vitest 配置，其 include 按 project 拆分（命令行传文件会超过 Windows CreateProcess 上限；互斥的 thread-safe 与 process-bound project 只保留各自的文件，避免任何文件跑两次）；空分区会在任何子进程启动前被拒绝，最重的分区最先启动使其结论最早落地（fail-fast），耗时历史通过 GitHub cache 以每 run 唯一键恢复与保存（cache 条目不可变）。分区模式会在各子进程中关闭阈值与覆盖率报告器，为每个子进程分配独立报告目录，并让每个进程写出 1 份 blob 报告。

协调器等待全部子进程结束，验证 blob 目录只包含预期文件，然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值，因此系统不会拿有意不完整的测试清单单独判定任一分区。

`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模，不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker，并允许 4 道外层门禁并发。工作区构建与生产网站验证会立即启动；两道覆盖率门禁都等待完整构建。插桩套件包含针对已构建 `lib/` 输出的打包器断言，因此这项依赖可避免它读取只完成部分产出的包闭包，也可避免豁免门禁的临时 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 清单及其阻断性划分，以及不缓冲的流式输出。可能在分区间移动的 React fake-timer 用例会在 `act()` 内推进计时器；依赖几何位置的 portal 测试会固定元素矩形，使不同分片调度不会把延迟更新或 jsdom 坐标变成只在覆盖率运行中出现的失败。

已完成的原生 Windows 对比中，双分区耗时约 405 秒，16 分区耗时 112.66–122.01 秒；这些数据来自先前的门禁顺序，只用于比较分区延迟，不代表当前峰值。当前的构建后阶段会让 4 个插桩分区进程与 2 个豁免 worker 并行，共形成 6 个覆盖率执行单元。若改为 16 个分区，则在尚未结束的生产网站工作或系统开销计入之前，该阶段就会达到 18 个执行单元。4 个分区保留独立进程隔离并与 Linux 对齐，代价是单 job 覆盖率墙钟更长；这是为了降低自托管高并发下 vitest worker 启动失败而接受的取舍。两个 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 进程运行具有不同的启动成本与资源上限。本次变更在 Windows 高并发运行暴露 8 分片 worker 启动失败后，将两者统一为 4 分区。

**在每个分区内独立应用阈值。** 不予采用，因为每个分区有意只看到套件的一部分，会误报未覆盖文件。阈值归合并报告所有。

## 后果

每个分区都要支付 1 次 Vitest 启动与配置开销，最后还要执行 1 次报告合并，但它不引入另一套工作流拓扑，并保留唯一的最终阈值判定。分区输出可能交错，但分区启动标签和 Vitest 文件标识仍可用于归因。

Linux 与 Windows 使用相同的协调器，并各自设置分区数量与外围 worker 预算。原生 Windows 会在完整构建之后启动两道覆盖率门禁，因为其插桩语料可能消费构建产物；Linux 的专用覆盖率 job 不会与同一工作区中的并发构建共享目录。本地覆盖率默认保持简单；只有调用方显式选择分区包脚本并提供大于 1 的合法数量时，才启用分区。

未来调优从一个固定配置的完整运行开始。进度缓慢本身绝不会提高分区数量或外层并发，因为反复重启会抹掉选择稳定设置所需的唯一证据。
