The single rendezvous made every agent's browser proof wait behind one chrome — the queue sat 10-19 deep for hours. Exclusivity was never the real constraint: MEMORY is, at roughly 300-500MB per headless chrome. So the rendezvous becomes a counting semaphore. internal/browser-slot.sh takes the first free slot with flock -n, in a fixed order, and queues on slot 1 only when all are busy. Before taking any slot it requires MemAvailable above a floor, because a free slot on an out-of-memory box is an invitation to be killed mid-measurement — which then reads as a flaky gate rather than as the machine it actually was. Default 2 slots, 2500MB floor, both overridable. It keeps the exit-code contract that the last commit established: 75 is EX_TEMPFAIL, "not measured", distinct from 0 and from any gate's own 1. The runner already reports 75 as ⊘ and excludes it from the failure count. CONTROLS, all fired: two takers both run concurrently a third waits, and takes a slot the moment one frees short wait exits 75 rather than 1, so a full queue is never a red gate floor above RAM exits 75 immediately, naming the MemAvailable it saw inner exit 3 passes through as 3 — the wrapper never rewrites a verdict through a wave two browser gates, 3.5s wall for 3.9s of work: overlapping The legacy single lock is deliberately no longer consulted: taking it alongside the slots would collapse two slots back into the one queue this replaces. Agents' own wrappers should call internal/browser-slot.sh so everyone shares one counter — and it lives in the repo precisely because the old shared path carried a name check:no-codename forbids us to ship.
291 lines
16 KiB
JavaScript
291 lines
16 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
|
|
* House rule: "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, spawnSync } from 'node:child_process'
|
|
import { readdirSync, existsSync, readFileSync } 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
|
|
)
|
|
|
|
// ★ TWO LOCKS, IN A FIXED ORDER, AND THAT IS NOT BELT-AND-BRACES — IT IS A NAME PROBLEM.
|
|
// The rendezvous must be ONE path for everyone or the queue is not a queue. The agents' own probe
|
|
// scripts take a lock whose filename carries an internal code name, and check:no-codename refuses
|
|
// (correctly) to let a repo we are handing over contain that word. So this takes BOTH: the repo's
|
|
// product-named lock, and whatever path the environment names — always in that order, so two
|
|
// processes can never take them in opposite orders and deadlock. Either file alone still
|
|
// serialises everything that uses it; taking both is what makes gates and hand-run probes queue
|
|
// against each other without the repo having to spell a name it must not ship.
|
|
// The slot files live in browser-slot.sh, which owns the rendezvous now. BROWSER_GATE_LOCK from a
|
|
// caller's environment is deliberately NOT consulted here any more: a single legacy lock taken
|
|
// alongside the slots would collapse two slots back into one, which is the queue this replaced.
|
|
// Agents' own wrappers should call internal/browser-slot.sh so everyone shares the same counter.
|
|
// ★ THE LOAD WAIT WAS COSTING MORE THAN IT SAVED. It was written at LOAD_MAX 12 when the risk was
|
|
// seven agents starting chrome at once; the FLOCK below is what actually prevents that — one
|
|
// browser gate at a time, across every agent on the box. The load check is belt-and-braces on top
|
|
// of a belt. MEASURED 2026-09-25 with several agents working: 1-min load sits at 36-50 all day, so
|
|
// 12 is unreachable and every browser gate simply waited out its full 900s cap before running
|
|
// anyway. That is a 15-minute tax per wave for a guard that never opens, and its practical effect
|
|
// is that people stop running browser gates. Raised to a level that still catches a genuinely
|
|
// wedged box, with a short cap so a wave is never held for long. Both still override by env.
|
|
// Scaled to the machine rather than a constant: a load of 40 means something different on 8 cores
|
|
// than on 64. Five runnable processes per core is busy-but-working; past that the box is thrashing
|
|
// and a browser gate would be measuring the thrash.
|
|
const LOAD_MAX = Number(process.env.LOAD_MAX || cpus().length * 5)
|
|
const QUIET_WAIT_MAX = Number(process.env.QUIET_WAIT_MAX || 120)
|
|
const HAS_FLOCK = spawnSync('sh', ['-c', 'command -v flock'], { stdio: 'ignore' }).status === 0
|
|
const LOCK_WAIT_S = Number(process.env.BROWSER_LOCK_WAIT || 900)
|
|
// EX_TEMPFAIL. Anything but 0 or 1, so it cannot be confused with a gate's own verdict.
|
|
const LOCK_BUSY_CODE = 75
|
|
|
|
/**
|
|
* Does this step drive a real browser? Asked of the GATE FILE, not of a hand-kept list:
|
|
* a list goes stale the first time someone adds a gate, and a browser gate missing from it
|
|
* is exactly the one that rejoins the dogpile. package.json names the file; the file says
|
|
* whether it imports playwright.
|
|
*/
|
|
function isBrowserGate(name) {
|
|
try {
|
|
const scripts = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')).scripts || {}
|
|
const m = String(scripts[name] || '').match(/([\w./-]+\.mjs)/)
|
|
if (!m) return false
|
|
const file = resolve(ROOT, m[1])
|
|
if (!existsSync(file)) return false
|
|
const src = readFileSync(file, 'utf8')
|
|
// Two ways a step can drive chrome: it launches one itself, or it is a RUNNER that
|
|
// launches gates which do. `verify` is the second kind — verify-all.mjs imports no
|
|
// playwright and would have been graded static, while three of the gates it spawns
|
|
// (posters, app-tables, map-paint) render pages. It already marks those `browser: true`
|
|
// for its own reasons, so that declaration is honoured here rather than duplicated.
|
|
return /from 'playwright'|require\('playwright'\)|chromium/.test(src) || /browser:\s*true/.test(src)
|
|
} catch { return false }
|
|
}
|
|
|
|
/**
|
|
* Hold a browser gate back while the box is already overloaded. Same threshold and cadence as
|
|
* the shared wait-for-quiet.sh, so agents behave identically whichever entry point they use.
|
|
* It PROCEEDS after the cap rather than failing: a gate that refuses to run is a coverage
|
|
* loss, and load is a property of the machine, not of the code under test.
|
|
*/
|
|
let quietOnce = null
|
|
async function waitForQuiet(log) {
|
|
// ONCE PER WAVE, not once per gate. The flock already guarantees one browser at a time, so
|
|
// a per-step load wait just multiplies the same hold by the number of browser gates — with
|
|
// four of them and a 900s cap that is an hour of a wave doing nothing on a busy box, which
|
|
// is how a load fix turns into a reason to stop running gates.
|
|
if (quietOnce) return quietOnce
|
|
quietOnce = (async () => {
|
|
let waited = 0
|
|
for (;;) {
|
|
const load = Number(String(readFileSync('/proc/loadavg', 'utf8')).split(' ')[0])
|
|
if (!Number.isFinite(load) || load < LOAD_MAX) return
|
|
if (waited >= QUIET_WAIT_MAX) {
|
|
log(` · load ${load} still above ${LOAD_MAX} after ${waited}s — proceeding anyway`)
|
|
return
|
|
}
|
|
log(` · load ${load} >= ${LOAD_MAX}, holding browser gates (${waited}s)`)
|
|
await new Promise((r) => setTimeout(r, 15000))
|
|
waited += 15
|
|
}
|
|
})()
|
|
return quietOnce
|
|
}
|
|
|
|
/** @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) => {
|
|
const step = { name, cmd: 'bun', args: ['run', name], env: {} }
|
|
if (!isBrowserGate(name) || !HAS_FLOCK) return step
|
|
// ★ ONE CHROME AT A TIME, ACROSS EVERY AGENT ON THIS BOX. Seven agents each running a
|
|
// wave of their own put 7+ chrome-headless renders on one machine at once; measured
|
|
// 2026-09-24, the box hit load 47 with 8GB of swap, and a browser gate that is swapping
|
|
// does not measure the product, it measures the swap. The rendezvous is a COARSE flock
|
|
// on one well-known path that every agent's tooling shares (bundler-3's
|
|
// wait-for-quiet.sh takes the same lock), so this queues rather than dogpiles. -w 1200
|
|
// caps the wait so a crashed holder cannot wedge a wave forever, and static gates —
|
|
// parse, sri, hygiene, the twenty that are node-startup — are left completely free.
|
|
// ★ A TWO-SLOT SEMAPHORE, NOT A MUTEX. One lock made every agent's browser proof wait behind
|
|
// a single chrome — measured 2026-09-25, the queue sat 10-19 deep for hours. The constraint
|
|
// is MEMORY (~300-500MB per headless chrome), not exclusivity, so browser-slot.sh runs as
|
|
// many as the box can hold and refuses to start one when MemAvailable is below the floor.
|
|
// It keeps the same exit-code contract: 75 means "not measured", never "failed".
|
|
const args = [resolve(ROOT, 'internal/browser-slot.sh'), 'bun', 'run', name]
|
|
return { ...step, browser: true, cmd: 'sh', args }
|
|
})
|
|
}
|
|
// 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 } }))
|
|
}
|
|
|
|
async function runStep(step) {
|
|
if (step.browser && existsSync('/proc/loadavg')) await waitForQuiet((m) => console.log(m))
|
|
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.
|
|
// A gate that never ran because the browser lock stayed busy is UNMEASURED, not failed.
|
|
const lockBusy = r.browser && r.code === LOCK_BUSY_CODE
|
|
const skipped = lockBusy || /(^|\n)\s*[A-Z][A-Z0-9-]*:\s*SKIP\b/.test(r.out)
|
|
if (lockBusy) r.out = (r.out || '') + `\n (could not take the browser lock within ${LOCK_WAIT_S}s — not a product finding)`
|
|
console.log(
|
|
` ${skipped ? '⊘' : r.code !== 0 ? '✗' : '✓'} ${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 && !(r.browser && r.code === LOCK_BUSY_CODE))
|
|
|
|
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)
|
|
})
|