Bladeren bron

Merge remote-tracking branch 'origin/master' into fix/windows-coverage-align-linux

Chinesezjc 3 weken geleden
bovenliggende
commit
faeb4e2218

+ 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: 22d69478f0fe664b91c4ada2c5c97e7c61ee7deb
-2026-07-06-parallel-pre-push-gates.zh.md: 98de527688399b8f6c09e91f55916361bf79d12d
+2026-07-06-parallel-pre-push-gates.md: 54fb01f03de1d0d198e373d960e9bd68b8687d60
+2026-07-06-parallel-pre-push-gates.zh.md: d7a949af649d3cf83da91358015f9196f71bc459

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

@@ -4,7 +4,7 @@ Status: implemented
 
 English | [中文](2026-07-06-parallel-pre-push-gates.zh.md)
 
-The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands.
+The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. The scheduler's fail-fast option is recorded in [Gate-runner fail-fast](2026-08-27-gate-runner-fail-fast.md).
 
 ## Problem
 

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

@@ -4,7 +4,7 @@ Status: implemented
 
 [English](2026-07-06-parallel-pre-push-gates.md) | 中文
 
-本记录中的本地钩子部分已由[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.zh.md) 取代。有界门禁调度器和包级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。
+本记录中的本地钩子部分已由[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.zh.md) 取代。有界门禁调度器和包级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。调度器的快速失败选项记录在[门禁运行器快速失败](2026-08-27-gate-runner-fail-fast.zh.md)。
 
 ## 问题
 

+ 6 - 0
.agents/notes/implemented/process/2026-08-27-gate-runner-fail-fast.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-27-gate-runner-fail-fast.md
+2026-08-27-gate-runner-fail-fast.md: b11e3336aad01d2d4bf57c132e1aeaddfac5c258
+2026-08-27-gate-runner-fail-fast.zh.md: 93e1b0314289afa17e89afea19c8b8c0563362f4

File diff suppressed because it is too large
+ 18 - 0
.agents/notes/implemented/process/2026-08-27-gate-runner-fail-fast.md


File diff suppressed because it is too large
+ 18 - 0
.agents/notes/implemented/process/2026-08-27-gate-runner-fail-fast.zh.md


+ 6 - 0
.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md
+2026-08-14-case-insensitive-path-round-trips.md: 5ff03a4fb55fc3dc136b22850f938685ed15a44f
+2026-08-14-case-insensitive-path-round-trips.zh.md: 6ad6121653badd3f636dda822a95fa7672b22cc3

+ 37 - 0
.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md

@@ -0,0 +1,37 @@
+# Agent Note: Case-insensitive path round-trips in test expectations
+
+Status: implemented
+
+English | [中文](2026-08-14-case-insensitive-path-round-trips.zh.md)
+
+## Problem
+
+[`packages/session/session-persistence-jsonl/tests/jsonl.spec.ts`](../../../../packages/session/session-persistence-jsonl/tests/jsonl.spec.ts) proves that a relative `root` is resolved before a session is located. It handed the plugin `relative(process.cwd(), absoluteRoot)` and built its expectation from `resolve(absoluteRoot)` — two different starting points for the same directory.
+
+On a case-insensitive filesystem those starting points can disagree on spelling. `path.relative()` on Windows compares case-insensitively and returns a path with the shared prefix removed, so the casing of that prefix is gone; `path.resolve()` then rebuilds it from `process.cwd()`. When the prefix `tmpdir()` and `process.cwd()` share is spelled with different casing in each, the plugin's resolved root carries the `cwd` spelling while the expectation carries the `tmpdir()` spelling, and `toEqual` compares two strings that name the same file.
+
+A host reaches that state when `tmpdir()` and `process.cwd()` share a path prefix but spell it differently — for example when `TMP` is mapped into the runner work tree under one spelling while the workspace path uses another. Sharing the tree is not enough on its own: if both spell the prefix alike, the round-trip returns the same string. The case fails there and passes everywhere else, which reads as a flake rather than as a fixed disagreement between two spellings.
+
+## Decision
+
+The expectation resolves the same relative root the plugin receives. Both sides pass through one `resolve(cwd, relative)` call, so the case-insensitive round-trip cannot place two spellings on the two sides of the comparison.
+
+This is a test-only change. The platform treats both spellings as the same file, so storage behaviour does not depend on which spelling `resolve()` produces. The string itself stays observable: hook payloads carry it as `transcript_path` and the shell contributor exports it as `DSH_SESSION_JSONL`, so a consumer that compares those strings can still see the difference. Composition fixtures such as [`apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml`](../../../../apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml) set a relative session root — but the plugin resolves whatever it receives before use, so a relative root reaches disk as one spelling rather than two.
+
+The case still asserts what it names: with the plugin's `resolve(config.root)` reduced to `config.root`, so a relative root is no longer resolved, the case fails.
+
+The neighbouring decision about constructing paths with the host `node:path` API lives in [cross-platform test fixtures](2026-07-22-cross-platform-test-fixtures.md); this note covers a different mechanism, the `relative()`/`resolve()` round-trip under a case-insensitive filesystem.
+
+## Alternatives considered
+
+**Compare the two paths case-insensitively.** This keeps the assertion green on the affected runners but accepts a real configuration disagreement as normal, and it would spread to every future path assertion rather than staying in the one case that round-trips through `relative()`.
+
+**Re-register the runners so `workFolder` matches the directory casing.** That repairs the underlying inconsistency, but `.runner` also carries the agent identity, pool, and server URLs, so hand-editing it risks a registration mismatch, and the test would remain fragile for any other host whose temp directory and working directory disagree on casing.
+
+**Normalize through `realpath()` in the expectation.** `realpath()` returns the on-disk casing, which is the `cwd` spelling here, so the case would pass; it also resolves symlinks, which changes what the assertion covers on hosts where the temp directory is a link.
+
+## Consequences
+
+The relative-root case now depends on `resolve()` alone rather than on the two spellings agreeing, so it passes on hosts whose temp directory and working directory disagree on casing. The underlying runner registration is untouched: a `workFolder` whose spelling differs from the directory on disk stays that way, so any future assertion that compares a `tmpdir()`-derived absolute path against a `cwd`-derived one will meet the same disagreement.
+
+The changed case passes, and the whole `session-persistence-jsonl` suite passes at 242 cases. The mechanism was reproduced away from Windows with `path.win32`: `relative()` on two differently-cased spellings of one directory returns a prefix-free relative path, `resolve()` rebuilds it from the `cwd` spelling, and the two absolute strings differ; with both sides spelled alike the same code matches. The regression check above — removing `resolve()` from the plugin — turns the case red while the fixture root and the working directory share a drive letter. Across drives `relative()` returns an absolute path, so both spellings already agree and the check cannot go red; the fixture roots come from `tmpdir()`, so the check only goes red where that path and the working directory share a drive.

+ 37 - 0
.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: 测试期望值里的大小写不敏感路径往返
+
+Status: implemented
+
+[English](2026-08-14-case-insensitive-path-round-trips.md) | 中文
+
+## 问题
+
+[`packages/session/session-persistence-jsonl/tests/jsonl.spec.ts`](../../../../packages/session/session-persistence-jsonl/tests/jsonl.spec.ts) 有一条用例验证「定位 session 之前会先解析相对 `root`」。它传给插件的是 `relative(process.cwd(), absoluteRoot)`,而期望值由 `resolve(absoluteRoot)` 算出——同一个目录、两个不同的起点。
+
+在大小写不敏感的文件系统上,这两个起点的拼写可能不一致。Windows 的 `path.relative()` 按大小写不敏感比较,返回的是去掉公共前缀之后的相对路径,前缀的大小写信息随之丢失;随后 `path.resolve()` 用 `process.cwd()` 重新拼出前缀。当 `tmpdir()` 与 `process.cwd()` 共有的那段前缀在两者中拼写大小写不同时,插件解析出的 root 带的是 `cwd` 那种拼写,而期望值带的是 `tmpdir()` 那种拼写,于是 `toEqual` 比较的是指向同一个文件的两个字符串。
+
+当 `tmpdir()` 与 `process.cwd()` 共享一段路径前缀、但两者对它的拼写不同时,主机就处在这个状态——例如把 `TMP` 以一种拼写映射进 runner 工作树、而 workspace 路径用另一种拼写。仅仅落在同一目录树内并不够:若两者的前缀拼写相同,往返会得到同一个字符串。该用例只在那里失败、别处都通过,看起来像 flake,实际是两种拼写之间一个固定的分歧。
+
+## 决定
+
+期望值改为解析「插件实际收到的那个相对 root」。两侧都经过同一次 `resolve(cwd, relative)`,大小写不敏感的往返就不可能把两种拼写分别放到比较的两边。
+
+这是只改测试的变更。平台把两种拼写视为同一个文件,所以存储行为不依赖 `resolve()` 产出哪种拼写。字符串本身仍可被观察到:hook 载荷以 `transcript_path` 携带它,shell 贡献者以 `DSH_SESSION_JSONL` 导出它,因此比较这些字符串的消费方仍能看出差异。组合 fixture(测试前置数据)如 [`apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml`](../../../../apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml) 就设置了相对的会话 root——但插件会先解析收到的值再使用,因此相对 root 落盘时只有一种拼写而非两种。
+
+该用例仍然在验证它声称的东西:把插件的 `resolve(config.root)` 降级成 `config.root`(即不再解析相对 root)后,用例转红。
+
+关于「用宿主的 `node:path` API 构造路径」这一相邻决策,归属的 note 是[跨平台测试前置数据](2026-07-22-cross-platform-test-fixtures.zh.md);本 note 讲的是另一个机制——大小写不敏感文件系统上 `relative()`/`resolve()` 的往返。
+
+## 考虑过的替代方案
+
+**按大小写不敏感的方式比较两个路径。** 这能让受影响的 runner 上变绿,但等于把一个真实的配置分歧当成正常状态接受;而且这种写法会扩散到之后每一条路径断言,而不是留在唯一经由 `relative()` 往返的这一条里。
+
+**重新注册 runner,让 `workFolder` 与目录大小写一致。** 这修的是底层的不一致,但 `.runner` 里同时存着 runner 的注册身份、pool 与 server URL,手工编辑有造成注册失配的风险;而且只要有别的宿主机的临时目录与工作目录大小写不一致,这条用例仍然是脆的。
+
+**在期望值里用 `realpath()` 归一化。** `realpath()` 返回磁盘上的真实大小写,在这里就是 `cwd` 那种拼写,用例会通过;但它同时会解析符号链接,在临时目录本身是链接的宿主机上会改变该断言覆盖的内容。
+
+## 后果
+
+相对 root 那条用例现在只依赖 `resolve()` 本身,不再依赖两种拼写是否一致,因此在临时目录与工作目录大小写不一致的宿主机上也能通过。runner 注册本身未被改动:注册拼写与磁盘目录名不一致的状态会保持下去,所以今后任何拿 `tmpdir()` 派生的绝对路径去和 `cwd` 派生路径比较的断言,都会遇到同一个分歧。
+
+改动后的用例通过,`session-persistence-jsonl` 整套 242 条用例通过。机制在非 Windows 环境用 `path.win32` 复现过:对同一目录的两种不同大小写拼写调用 `relative()` 会得到不含前缀的相对路径,`resolve()` 用 `cwd` 那种拼写重建,两个绝对字符串因此不同;把两侧拼写改成一致后,同一段代码即匹配。上面那条回归检查——把插件的 `resolve()` 去掉——在 fixture(测试前置数据)根与工作目录同盘符时会让用例转红。跨盘符时 `relative()` 返回绝对路径,两种拼写本就相同,该检查无法转红;本文件的 fixture 根来自 `tmpdir()`,所以只有该路径与工作目录同盘时该检查才会转红。

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

@@ -46,6 +46,9 @@ jobs:
     name: node 24 / static
     env:
       DSH_GATE_CONCURRENCY: '8'
+      # Stop the aggregate at the first blocking gate failure so a red run
+      # does not keep burning enterprise runner time on the remaining gates.
+      DSH_GATE_FAIL_FAST: '1'
     steps:
       # Fetch complete history so the archive gate can read the trusted PR base from a reused shallow checkout.
       - uses: actions/checkout@v6
@@ -102,6 +105,9 @@ jobs:
       DSH_COVERAGE_MAX_WORKERS: '6'
       DSH_COVERAGE_PARTITIONS: '4'
       DSH_GATE_CONCURRENCY: '3'
+      # A gate failure aborts the sibling gate instead of waiting out its
+      # multi-minute instrumented run.
+      DSH_GATE_FAIL_FAST: '1'
     steps:
       - uses: actions/checkout@v6
         with:
@@ -164,6 +170,10 @@ jobs:
       DSH_OXLINT_THREADS: '8'
       DSH_PUBLINT_CONCURRENCY: '8'
       DSH_WEB_SNAPSHOT_WORKERS: '6'
+      # A failing gate aborts its running siblings: a failing build stops the
+      # independent Node compatibility smoke, and a failing reader (e.g.
+      # publint) stops the remaining artifact consumers.
+      DSH_GATE_FAIL_FAST: '1'
       # 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:
@@ -244,6 +254,9 @@ jobs:
     env:
       DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }}
       DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
+      # A failed smoke aborts the remaining compatibility gates instead of
+      # letting the build-backed legs run against an already-red aggregate.
+      DSH_GATE_FAIL_FAST: '1'
     strategy:
       fail-fast: false
       matrix:
@@ -425,6 +438,9 @@ jobs:
           || 'dsh-windows-2025-16core' }}
     name: windows node 24 / build
     timeout-minutes: 60
+    env:
+      # A failing build or site aborts the sibling gate on the same runner.
+      DSH_GATE_FAIL_FAST: '1'
     steps:
       - uses: actions/checkout@v6
         with:
@@ -471,6 +487,9 @@ jobs:
       DSH_COVERAGE_PARTITIONS: '4'
       DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
       DSH_GATE_CONCURRENCY: '3'
+      # A gate failure aborts the sibling gate instead of waiting out its
+      # multi-minute instrumented run.
+      DSH_GATE_FAIL_FAST: '1'
     steps:
       - uses: actions/checkout@v6
         with:

+ 7 - 2
packages/session/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -171,17 +171,22 @@ describe('JsonlSessionPersistence: format helpers', () => {
 
   it('resolves a relative custom root before locating a session', async () => {
     const absoluteRoot = await freshRoot()
+    // Resolve the same relative root the plugin receives, not the original absolute
+    // path, so both sides of the comparison pass through one `resolve()` call. See
+    // .agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md
+    // for why an absolute root can arrive under a different casing.
+    const relativeRoot = relative(process.cwd(), absoluteRoot)
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     const fiber = await ctx.plugin(JsonlSessionPersistence, {
-      root: relative(process.cwd(), absoluteRoot),
+      root: relativeRoot,
       compression: 'none',
       writeBatchMaxDelayMs: 1,
     })
     const m = meta('relative-location', '/work')
     expect(ctx.sessionPersistence.locate(m)).toEqual({
       kind: 'jsonl',
-      path: rawLogPath(resolve(absoluteRoot), '/work', m.id),
+      path: rawLogPath(resolve(relativeRoot), '/work', m.id),
     })
     await fiber.dispose()
   })

+ 23 - 1
scripts/ci-workflow.spec.ts

@@ -67,11 +67,12 @@ describe('CI workflow', () => {
       || !isRecord(workflow.jobs['node-24'])
       || !isRecord(workflow.jobs['node-24-coverage'])
       || !isRecord(workflow.jobs['node-24-consumers'])
+      || !isRecord(workflow.jobs['node-compat'])
       || !isRecord(workflow.jobs['all-checks-passed'])
       || !isRecord(masterWorkflow.jobs)
       || !isRecord(masterWorkflow.jobs['wine-apt-cache'])
       || !isRecord(masterWorkflow.jobs['serial-windows'])) {
-      throw new TypeError('CI workflow must define windows, windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-consumers, and all-checks-passed; ci-master must define wine-apt-cache and serial-windows')
+      throw new TypeError('CI workflow must define windows, windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-consumers, node-compat, and all-checks-passed; ci-master must define wine-apt-cache and serial-windows')
     }
 
     const windows = workflow.jobs.windows
@@ -84,6 +85,7 @@ describe('CI workflow', () => {
     const node24 = workflow.jobs['node-24']
     const node24Coverage = workflow.jobs['node-24-coverage']
     const node24Consumers = workflow.jobs['node-24-consumers']
+    const nodeCompat = workflow.jobs['node-compat']
     const aggregate = workflow.jobs['all-checks-passed']
     if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) {
       throw new TypeError('Windows job must define steps and the aggregate must define needs')
@@ -230,6 +232,26 @@ describe('CI workflow', () => {
     expect(aggregate['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
     expect(aggregate['runs-on']).not.toContain('DSH_CI_FAILOVER_WINDOWS')
     expect(aggregate['runs-on']).toContain('vm-backup')
+
+    // The run-gates aggregate lanes stop at the first blocking gate failure so
+    // a red aggregate does not keep burning runner time on the remaining
+    // gates. Removing the flag silently reverts to running every independent
+    // gate to completion.
+    for (const [jobName, job] of [['node-24', node24], ['node-24-coverage', node24Coverage], ['node-24-consumers', node24Consumers], ['node-compat', nodeCompat]] as const) {
+      expect(job.env, `${jobName} must enable fail-fast`).toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
+    }
+
+    // The native Windows lanes with run-gates aggregates fail fast for the
+    // same reason: a failing gate aborts the sibling gate instead of waiting
+    // out the multi-minute instrumented coverage run.
+    expect(windowsBuild.env, 'windows-build must enable fail-fast').toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
+    expect(windowsCoverage.env, 'windows-coverage must enable fail-fast').toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
+
+    // The observational lane stays complete: it is continue-on-error by design
+    // and exists to collect as much Windows-native evidence per run as
+    // possible, so the first failure must not truncate the rest.
+    expect(windowsObservational.env).toBeDefined()
+    expect(windowsObservational.env).not.toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
   })
 
   it('gives the Wine Host TypeScript compile the repository heap budget', () => {

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

@@ -1,14 +1,94 @@
-import { describe, expect, it, vi } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { describe, expect, it, vi, type MockInstance } from 'vitest'
 import {
+  cliGateOptions,
   defaultConcurrency,
   formatGateResultReason,
   gatesForMode,
+  parsePidPpidLines,
   runGate,
   runGates,
+  taskkillArgs,
   type Gate,
   type GateResult,
 } from './run-gates.ts'
 
+/**
+ * Capture output a gate streams through runGate's streamOutput path.
+ * @returns the accumulated chunks and the stdout spy to restore in finally.
+ */
+function captureStreamedOutput(): { writes: string[]; write: MockInstance } {
+  const writes: string[] = []
+  const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
+    writes.push(String(chunk))
+    return true
+  })
+  return { writes, write }
+}
+
+/**
+ * A process has stopped executing when its /proc entry is gone, or when it
+ * lingers as a zombie ('Z') — an un-reaped but dead entry still answers
+ * kill(pid, 0), so existence is not a liveness check. Non-Linux falls back to
+ * kill(pid, 0), whose ESRCH means the process is gone.
+ */
+function procStopped(pid: number): boolean {
+  if (process.platform === 'linux') {
+    try {
+      const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
+      return /\)\s+Z\s/.test(stat)
+    } catch {
+      return true
+    }
+  }
+  try {
+    process.kill(pid, 0)
+    return false
+  } catch {
+    return true
+  }
+}
+
+/**
+ * Wait until the captured output contains `marker` and the grandchild pid the
+ * gate printed, then return that pid.
+ * @param writes - chunks captured from the gate's streamed stdout.
+ * @param marker - the output line that proves the gate reached the abort point.
+ * @param deadline - fail the wait when exceeded.
+ * @returns the grandchild pid printed by the gate script.
+ */
+async function waitForGrandchildPid(writes: string[], marker: string, deadline: number): Promise<number> {
+  let pid: number | undefined
+  while ((pid === undefined || !writes.join('').includes(marker)) && Date.now() < deadline) {
+    const match = writes.join('').match(/grandchild:(\d+)/)
+    if (match !== null) pid = Number(match[1])
+    await new Promise(resolve => setTimeout(resolve, 50))
+  }
+  expect(pid ?? 0).toBeGreaterThan(0)
+  expect(writes.join('')).toContain(marker)
+  return pid!
+}
+
+/**
+ * Abort the run and assert it settles marked aborted with the grandchild no
+ * longer executing — the abort path must have signalled it from the captured
+ * descendant list rather than settling over a live orphan.
+ * @param promise - the pending `runGate` promise.
+ * @param controller - the signal source to abort.
+ * @param pid - the grandchild pid the gate script printed.
+ */
+async function abortAndExpectTreeStopped(promise: Promise<GateResult>, controller: AbortController, pid: number): Promise<void> {
+  controller.abort()
+  const result = await promise
+  expect(result.aborted).toBe(true)
+  const stopDeadline = Date.now() + 8000
+  while (!procStopped(pid) && Date.now() < stopDeadline) {
+    await new Promise(resolve => setTimeout(resolve, 50))
+  }
+  expect(procStopped(pid)).toBe(true)
+}
+
+
 function gate(id: string, options: Partial<Gate> = {}): Gate {
   return {
     id,
@@ -294,7 +374,7 @@ describe('gate graph validation', () => {
     const results = await runGates([dependent, root], 1, execute)
 
     expect(execute).toHaveBeenCalledOnce()
-    expect(execute).toHaveBeenCalledWith(root)
+    expect(execute).toHaveBeenCalledWith(root, undefined)
     expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
   })
 
@@ -529,3 +609,300 @@ describe('gate process outcomes', () => {
     expect(formatGateResultReason(result)).toBe('signal SIGTERM')
   })
 })
+
+describe('fail-fast scheduling', () => {
+  it('aborts the aggregate at the first blocking failure', async () => {
+    const slow = gate('slow')
+    const fast = gate('fast')
+    const dependent = gate('dependent', { needs: ['slow'] })
+    const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
+      if (subject.id === 'fast') {
+        return new Promise<GateResult>((resolve) => {
+          signal?.addEventListener('abort', () => {
+            // The real runGate marks a gate the abort terminated; the drain
+            // must then record it skipped rather than keep the failure.
+            resolve({ ...resultFor(subject, 'failed'), aborted: true })
+          }, { once: true })
+        })
+      }
+      return resultFor(subject, subject.id === 'slow' ? 'failed' : 'passed')
+    })
+
+    const results = await runGates([slow, fast, dependent], 2, execute, () => {}, { failFast: true })
+
+    expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['slow', 'fast'])
+    expect(results.map(result => result.status)).toEqual(['failed', 'skipped', 'skipped'])
+    expect(results[1]).toMatchObject({
+      status: 'skipped',
+      error: 'aborted by fail-fast: slow failed',
+    })
+    expect(results[2]).toMatchObject({
+      status: 'skipped',
+      error: 'aborted by fail-fast: slow failed',
+    })
+  })
+
+  it('does not abort on a non-blocking gate failure', async () => {
+    const observational = gate('observational', { allowFailure: true })
+    const root = gate('root')
+    const execute = vi.fn(async (subject: Gate) => (
+      resultFor(subject, subject.id === 'observational' ? 'failed' : 'passed')
+    ))
+
+    const results = await runGates([observational, root], 2, execute, () => {}, { failFast: true })
+
+    expect(execute).toHaveBeenCalledTimes(2)
+    expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
+  })
+
+  it('runs independent gates to completion when fail-fast is disabled', async () => {
+    const root = gate('root')
+    const sibling = gate('sibling')
+    const execute = vi.fn(async (subject: Gate) => (
+      resultFor(subject, subject.id === 'root' ? 'failed' : 'passed')
+    ))
+
+    const results = await runGates([root, sibling], 2, execute, () => {}, { failFast: false })
+
+    expect(execute).toHaveBeenCalledTimes(2)
+    expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
+  })
+
+  it('kills the child when the abort signal fires', async () => {
+    const controller = new AbortController()
+    const promise = runGate(gate('killable', { args: ['-e', 'setInterval(() => {}, 1000)'] }), controller.signal)
+    controller.abort()
+    const result = await promise
+
+    expect(result.status).toBe('failed')
+    expect(result.aborted).toBe(true)
+    if (process.platform !== 'win32') expect(result.signalCode).toBe('SIGTERM')
+  })
+
+  it.skipIf(process.platform === 'win32')('marks a zero-exit child as aborted when the signal fired', async () => {
+    const { writes, write } = captureStreamedOutput()
+    try {
+      const controller = new AbortController()
+      const child = gate('traps-signal', {
+        args: ['-e', "process.stdout.write('ready\\n'); process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"],
+        streamOutput: true,
+      })
+      const promise = runGate(child, controller.signal)
+      // Wait for the child to register its SIGTERM trap before aborting, so
+      // the signal is caught and the child really exits zero.
+      const deadline = Date.now() + 5000
+      while (!writes.join('').includes('ready') && Date.now() < deadline) {
+        await new Promise(resolve => setTimeout(resolve, 10))
+      }
+      controller.abort()
+      const result = await promise
+
+      // The child trapped the signal and exited zero; the drain must not
+      // report this gate passed, so the raw outcome carries the abort mark.
+      expect(result.status).toBe('passed')
+      expect(result.aborted).toBe(true)
+    } finally {
+      write.mockRestore()
+    }
+  })
+
+  it.skipIf(process.platform === 'win32')('kills the whole gate process tree when the abort signal fires', async () => {
+    const { writes, write } = captureStreamedOutput()
+    const controller = new AbortController()
+    let promise: Promise<GateResult> | undefined
+    try {
+      const script = [
+        "const { spawn } = require('node:child_process')",
+        // Detached, so the grandchild leads its own process group: the gate
+        // group signal cannot reach it, and only the descendant enumeration in
+        // treeKill does — the shape of a nested run-gates' leaf gates.
+        "const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true })",
+        "process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
+        'setInterval(() => {}, 1000)',
+      ].join(';')
+      promise = runGate(gate('tree', { args: ['-e', script], streamOutput: true }), controller.signal)
+      const deadline = Date.now() + 5000
+      let pid: number | undefined
+      while (pid === undefined && Date.now() < deadline) {
+        const match = writes.join('').match(/grandchild:(\d+)/)
+        if (match !== null) pid = Number(match[1])
+        else await new Promise(resolve => setTimeout(resolve, 20))
+      }
+      expect(pid ?? 0).toBeGreaterThan(0)
+      controller.abort()
+      const result = await promise
+      expect(result.status).toBe('failed')
+      // The descendant enumeration signals the detached grandchild at the same
+      // time as the group signal reaches the direct child; the direct child's
+      // own death closes the gate pipes, so poll for the grandchild to stop
+      // executing rather than asserting on a fixed instant.
+      const stopDeadline = Date.now() + 5000
+      while (!procStopped(pid!) && Date.now() < stopDeadline) {
+        await new Promise(resolve => setTimeout(resolve, 20))
+      }
+      expect(procStopped(pid!)).toBe(true)
+    } finally {
+      // A failed wait or assertion must not leave the forever-looping detached
+      // grandchild behind on the host: abort the gate and wait for the
+      // process tree to settle before restoring the spy.
+      controller.abort()
+      await promise
+      write.mockRestore()
+    }
+  })
+
+  it('forwards host interruption signals to the abort path', async () => {
+    const slow = gate('slow')
+    const sibling = gate('sibling')
+    const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
+      if (subject.id === 'slow') {
+        return new Promise<GateResult>((resolve) => {
+          signal?.addEventListener('abort', () => {
+            // A child can trap the signal and exit zero; the drain must still
+            // record the gate skipped so the interrupted run fails.
+            resolve({ ...resultFor(subject, 'passed'), aborted: true })
+          }, { once: true })
+        })
+      }
+      return resultFor(subject)
+    })
+
+    const promise = runGates([slow, sibling], 1, execute, () => {}, { failFast: true, forwardProcessSignals: true })
+    // The first loop iteration starts `slow` synchronously, so its abort
+    // listener is registered before the signal is emitted.
+    process.emit('SIGTERM')
+    const results = await promise
+
+    expect(execute).toHaveBeenCalledOnce()
+    expect(results.map(result => result.status)).toEqual(['skipped', 'skipped'])
+    expect(results[0]).toMatchObject({
+      status: 'skipped',
+      error: 'aborted by fail-fast: host interruption',
+    })
+  })
+
+  it('pairs host signal forwarding with fail-fast at the CLI entrypoint', () => {
+    expect(cliGateOptions(true)).toEqual({ failFast: true, forwardProcessSignals: true })
+    expect(cliGateOptions(false)).toEqual({ failFast: false, forwardProcessSignals: false })
+  })
+
+  it('rejects host signal forwarding without fail-fast', async () => {
+    const execute = vi.fn(async (subject: Gate) => resultFor(subject))
+
+    await expect(runGates([gate('subject')], 1, execute, () => {}, { forwardProcessSignals: true }))
+      .rejects.toThrow('forwardProcessSignals requires failFast')
+    expect(execute).not.toHaveBeenCalled()
+  })
+
+  it('leaves an un-aborted child running to completion', async () => {
+    const result = await runGate(gate('settles', { args: ['-e', ''] }), new AbortController().signal)
+
+    expect(result.status).toBe('passed')
+    expect(result.aborted).toBe(false)
+  })
+
+  it.skipIf(process.platform === 'win32')('kills a detached descendant that outlived the child when the abort arrives later', async () => {
+    const writes: string[] = []
+    const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
+      writes.push(String(chunk))
+      return true
+    })
+    const controller = new AbortController()
+    let promise: Promise<GateResult> | undefined
+    try {
+      const script = [
+        "const { spawn } = require('node:child_process')",
+        // Detached with inherited stdio: the grandchild leads its own process
+        // group (the gate group signal misses it) and holds the gate's
+        // stdout write end (so `close` stays pending past the child exit).
+        "const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' })",
+        "process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
+        // Outlive the first descendant-sampler tick with margin so the cache
+        // holds the grandchild even on a loaded runner, then exit normally
+        // before the abort arrives.
+        "setTimeout(() => { process.stdout.write('child-exit\\n'); process.exit(0) }, 8000)",
+      ].join(';')
+      promise = runGate(gate('late-abort', { args: ['-e', script], streamOutput: true }), controller.signal)
+      const pid = await waitForGrandchildPid(writes, 'child-exit', Date.now() + 10000)
+      // terminate must not re-enumerate over the sampler cache now that the
+      // child is gone; the detached grandchild is killed from the cached list.
+      await abortAndExpectTreeStopped(promise, controller, pid)
+    } finally {
+      // A failed wait or assertion must not leave the forever-looping detached
+      // grandchild behind on the host: abort the gate and wait for the
+      // process tree to settle before restoring the spy.
+      controller.abort()
+      await promise
+      write.mockRestore()
+    }
+  }, 20000)
+
+  it.skipIf(process.platform === 'win32')('keeps a reparented detached descendant tracked across a sampler tick', async () => {
+    const { writes, write } = captureStreamedOutput()
+    const controller = new AbortController()
+    let promise: Promise<GateResult> | undefined
+    try {
+      const script = [
+        "const { spawn } = require('node:child_process')",
+        // Wrapper spawns a detached grandchild with inherited stdio (its own
+        // process group, holding the gate's stdout write end), prints the pid,
+        // then exits after 7 seconds — after the first sampler tick, before
+        // the second. From then on the grandchild is reparented and
+        // unreachable by parent id.
+        "const wrapper = spawn(process.execPath, ['-e', \"const { spawn } = require('node:child_process'); const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' }); process.stdout.write('grandchild:' + grandchild.pid + '\\\\n'); setTimeout(() => process.exit(0), 7000)\"], { stdio: 'inherit' })",
+        "wrapper.on('exit', () => process.stdout.write('wrapper-exited\\n'))",
+        // Keep the root child alive past the abort with a heartbeat so the
+        // test can abort while it is still running.
+        "setInterval(() => process.stdout.write('hb\\n'), 1000)",
+      ].join(';')
+      promise = runGate(gate('sampler-merge', { args: ['-e', script], streamOutput: true }), controller.signal)
+      const pid = await waitForGrandchildPid(writes, 'wrapper-exited', Date.now() + 15000)
+      // Wait past the second sampler tick (t=10) with margin: a replacing tick
+      // would drop the reparented grandchild from the cache, after which the
+      // abort cannot reach it. The root child keeps running throughout.
+      const tickDeadline = Date.now() + 10000
+      const wrapperExitedAt = Date.now()
+      while (Date.now() - wrapperExitedAt < 5000 && Date.now() < tickDeadline) {
+        await new Promise(resolve => setTimeout(resolve, 50))
+      }
+      expect(Date.now() - wrapperExitedAt).toBeGreaterThanOrEqual(5000)
+      await abortAndExpectTreeStopped(promise, controller, pid)
+    } finally {
+      // A failed wait or assertion must not leave the forever-looping detached
+      // grandchild behind on the host: abort the gate and wait for the
+      // process tree to settle before restoring the spy.
+      controller.abort()
+      await promise
+      write.mockRestore()
+    }
+  }, 30000)
+})
+
+describe('process-table parsing', () => {
+  it('parses `pid ppid` rows from a POSIX ps dump', () => {
+    expect(parsePidPpidLines('  123   1\n456 123\n  789 456\n')).toEqual([[123, 1], [456, 123], [789, 456]])
+  })
+
+  it('parses Windows PowerShell Get-CimInstance output of the same shape', () => {
+    expect(parsePidPpidLines(' 123 1\r\n456 123\r\n')).toEqual([[123, 1], [456, 123]])
+  })
+
+  it('drops blank and malformed lines', () => {
+    expect(parsePidPpidLines('  123   1\n\ncommand not found\n999 abc\n')).toEqual([[123, 1]])
+  })
+})
+
+describe('Windows tree termination', () => {
+  it('targets the root first and each captured descendant after it', () => {
+    expect(taskkillArgs(100, [201, 302, 403])).toEqual([
+      ['/PID', '100', '/T', '/F'],
+      ['/PID', '201', '/T', '/F'],
+      ['/PID', '302', '/T', '/F'],
+      ['/PID', '403', '/T', '/F'],
+    ])
+  })
+
+  it('terminates the root alone when no descendant was captured', () => {
+    expect(taskkillArgs(100, [])).toEqual([['/PID', '100', '/T', '/F']])
+  })
+})

+ 579 - 41
scripts/run-gates.ts

@@ -5,7 +5,8 @@
  * dependency graphs, scheduler environment, and process diagnostics.
  * @see ../.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
  */
-import { spawn } from 'node:child_process'
+import { spawn, spawnSync } from 'node:child_process'
+import { readdirSync, readFileSync } from 'node:fs'
 import { availableParallelism } from 'node:os'
 import { resolve } from 'node:path'
 import { performance } from 'node:perf_hooks'
@@ -69,6 +70,10 @@ export interface GateResult {
   exitCode: number | null
   signalCode: NodeJS.Signals | null
   error?: string
+  /** True when the shared abort signal terminated this gate before its outcome
+   * was observed; such a result must not be reported as passed, even if the
+   * child trapped the signal and exited zero. */
+  aborted?: boolean
 }
 
 interface GateOutputChunk {
@@ -86,7 +91,7 @@ interface ConcurrencyDefault {
   source: string
 }
 
-type GateExecutor = (gate: Gate) => Promise<GateResult>
+type GateExecutor = (gate: Gate, signal?: AbortSignal) => Promise<GateResult>
 type ResultObserver = (result: GateResult) => void
 
 const root = resolve(import.meta.dirname, '..')
@@ -103,16 +108,28 @@ async function main(args: string[]): Promise<number> {
   const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === ''
     ? concurrencyDefault.source
     : '$DSH_GATE_CONCURRENCY'
+  const failFast = flagEnabled('DSH_GATE_FAIL_FAST')
   const startedAt = performance.now()
-  console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
+  console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}${failFast ? ', fail-fast after first blocking failure' : ''}.`)
 
-  const results = await runGates(gates, maxConcurrency, runGate, printResult)
+  const results = await runGates(gates, maxConcurrency, runGate, printResult, cliGateOptions(failFast))
   printSummary(results, performance.now() - startedAt)
   return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))
     ? 1
     : 0
 }
 
+/**
+ * The options the CLI entrypoint hands to the scheduler. Host signal
+ * forwarding always follows fail-fast: children are detached only then, so
+ * without it the forwarding would have no tree to drain.
+ * @param failFast - whether `DSH_GATE_FAIL_FAST` is enabled.
+ * @returns the scheduler options for the entrypoint.
+ */
+export function cliGateOptions(failFast: boolean): RunGatesOptions {
+  return { failFast, forwardProcessSignals: failFast }
+}
+
 function parseMode(raw: string | undefined): Mode {
   switch (raw) {
     case 'ci-primary':
@@ -836,12 +853,30 @@ function findDependencyCycle(gates: readonly Gate[]): string[] | undefined {
   return undefined
 }
 
+/**
+ * Scheduling options for one aggregate.
+ */
+export interface RunGatesOptions {
+  /** Stop the aggregate at the first blocking gate failure. */
+  failFast?: boolean
+  /** Forward host SIGINT/SIGTERM to the abort path so detached gate trees are
+   * terminated when the run itself is interrupted or the runner cancels it.
+   * Tree termination additionally requires failFast, because only then is the
+   * abort signal passed to the executor and children detached. */
+  forwardProcessSignals?: boolean
+}
+
 /**
  * Validate and run one aggregate before the injected executor can start a child.
  * @param gates - complete aggregate to execute.
  * @param maxActive - maximum concurrent child count.
- * @param execute - child-process executor.
+ * @param execute - child-process executor; receives the abort signal only when
+ * fail-fast is enabled, so ordinary runs keep their children in the host
+ * process group.
  * @param observe - result observer invoked when each gate settles.
+ * @param options - scheduling options; fail-fast aborts the aggregate at the
+ * first blocking gate failure by killing running children and skipping every
+ * not-yet-run gate.
  * @returns results in aggregate order.
  */
 export async function runGates(
@@ -849,54 +884,105 @@ export async function runGates(
   maxActive: number,
   execute: GateExecutor,
   observe: ResultObserver = () => {},
+  options: RunGatesOptions = {},
 ): Promise<GateResult[]> {
   validateGateGraph(gates)
   if (!Number.isSafeInteger(maxActive) || maxActive < 1) {
     throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`)
   }
+  if (options.forwardProcessSignals === true && options.failFast !== true) {
+    throw new Error('run-gates: forwardProcessSignals requires failFast, otherwise no child is detached or killed.')
+  }
   const states = new Map<string, GateState>(gates.map(gate => [gate.id, 'pending']))
   const results = new Map<string, GateResult>()
   const running: RunningGate[] = []
-
-  for (;;) {
-    let madeProgress = false
-    while (running.length < maxActive) {
-      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) })
-      console.log(`run-gates: start ${ready.label}`)
-      madeProgress = true
+  const abort = new AbortController()
+  let abortCause: string | undefined
+  // Host interruption (terminal Ctrl+C, runner cancellation) drains through
+  // the same abort path as a gate failure, so detached trees are killed and
+  // never orphaned. Handlers are removed before returning.
+  const hostSignals = options.forwardProcessSignals === true ? ['SIGINT', 'SIGTERM'] as const : []
+  const hostHandlers = hostSignals.map((name) => {
+    const handler = () => {
+      abortCause = abortCause ?? 'host interruption'
+      abort.abort()
     }
+    process.on(name, handler)
+    return { name, handler }
+  })
+  const failFastSignal = options.failFast === true ? abort.signal : undefined
+
+  try {
+    for (;;) {
+      let madeProgress = false
+      if (abortCause === undefined) {
+        while (running.length < maxActive) {
+          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, failFastSignal) })
+          console.log(`run-gates: start ${ready.label}`)
+          madeProgress = true
+        }
+      }
 
-    if (running.length === 0) {
-      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(', ')}`,
+      if (running.length === 0) {
+        if (abortCause !== undefined) {
+          for (const gate of gates) {
+            if (states.get(gate.id) !== 'pending') continue
+            const skipped = skippedByFailFast(gate, abortCause)
+            states.set(gate.id, 'skipped')
+            results.set(gate.id, skipped)
+            observe(skipped)
+          }
+          break
+        }
+        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(', ')}`,
+        }
+        states.set(gate.id, 'skipped')
+        results.set(gate.id, result)
+        observe(result)
+        continue
       }
-      states.set(gate.id, 'skipped')
-      results.set(gate.id, result)
-      observe(result)
-      continue
-    }
 
-    if (!madeProgress) {
-      const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
-      running.splice(running.indexOf(settled.item), 1)
-      states.set(settled.item.gate.id, settled.result.status)
-      results.set(settled.item.gate.id, settled.result)
-      observe(settled.result)
+      if (!madeProgress) {
+        const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
+        running.splice(running.indexOf(settled.item), 1)
+        const observed = abortCause === undefined || settled.result.aborted !== true
+          ? settled.result
+          : skippedByFailFast(settled.item.gate, abortCause)
+        states.set(settled.item.gate.id, observed.status)
+        results.set(settled.item.gate.id, observed)
+        observe(observed)
+        if (abortCause === undefined && options.failFast === true
+          && observed.status === 'failed' && settled.item.gate.allowFailure !== true) {
+          abortCause = `${observed.gate.label} failed`
+          abort.abort()
+          console.error(`run-gates: fail-fast aborting: ${abortCause}.`)
+          for (const gate of gates) {
+            if (states.get(gate.id) !== 'pending') continue
+            const skipped = skippedByFailFast(gate, abortCause)
+            states.set(gate.id, 'skipped')
+            results.set(gate.id, skipped)
+            observe(skipped)
+          }
+        }
+      }
     }
+  } finally {
+    for (const { name, handler } of hostHandlers) process.removeListener(name, handler)
   }
 
   return gates.map((gate) => {
@@ -906,6 +992,31 @@ export async function runGates(
   })
 }
 
+/**
+ * The result of a gate that produced no evidence because fail-fast aborted.
+ * A gate whose process settled before the abort took effect keeps its real
+ * result instead: it did produce evidence, and the summary must say so. Any
+ * result settling after the abort — including a genuine independent failure
+ * in the race window, and a child that trapped the signal and exited zero —
+ * is recorded skipped with its partial output discarded, because on Windows a
+ * killed process is indistinguishable from a failed one by exit code alone.
+ * @param gate - the gate that produced no evidence.
+ * @param cause - the full clause naming what aborted the aggregate, e.g.
+ * `typecheck failed` or `host interruption`.
+ * @returns the skipped record with the fail-fast error.
+ */
+function skippedByFailFast(gate: Gate, cause: string): GateResult {
+  return {
+    gate,
+    status: 'skipped',
+    durationMs: 0,
+    output: [],
+    exitCode: null,
+    signalCode: null,
+    error: `aborted by fail-fast: ${cause}`,
+  }
+}
+
 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)))
@@ -922,12 +1033,17 @@ function gateFailed(state: GateState | undefined): boolean {
 /**
  * Execute one gate through the real shell-free child-process boundary.
  * @param gate - command and scheduler environment to execute.
+ * @param signal - abort signal that terminates the whole gate process tree when
+ * the aggregate fails fast; an already-aborted signal terminates it
+ * immediately. A provided signal spawns the child detached so POSIX can signal
+ * its process group and Windows can reach its tree through taskkill.
  * @returns the complete process outcome.
  */
-export async function runGate(gate: Gate): Promise<GateResult> {
+export async function runGate(gate: Gate, signal?: AbortSignal): Promise<GateResult> {
   const started = performance.now()
   const output: GateOutputChunk[] = []
   let spawnError: string | undefined
+  let aborted = false
 
   const outcome = await new Promise<{
     exitCode: number | null
@@ -937,6 +1053,7 @@ export async function runGate(gate: Gate): Promise<GateResult> {
       cwd: root,
       env: { ...process.env, ...gate.env },
       stdio: ['pipe', 'pipe', 'pipe'],
+      detached: signal !== undefined && process.platform !== 'win32',
     })
     child.stdout.setEncoding('utf8')
     child.stderr.setEncoding('utf8')
@@ -948,11 +1065,187 @@ export async function runGate(gate: Gate): Promise<GateResult> {
       if (gate.streamOutput === true) process.stderr.write(chunk)
       else output.push({ stream: 'stderr', text: chunk })
     })
+    // Deliver one signal to the entire gate tree: the negative pid targets the
+    // POSIX process group the detached child leads; Windows has no groups, so
+    // taskkill walks the tree rooted at the child and force-terminates (a
+    // taskkill without `/F` does not terminate console processes, which is
+    // what gate commands are). Outcomes are deliberately unchecked because
+    // delivery races tree exit, and a missing taskkill binary is as tolerable
+    // as ESRCH. Mirrors the subprocess package's teardown contract
+    // (packages/subprocess/subprocess-local/src/spawn.ts).
+    const treeKill = (signalToSend: 'SIGTERM' | 'SIGKILL') => {
+      const pid = child.pid
+      if (pid === undefined) return
+      if (process.platform === 'win32') {
+        for (const args of taskkillArgs(pid, descendants)) {
+          spawnSync('taskkill', args, { stdio: 'ignore' })
+        }
+        return
+      }
+      try {
+        process.kill(-pid, signalToSend)
+      } catch {
+        // The group is gone; the direct child may still be alive alone.
+        child.kill(signalToSend)
+      }
+      // The captured list stays valid after the group kill reparents the
+      // detached descendants of a nested run-gates (the `check:node-compat`
+      // and `check:ci:lint:contracts-ready` gates in ci-consumers): pids do
+      // not change on reparenting, so the escalation reaches leaves that
+      // ignored SIGTERM without re-enumerating.
+      for (const descendantPid of descendants) {
+        try {
+          process.kill(descendantPid, signalToSend)
+        } catch {
+          // The descendant exited between the enumeration and the signal.
+        }
+      }
+    }
+    let escalation: ReturnType<typeof setTimeout> | undefined
+    let terminatedAt = 0
+    // Captured once at terminate and re-signalled on escalation: the group
+    // kill reaps the direct child, after which its detached descendants are
+    // reparented and unreachable by parent id, so the escalation cannot
+    // re-enumerate them.
+    let descendants: number[] = []
+    let pipeDrain: ReturnType<typeof setTimeout> | undefined
+    const terminate = () => {
+      aborted = true
+      const pid = child.pid
+      // Merge while the child is still alive: re-enumerating alone would drop
+      // a descendant that an exited intermediate reparented out of the parent
+      // chain, and replacing the list entirely would lose the sampler's
+      // last-known entries when the child already exited. Union preserves both.
+      // The sampler runs on every platform (including Windows, where an
+      // exited intermediate's table record vanishes and a fresh enumeration
+      // cannot cross the gap), so the cache is the source of truth once the
+      // child is gone.
+      if (pid !== undefined && child.exitCode === null && child.signalCode === null) {
+        descendants = [...new Set([...descendants, ...descendantPids(pid)])]
+      }
+      treeKill('SIGTERM')
+      if (escalation === undefined) {
+        terminatedAt = Date.now()
+        // Force-kill at the deadline regardless of the direct child's exit
+        // state: when the wrapper dies but a grandchild ignores SIGTERM and
+        // still holds the stdio pipes, `close` has not fired and the tree must
+        // still be killed. treeKill swallows an already-absent group.
+        escalation = setTimeout(() => { treeKill('SIGKILL') }, 5000)
+      }
+      if (pipeDrain === undefined) {
+        // `close` can stay pending past the direct child's exit when a
+        // descendant holds the stdio write ends (escaped process group, or
+        // uninterruptible I/O that keeps the SIGKILL pending). Bound the wait
+        // past the 5-second SIGKILL grace and force the streams closed so
+        // fail-fast settles instead of hanging to the job timeout. Only the
+        // abort path arms it: on an ordinary run a gate that outlives its
+        // descendants must keep waiting rather than report passed over a live
+        // leak. Armed in terminate (not only at `exit`) so the window where
+        // the child already exited before the abort is covered too.
+        pipeDrain = setTimeout(() => {
+          child.stdout.destroy()
+          child.stderr.destroy()
+          child.stdin.destroy()
+        }, 10000)
+      }
+    }
+    if (signal !== undefined) {
+      if (signal.aborted) terminate()
+      else signal.addEventListener('abort', terminate, { once: true })
+    }
+    // Refresh the descendant cache while the child runs, so an abort that
+    // arrives after the child already exited can still reach a detached
+    // descendant the child left behind: once the child is gone, its
+    // descendants are reparented (POSIX) or their intermediate's table record
+    // is gone (Windows), so a fresh enumeration cannot cross the gap. The
+    // cache is primed at spawn and refreshed every 5 seconds, so a descendant
+    // is captured once it appears in any enumeration whose parent chain is
+    // still fully present in the table; the residual window is a descendant
+    // that never appears in such a snapshot — created after one enumeration
+    // and orphaned before the next. Enumeration is asynchronous (a slow
+    // WMI/CIM call is bounded by its own 10-second timeout), so a gate's
+    // output draining and exit handling are never blocked while the sampler
+    // reads the process table. Fail-fast runs only; ordinary runs never
+    // abort.
+    let descendantSampler: ReturnType<typeof setInterval> | undefined
+    if (signal !== undefined) {
+      let enumerationInFlight: { cancel: () => void } | undefined
+      const refreshDescendants = () => {
+        const pid = child.pid
+        if (pid === undefined || child.exitCode !== null || child.signalCode !== null) return
+        if (enumerationInFlight !== undefined) return
+        const handle = descendantPidsAsync(pid, process.platform)
+        enumerationInFlight = handle
+        void handle.promise.then((fresh) => {
+          if (enumerationInFlight === handle) enumerationInFlight = undefined
+          // Merge regardless of the child's exit state: the enumeration
+          // started while the child was alive, so its snapshot is the last
+          // reliable view of the tree. The child may exit (its intermediate
+          // gone, its table record vanished) before the promise settles while
+          // a grandchild still holds the stdio write ends and keeps `close`
+          // pending — exactly when terminate needs this list.
+          // Merge instead of replacing, like terminate: an intermediate that
+          // exited since the last tick reparented its detached descendants
+          // out of the parent chain, so a fresh enumeration alone would drop
+          // them. Filter the cache to the still-executing so a long gate
+          // does not accumulate stale pids; while sampler ticks still run the
+          // live filter also keeps the escalation from signalling a reused
+          // pid, but once ticks stop (child exited) the cache can go stale,
+          // and a pid reused after that is the accepted sampling window.
+          descendants = [...new Set([...descendants.filter(processAlive), ...fresh])]
+        })
+      }
+      const cancelInFlightEnumeration = () => {
+        if (enumerationInFlight !== undefined) enumerationInFlight.cancel()
+        enumerationInFlight = undefined
+      }
+      refreshDescendants()
+      descendantSampler = setInterval(refreshDescendants, 5000)
+      // A gate that settles while an enumeration is still running must not
+      // leave the PowerShell subprocess holding stdio handles until its own
+      // timeout: stop it as soon as the child's outcome is known.
+      child.once('close', cancelInFlightEnumeration)
+      child.once('error', cancelInFlightEnumeration)
+    }
     child.on('error', (error) => {
+      if (escalation !== undefined) clearTimeout(escalation)
+      if (pipeDrain !== undefined) clearTimeout(pipeDrain)
+      if (descendantSampler !== undefined) clearInterval(descendantSampler)
+      if (signal !== undefined) signal.removeEventListener('abort', terminate)
       spawnError = `failed to start command: ${error.message}`
       resolveExit({ exitCode: null, signalCode: null })
     })
     child.on('close', (exitCode, signalCode) => {
+      if (pipeDrain !== undefined) clearTimeout(pipeDrain)
+      if (descendantSampler !== undefined) clearInterval(descendantSampler)
+      if (signal !== undefined) signal.removeEventListener('abort', terminate)
+      if (escalation !== undefined && process.platform !== 'win32') {
+        // `close` only means the direct child's stdio closed; a grandchild
+        // that ignored SIGTERM and redirected its stdio can outlive it. Do
+        // not settle until the process group and the captured descendants are
+        // confirmed gone — the deadline SIGKILL covers members still alive at
+        // the grace end — so runGate returns only once the tree is quiescent.
+        const confirmGroupGone = () => {
+          if (!groupAlive(child.pid) && descendants.every(descendantPid => !processAlive(descendantPid))) {
+            clearTimeout(escalation)
+            resolveExit({ exitCode, signalCode })
+            return
+          }
+          if (Date.now() - terminatedAt < 8000) {
+            setTimeout(confirmGroupGone, 50)
+            return
+          }
+          // The grace ended with members still alive (e.g. uninterruptible
+          // I/O that even SIGKILL cannot cut). Fail loud instead of reporting
+          // a quiescent tree: the gate is recorded failed either way.
+          console.error(`run-gates: gate tree not quiescent after 8s (${gate.label}).`)
+          clearTimeout(escalation)
+          resolveExit({ exitCode, signalCode })
+        }
+        confirmGroupGone()
+        return
+      }
+      if (escalation !== undefined) clearTimeout(escalation)
       resolveExit({ exitCode, signalCode })
     })
     child.stdin.end()
@@ -968,10 +1261,255 @@ export async function runGate(gate: Gate): Promise<GateResult> {
     exitCode,
     signalCode,
   }
+  result.aborted = aborted
   if (spawnError !== undefined) result.error = spawnError
   return result
 }
 
+/**
+ * Parse the state, parent, and process-group fields from a `/proc/<pid>/stat`
+ * line. The comm field may contain spaces and parentheses, so the state starts
+ * after the last closing parenthesis.
+ * @param stat - one `/proc/<pid>/stat` line.
+ * @returns state, parent pid, and process-group pid; undefined when truncated.
+ */
+function procStatFields(stat: string): { state: string; ppid: number; pgrp: number } | undefined {
+  const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
+  const state = fields[0]
+  const ppid = fields[1]
+  const pgrp = fields[2]
+  if (state === undefined || ppid === undefined || pgrp === undefined) return undefined
+  return { state, ppid: Number(ppid), pgrp: Number(pgrp) }
+}
+
+/**
+ * Whether one process is still executing. Zombies (state `Z`) do not count:
+ * they are dead records awaiting reaping, and kill(pid, 0) would report them
+ * as alive. Linux reads /proc/<pid>/stat to distinguish; other platforms fall
+ * back to the signal probe.
+ * @param pid - the process to probe.
+ */
+function processAlive(pid: number): boolean {
+  if (process.platform === 'linux') {
+    try {
+      const parsed = procStatFields(readFileSync(`/proc/${pid}/stat`, 'utf8'))
+      return parsed !== undefined && parsed.state !== 'Z'
+    } catch {
+      return false
+    }
+  }
+  try {
+    process.kill(pid, 0)
+    return true
+  } catch {
+    return false
+  }
+}
+
+/**
+ * Whether any member of the child's POSIX process group is still executing.
+ * Zombie entries (state `Z`) do not count: they are dead records awaiting
+ * reaping, and the kill(-pid, 0) group probe would report them as alive.
+ * Linux enumerates /proc to distinguish after a fast-path group probe; other
+ * POSIX platforms fall back to the probe alone.
+ * @param pid - the group leader's pid; undefined or non-positive means the
+ * spawn failed and nothing is alive.
+ */
+function groupAlive(pid: number | undefined): boolean {
+  if (pid === undefined || pid <= 0) return false
+  if (process.platform === 'linux') {
+    try {
+      process.kill(-pid, 0)
+    } catch {
+      // ESRCH: the group has no entries at all.
+      return false
+    }
+    try {
+      for (const entry of readdirSync('/proc')) {
+        if (!/^\d+$/.test(entry)) continue
+        try {
+          const parsed = procStatFields(readFileSync(`/proc/${entry}/stat`, 'utf8'))
+          if (parsed !== undefined && parsed.pgrp === pid && parsed.state !== 'Z') return true
+        } catch {
+          // The process exited mid-scan; it is not a live member.
+        }
+      }
+      return false
+    } catch {
+      return false
+    }
+  }
+  try {
+    process.kill(-pid, 0)
+    return true
+  } catch {
+    return false
+  }
+}
+
+/**
+ * The pids of every transitive descendant of `root`, read from the live
+ * process table. Linux walks /proc/<pid>/stat parent fields; other platforms
+ * parse `ps` (POSIX) or the CIM process table (Windows) output. This is one
+ * snapshot, not the full tree-ownership mechanism: terminate and the sampler
+ * rely on the 5-second cache to cross an intermediate that exited between
+ * ticks (reparented on POSIX, table record gone on Windows), so a single
+ * enumeration reaches only the descendants whose parent chain is still fully
+ * present in the table.
+ * @param root - the pid whose descendants are wanted.
+ * @returns descendant pids in breadth-first order; empty on enumeration failure.
+ */
+function descendantPids(root: number): number[] {
+  if (root <= 0) return []
+  if (process.platform === 'linux') {
+    const rows: Array<[number, number]> = []
+    try {
+      for (const entry of readdirSync('/proc')) {
+        if (!/^\d+$/.test(entry)) continue
+        try {
+          const parsed = procStatFields(readFileSync(`/proc/${entry}/stat`, 'utf8'))
+          if (parsed !== undefined) rows.push([Number(entry), parsed.ppid])
+        } catch {
+          // The process exited mid-scan; skip it.
+        }
+      }
+    } catch {
+      return []
+    }
+    return collectDescendants(root, rows)
+  }
+  let ps: { error?: Error; stdout: string }
+  if (process.platform === 'win32') {
+    // taskkill /T covers the tree only while the root is alive; once the
+    // direct child exits (a descendant still holding the stdio write ends
+    // keeps `close` pending), abort must reach the survivors from a fresh
+    // enumeration. Windows keeps the exited parent's pid in its descendants'
+    // parent column, so this walk still finds the whole tree. A hung
+    // PowerShell (WMI/CIM service trouble) must not stall the abort path
+    // indefinitely, so the enumeration is bounded.
+    ps = spawnSync('powershell', processTableArgs('win32'), { encoding: 'utf8', timeout: 10000 })
+  } else {
+    ps = spawnSync('ps', processTableArgs('posix'), { encoding: 'utf8' })
+  }
+  if (ps.error !== undefined) return []
+  return collectDescendants(root, parsePidPpidLines(ps.stdout))
+}
+
+/**
+ * The process-table enumeration command for one platform. Windows queries the
+ * CIM provider through PowerShell (each line `pid ppid`); other platforms use
+ * `ps -axo pid=,ppid=`.
+ * @param platform - the target platform.
+ * @returns the command arguments to enumerate every live process's pid/ppid.
+ */
+function processTableArgs(platform: 'win32' | 'posix'): string[] {
+  if (platform === 'win32') {
+    return ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }']
+  }
+  return ['-axo', 'pid=,ppid=']
+}
+
+/**
+ * Asynchronous descendant enumeration, so a slow WMI/CIM call (bounded by a
+ * 10-second timeout) cannot block the event loop: the sampler runs it while
+ * the gate's output streams and exit handling must keep flowing. Returns the
+ * same descendant list as {@link descendantPids}; used by the fail-fast
+ * sampler only, never on the abort path (which needs the synchronous walk to
+ * capture the tree before any member exits).
+ * @param root - the pid whose descendants are wanted.
+ * @param platform - the platform whose table the enumeration reads.
+ * @returns a promise of descendant pids in breadth-first order; empty on
+ * enumeration failure.
+ */
+function descendantPidsAsync(root: number, platform: NodeJS.Platform): { promise: Promise<number[]>; cancel: () => void } {
+  if (root <= 0 || platform === 'linux') {
+    // The /proc walk is synchronous inside the async wrapper so the sampler
+    // keeps the same contract on every platform; /proc reads are fast and
+    // need no subprocess, and a completed enumeration needs no cancellation.
+    return { promise: Promise.resolve(descendantPids(root)), cancel: () => {} }
+  }
+  const [command, args] = platform === 'win32'
+    ? ['powershell', processTableArgs('win32')]
+    : ['ps', processTableArgs('posix')]
+  const child = spawn(command, args, {
+    stdio: ['ignore', 'pipe', 'ignore'],
+    timeout: platform === 'win32' ? 10000 : undefined,
+  })
+  child.stdout.setEncoding('utf8')
+  let stdout = ''
+  let settled = false
+  let settle!: (value: number[]) => void
+  const promise = new Promise<number[]>((resolve) => { settle = resolve })
+  const finish = (value: number[]) => {
+    if (settled) return
+    settled = true
+    // The enumeration completed (or was cancelled): stop the subprocess so
+    // the gate does not wait on its stdio handles.
+    child.kill('SIGTERM')
+    settle(value)
+  }
+  child.stdout.on('data', (chunk: string) => { stdout += chunk })
+  child.on('error', () => { finish([]) })
+  child.on('close', () => { finish(collectDescendants(root, parsePidPpidLines(stdout))) })
+  return {
+    promise,
+    cancel: () => { finish([]) },
+  }
+}
+
+/** Parse `pid ppid` rows from a process-table dump. Both the POSIX `ps -axo
+ * pid=,ppid=` output and the Windows PowerShell `Get-CimInstance Win32_Process`
+ * projection emit one `pid ppid` pair per line.
+ * @param output - the raw dump text.
+ * @returns the parsed pid/ppid rows in line order; blank and malformed lines
+ * are dropped.
+ */
+export function parsePidPpidLines(output: string): Array<[number, number]> {
+  const rows: Array<[number, number]> = []
+  for (const line of output.split('\n')) {
+    const match = line.trim().match(/^(\d+)\s+(\d+)$/)
+    if (match !== null) rows.push([Number(match[1]), Number(match[2])])
+  }
+  return rows
+}
+
+/**
+ * The taskkill invocations that terminate one Windows gate tree. The direct
+ * child leads, because a live `taskkill /T` walks its whole subtree in one
+ * call; each captured descendant follows individually, because when the root
+ * already exited (a descendant holding the stdio write ends keeps `close`
+ * pending) `taskkill /T` rooted at the dead pid finds nothing — Windows never
+ * reparents, so the ppid chain captured at terminate still reaches the whole
+ * tree, and `/T` lets a surviving intermediate carry its own subtree. A pid
+ * that exited between capture and termination is as tolerable as ESRCH on
+ * POSIX: taskkill reports a nonzero status that is deliberately unchecked.
+ * @param rootPid - the direct child's pid.
+ * @param descendants - the captured descendant pids.
+ * @returns one `taskkill` argument list per pid, in termination order.
+ */
+export function taskkillArgs(rootPid: number, descendants: number[]): string[][] {
+  return [rootPid, ...descendants].map(pid => ['/PID', String(pid), '/T', '/F'])
+}
+
+/** Breadth-first walk of the pid/ppid rows starting at `root`. */
+function collectDescendants(root: number, rows: Array<[number, number]>): number[] {
+  const byParent = new Map<number, number[]>()
+  for (const [pid, ppid] of rows) {
+    const children = byParent.get(ppid) ?? []
+    children.push(pid)
+    byParent.set(ppid, children)
+  }
+  const result: number[] = []
+  const queue = byParent.get(root) ?? []
+  for (let index = 0; index < queue.length; index += 1) {
+    const pid = queue[index]
+    if (pid === undefined) continue
+    result.push(pid)
+    queue.push(...(byParent.get(pid) ?? []))
+  }
+  return result
+}
+
 /**
  * Format every independently observed failure fact for the aggregate summary.
  * @param result - unsuccessful gate result.

Some files were not shown because too many files changed in this diff