doxa-maps/internal/run-parallel.mjs
doxa-maps-88655c70 a361da0a2e
Some checks are pending
build-gate / build (push) Waiting to run
gate wave: a SKIP is not a pass, and this printed them identically
run-parallel grades on the exit code, and a gate that declines to evaluate
exits 0. So "35/35 passed" read the same whether 35 gates graded something or
31 did, and the summary line — the one people quote — was the most misleading
part of the output.

This is the generalisation of the packet-sweep fix two commits ago. That closed
ONE always-skipping gate; the reason nobody could see it for months was that the
runner reported it green. 18 of the 35 steps in gates:post CAN skip, every one
on a precondition a fresh checkout does not have, so the gap is structural
rather than specific to packets.

Measured on a genuinely fresh checkout — `git archive HEAD | tar -x`, which is
what CI sees. Before: "31/35 passed". After: "28/35 passed, 4 SKIPPED", naming
check:packets-all, check:zone-census, check:done-evidence, check:backlog-status.

All four are legitimate, and I checked rather than assumed. packets-all skips
until packet:ci runs, which CI does first. zone-census needs generated posters.
The two backlog gates read _qa-cycle, which lives outside any repo by the
deliberate decision that PM markdown never sits inside a code repo — and both
already say so in their own output. So the finding is the REPORTING, not a
broken gate.

Exit semantics are deliberately unchanged, and verified: a wave whose only step
skips still exits 0. Failing on a SKIP would fail every CI build on the backlog
gates and teach people to disable the reporting.

The detector is mutation-tested against seven real output shapes. It matches
"ZONE-CENSUS: SKIP — …" and the indented variant, and correctly does not match
prose mentioning skip, nor "SHARE-POLICY: PASS — 12 buttons, 0 skipped".

A correction to my own method along the way: I first censused skips in
doxa-maps-plugin-studio as a proxy for a fresh checkout. It is not one — it sits
many commits behind main with a 21-step gates:post that lacks 14 of the steps
main has, so the two censuses were of different waves. The fresh-archive test
replaced it.

Also documents check:zone-census's precondition, the one conditional gate in the
wave that had none — including why it cannot be fixtured the way packets-all
was: it asserts coverage of real generated output, so a fixture would only
assert that the fixture is complete.
2026-09-24 14:50:05 +00:00

186 lines
8.8 KiB
JavaScript

#!/usr/bin/env node
/**
* run-parallel.mjs — runs a SET OF INDEPENDENT verification steps concurrently instead of
* one-at-a-time, and grades the set by exit code.
*
* WHY (measured 2026-09-23, on this box, `bun run` per step, load avg ~20):
* The 29 gates in build:unlocked cost 84.5s wall when chained with `&&`, but 21 of them
* finish in under 700ms — they are node-process-startup, not work. The chain's cost is
* the SUM of the steps while the machine sits on 7 idle cores. The two dominators
* (check:mjs-parse 26.9s, check:parse 18.7s) alone are 54% of the total, so every cheap
* gate behind them is pure queueing delay. Same shape for the 22 vite builds: 129.0s
* sequential, each writing to its OWN public/doxa-maps/<bundle>/ (vite.config.js sets
* outDir per bundle, emptyOutDir:false, and emits nothing outside it) — so they were
* never actually ordered, only serialized.
*
* ★ COVERAGE IS NOT REDUCED, AND THAT IS THE POINT. Every step that ran before still runs,
* as the same command, graded by the same exit code. Nothing is skipped, sampled,
* cached, or made conditional on what changed. The only thing that changes is WHEN each
* step starts. A speed-up that drops a gate is not a speed-up, it is a coverage cut with
* a stopwatch attached.
*
* ★ WHY IT REPORTS ALL FAILURES INSTEAD OF STOPPING AT THE FIRST
* An `&&` chain hides every failure after the first, so a build with three broken gates
* costs three full fix-and-rerun cycles to discover. Within one phase the steps are
* independent, so there is no reason to stop: this runs the whole set, prints every
* failure's full output, and exits 1. Ordering that DOES matter is still enforced —
* build:unlocked keeps `&&` BETWEEN phases, so a source gate still blocks the build, and
* the build still blocks the gates that read its output.
*
* ★ WHY THERE IS NO TIMEOUT HERE
* AGENT-BRIEF.md: "A command killed by a short `timeout` is not a failure." A runner that
* imposed its own deadline would manufacture failures under exactly the load that makes
* parallelism worth having. Steps run to completion; the caller decides.
*
* ★ WHY PASSING OUTPUT IS SUPPRESSED (and --verbose exists)
* These gates print teaching prose AFTER their verdict. Interleaving 20 of those streams
* produces something no one can read, and the brief already warns that `| tail -1`
* inverts the signal. So: passing steps report one table line, failing steps report their
* ENTIRE captured output, unabridged. --verbose prints everything.
*
* USAGE
* node internal/run-parallel.mjs --label=pre -- check:a check:b ... # runs `bun run <name>`
* node internal/run-parallel.mjs --bundles # BUNDLE=<d> bun x vite build, for every plugin-designs/<d>/
* env: GATES_CONCURRENCY (default min(cpus,6)) · BUILD_CONCURRENCY (default 4)
*/
import { spawn } from 'node:child_process'
import { readdirSync, existsSync } from 'node:fs'
import { cpus } from 'node:os'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const VERBOSE = argv.includes('--verbose')
const BUNDLES = argv.includes('--bundles')
const labelArg = argv.find((a) => a.startsWith('--label='))
const LABEL = labelArg ? labelArg.slice('--label='.length) : BUNDLES ? 'bundles' : 'gates'
// everything after `--` (or every bare non-flag arg) is a step name
const dashdash = argv.indexOf('--')
const names = (dashdash === -1 ? argv : argv.slice(dashdash + 1)).filter((a) => !a.startsWith('--'))
/**
* WHY TWO DIFFERENT DEFAULTS: a gate is one short-lived node process (startup-bound, ~cheap
* RAM), while a vite build holds a whole rollup graph in memory. Measured on this box:
* 15GB total with ~6GB available while other agents run browsers, and a vite build peaks
* in the hundreds of MB — so 22-wide would swap the machine to death and report it as a
* slow build. 4-wide is ~2GB and leaves the box usable for everyone else on it.
*/
const CONCURRENCY = Math.max(
1,
Number(
BUNDLES
? process.env.BUILD_CONCURRENCY || 4
: process.env.GATES_CONCURRENCY || Math.min(cpus().length, 6)
) || 1
)
/** @returns {{name:string, cmd:string, args:string[], env:Record<string,string>}[]} */
function buildSteps() {
if (!BUNDLES) {
if (!names.length) {
console.error('run-parallel: no steps given. Pass script names after `--`.')
process.exit(2)
}
return names.map((name) => ({ name, cmd: 'bun', args: ['run', name], env: {} }))
}
// A bundle is a plugin-designs/<name>/ directory — the same selector build:unlocked used
// (`for d in plugin-designs/*/`), so the SET of things built is identical.
const dir = resolve(ROOT, 'plugin-designs')
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort()
.map((name) => ({ name, cmd: 'bun', args: ['x', 'vite', 'build'], env: { BUNDLE: name } }))
}
function runStep(step) {
return new Promise((done) => {
const started = Date.now()
const child = spawn(step.cmd, step.args, {
cwd: ROOT,
env: { ...process.env, ...step.env },
stdio: ['ignore', 'pipe', 'pipe'],
})
let out = ''
const cap = (buf) => {
out += buf
}
child.stdout.on('data', cap)
child.stderr.on('data', cap)
child.on('error', (err) => {
// spawn itself failed (missing binary) — a real failure, not a gate verdict
done({ ...step, ms: Date.now() - started, code: 127, out: String(err && err.message) })
})
child.on('close', (code) => done({ ...step, ms: Date.now() - started, code: code ?? 1, out }))
})
}
async function main() {
const steps = buildSteps()
const t0 = Date.now()
console.log(
`▶ ${LABEL}: ${steps.length} step(s), ${CONCURRENCY} at a time ` +
`(${cpus().length} cpus; override with ${BUNDLES ? 'BUILD_CONCURRENCY' : 'GATES_CONCURRENCY'})`
)
const queue = steps.slice()
const results = []
const worker = async () => {
for (;;) {
const step = queue.shift()
if (!step) return
const r = await runStep(step)
results.push(r)
// ★ A SKIP IS NOT A PASS, AND THIS PRINTED THEM IDENTICALLY. The grade is the exit
// code, and a gate that declines to evaluate exits 0 — so "35/35 passed" read the
// same whether 35 gates graded something or 17 did. MEASURED 2026-09-24: 18 of the
// 35 steps in gates:post CAN skip, every one of them on a precondition (a served
// tree, generated posters, a built manifest) that a fresh checkout does not have.
// check:packets-all SKIPped on every CI build for exactly this reason and nobody
// could see it, because the wave reported it green. An always-taken SKIP is
// indistinguishable from an unwired gate unless the runner says so.
const skipped = /(^|\n)\s*[A-Z][A-Z0-9-]*:\s*SKIP\b/.test(r.out)
console.log(
` ${r.code !== 0 ? '✗' : skipped ? '⊘' : '✓'} ${r.name.padEnd(26)} ${String(r.ms).padStart(7)}ms` +
(r.code === 0 ? '' : ` exit ${r.code}`)
)
r.skipped = skipped
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, steps.length) }, worker))
const wall = Date.now() - t0
const serial = results.reduce((a, r) => a + r.ms, 0)
const failed = results.filter((r) => r.code !== 0)
if (VERBOSE) for (const r of results) if (r.out.trim()) console.log(`\n───── ${r.name} ─────\n${r.out}`)
// A failure must be readable in full: these gates explain themselves in their output, and
// a truncated explanation is how a real defect gets re-diagnosed as a flake.
else for (const r of failed) console.log(`\n───── FAILED: ${r.name} (exit ${r.code}) ─────\n${r.out}`)
// COUNT THE SKIPS IN THE SUMMARY, because the summary is the line a reader quotes and
// the one that lands in a report. "35/35 passed" was true of a wave where a third of the
// gates declined to look at anything.
const skips = results.filter((r) => r.skipped)
console.log(
`\n▶ ${LABEL}: ${results.length - failed.length - skips.length}/${results.length} passed` +
(skips.length ? `, ${skips.length} SKIPPED` : '') +
` in ${(wall / 1000).toFixed(1)}s wall ` +
`(${(serial / 1000).toFixed(1)}s of work — ${(serial / Math.max(wall, 1)).toFixed(1)}x)`
)
if (skips.length) {
console.log(` ⊘ evaluated nothing: ${skips.map((s) => s.name).join(', ')}`)
console.log(' Each declined on a precondition it names in its own output (run it alone to see).')
}
if (failed.length) {
console.error(`✗ ${LABEL}: ${failed.length} step(s) FAILED — ${failed.map((f) => f.name).join(', ')}`)
process.exit(1)
}
}
main().catch((err) => {
console.error('run-parallel: crashed —', err)
process.exit(1)
})