Kaynağa Gözat

fix(ci): preserve Windows gate ordering

imccyu 1 ay önce
ebeveyn
işleme
45a73e3ed5

+ 2 - 2
.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
-2026-07-06-parallel-pre-push-gates.md: 189d6c2dfe08a9551037b936fd8015a3e86d1e51
-2026-07-06-parallel-pre-push-gates.zh.md: 17920b189c30db57f661df41a2664e3e727d1589
+2026-07-06-parallel-pre-push-gates.md: 2ae08b8c87939085a0f8c7e0cb3ac69fb3ab8e91
+2026-07-06-parallel-pre-push-gates.zh.md: d31397966e7561cb7edcd9815a57b22a4a3ba8e1

+ 2 - 2
.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md

@@ -12,7 +12,7 @@ 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 by default, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. A gate marked `allowFailure` still reports its result but does not fail the aggregate.
+[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.
 
 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.
 
@@ -24,7 +24,7 @@ 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 and native Windows inventories and their dependency or 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.
+[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
 

+ 2 - 2
.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和按需启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,默认缓冲可归因的输出,分别报告进程退出与信号终止结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。标记为 `allowFailure` 的门禁仍会报告结果,但不会使聚合流程失败。
+[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和按需启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,默认缓冲可归因的输出,分别报告进程退出与信号终止结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。`needs` 边要求前置门禁通过,否则跳过依赖方;`after` 边只等待前置门禁以任意结果结算,随后仍允许后继门禁运行。标记为 `allowFailure` 的门禁仍会报告结果,但不会使聚合流程失败。
 
 自身子进程能够保留有效归因的长时间协调门禁可以选择 `streamOutput`。其 stdout 与 stderr 会立即到达父进程,不会被缓冲,也不会在结束时重复打印。分区覆盖率与并行 Web 快照使用该模式,使运行中途的失败无需等待兄弟工作结束就能显示。
 
@@ -24,7 +24,7 @@ Node 24 消费方任务采用单个包含 10 道门禁的模式,而非由 shel
 
 ## 验证
 
-[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定消费方与原生 Windows 清单及其依赖或失败语义,通过真实子进程验证信号终止,并证明流式输出会立即显示且不被缓冲。[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) 在下游产物消费方运行前拒绝缺失的公开导出。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
-2026-08-08-native-windows-pull-request-ci.md: 7bcdfa7a3e560f247b3041ddf6dd214031c540fc
-2026-08-08-native-windows-pull-request-ci.zh.md: 12c853364cbfb191e3bf7c06734559dcdd498ab3
+2026-08-08-native-windows-pull-request-ci.md: d4883cf1363a33a444f1172829149c0c41f21c10
+2026-08-08-native-windows-pull-request-ci.zh.md: c6eb91f0cbc0f3a97599f0c1bb60b8bf98d9c844

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md


+ 2 - 2
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md
-2026-08-18-in-job-partitioned-coverage.md: b7335c490c4a4921e5c786809e0db492613ef5c8
-2026-08-18-in-job-partitioned-coverage.zh.md: 5e2a6a60d59751a22c7d664d4bc50d93390c6995
+2026-08-18-in-job-partitioned-coverage.md: f86c2dffb6d3d30fdccfa445c57043c5217e439b
+2026-08-18-in-job-partitioned-coverage.zh.md: c7b1df28f1603a558d8dd3a3f0f26c6fb1edc3bd

+ 2 - 2
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md

@@ -18,7 +18,7 @@ When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:cove
 
 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, instrumented coverage, and exempt-heavy coverage start first, and the observational inventory waits for both coverage gates before entering the available slots. 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.
+`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
 
@@ -38,7 +38,7 @@ Completed native Windows comparisons measured two partitions near 405 seconds an
 
 **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 two-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.
+**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.
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。
 
-`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发:构建、生产网站验证、插桩覆盖率与豁免重型覆盖率先启动,观测性清单等待两道覆盖率门禁完成后才进入可用槽位。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。
+`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。构建、生产网站验证与插桩覆盖率会立即启动;豁免重型覆盖率只在构建通过后启动,避免其临时 Oxlint 探针与源码编译竞态。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。
 
 ## 失败与输出语义
 
@@ -38,7 +38,7 @@ Status: implemented
 
 **提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。
 
-**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的双进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。
+**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。
 
 **在每个分区内独立应用阈值。** 不予采用,因为每个分区有意只看到套件的一部分,会误报未覆盖文件。阈值归合并报告所有。
 

+ 1 - 0
.github/workflows/ci.yml

@@ -460,6 +460,7 @@ jobs:
       # under the complete lane's concurrent gate load.
       DSH_COVERAGE_TEST_TIMEOUT_MS: '30000'
       DSH_GATE_CONCURRENCY: '4'
+      DSH_PUBLINT_CONCURRENCY: '8'
     steps:
       - uses: actions/checkout@v6
         with:

+ 2 - 0
packages/subprocess/subprocess-local/tests/process-exit.spec.ts

@@ -106,6 +106,8 @@ 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'))
     if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state)
     await writeFile(join(root, 'proceed'), 'proceed')

+ 28 - 2
scripts/run-gates.spec.ts

@@ -109,15 +109,16 @@ describe('gate graph validation', () => {
 
     expect(byId.get('coverage')?.allowFailure).not.toBe(true)
     expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.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?.needs).toEqual(expect.arrayContaining([
+      expect(completeGate?.after).toEqual(expect.arrayContaining([
         'coverage',
         'coverage-exempt-heavy',
-        ...(gate.needs ?? []),
       ]))
+      expect(completeGate?.needs).toEqual(gate.needs)
     }
   })
 
@@ -171,7 +172,9 @@ describe('gate graph validation', () => {
     ['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))
 
@@ -197,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', () => {

+ 42 - 32
scripts/run-gates.ts

@@ -45,6 +45,8 @@ 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
@@ -454,8 +456,10 @@ function ciWindowsBlockingGates(): Gate[] {
 }
 
 function ciWindowsCompleteGates(): Gate[] {
-  const coverage = coverageGates()
-  const coverageNeeds = coverage.map(gate => gate.id)
+  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.
@@ -463,7 +467,7 @@ function ciWindowsCompleteGates(): Gate[] {
     .map(gate => ({
       ...gate,
       allowFailure: true,
-      needs: [...new Set([...coverageNeeds, ...(gate.needs ?? [])])],
+      after: [...new Set([...coverageAfter, ...(gate.after ?? [])])],
     }))
   return [
     pnpmScript('build', 'build'),
@@ -713,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)
@@ -734,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()
@@ -776,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) })
@@ -785,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) {
@@ -829,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'
 }
 
 /**

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor