Moved out (kept outside the repo, in the team's internal tracker): - AGENT-BRIEF.md: operating rules for the team's own agents - documentation/slide-deck-outline.md, documentation/README-longform.md: internal pitch material; the README and why.md cover the same ground - plugin-designs/gospel-resources/SOURCES-CHECKED-AND-REJECTED.md: research log - .claude/ (an editor status line), .forgejo/ (a CI workflow that never ran), _archive/ (a retired staging page; already gitignored but still tracked) Seven code comments that named these files now describe the rule instead. Fixed five dead links left from the docs/ -> documentation/ move, including the one in template-bundle that every new plugin inherits. The delegation diagram now shows the whole model: the Plugin Studio with the shared library and the merge queue into main, seven production plugins with capability badges and the Experimental shelf, one agent per plugin (a roaming agent for the shelf), the shepherd's oversight ring, a legend, and the five ways a build ships.
262 lines
13 KiB
JavaScript
262 lines
13 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
|
|
)
|
|
|
|
// The rendezvous every agent's browser gate queues on. It must be ONE path for all of them or
|
|
// the queue is not a queue, so it is an ENV VAR with a product-named default: the shared file
|
|
// several agents' shells already export lives outside this repo and carries an internal code
|
|
// name, which check:no-codename correctly refuses to let a handed-over repo carry. Export
|
|
// BROWSER_GATE_LOCK to join whatever rendezvous the rest of the box is using.
|
|
const BROWSER_LOCK = process.env.BROWSER_GATE_LOCK || '/tmp/doxa-maps-browser-gate.lock'
|
|
const LOAD_MAX = Number(process.env.LOAD_MAX || 12)
|
|
const QUIET_WAIT_MAX = Number(process.env.QUIET_WAIT_MAX || 900)
|
|
const HAS_FLOCK = spawnSync('sh', ['-c', 'command -v flock'], { stdio: 'ignore' }).status === 0
|
|
|
|
/**
|
|
* 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.
|
|
return { ...step, browser: true, cmd: 'flock', args: ['-w', '1200', BROWSER_LOCK, 'bun', 'run', name] }
|
|
})
|
|
}
|
|
// 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.
|
|
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)
|
|
})
|