doxa-maps/internal/_serves-this-tree.mjs
doxa-maps-88655c70 9acd4f8865 Queue the browser gates, and let them serve their own tree
Two problems, one change.

LOAD. Seven agents each running a wave put 7+ chrome-headless renders on one box;
measured today it reached load 47 with 8GB of swap, and a browser gate that is
swapping measures the swap, not the product. The wave runner now holds browser
steps while 1-min load is at or above LOAD_MAX (12) and takes a coarse flock
before each one, so they queue instead of dogpiling. Thresholds, cadence and the
20-minute cap are bundler-3's wait-for-quiet.sh verbatim, and BROWSER_GATE_LOCK
selects the rendezvous, so every agent lands on one lock whichever entry point
they use. Static gates are untouched: in gates:post that is 38 free, 4 queued.

The load wait is taken ONCE per wave, not once per gate. Per-step it multiplied
the same hold by the number of browser gates — an hour of a wave doing nothing,
which is how a load fix becomes a reason to stop running gates. The flock is
what serializes; the wait only decides when to join the queue.

Browser steps are identified from the gate FILE (a playwright import), not a
hand-kept list that goes stale the first time someone adds a gate — the one
missing from such a list is exactly the one that rejoins the dogpile. `verify` is
caught too: it imports no playwright itself but spawns three gates that render,
and it already marks them browser:true, so that declaration is honoured rather
than duplicated.

COVERAGE. Yesterday's guard made check:provider-switch and check:packet-browser
SKIP in every wave, because their default ports (4173, 4898) belong to other
worktrees on this machine. Skipping was honest but a gate that skips every time
is a gate nobody runs, so on a mismatch they now start a short-lived server over
THIS checkout and measure that instead — the point was never the port, it was
being able to prove which build answered.

MEASURED: a served tree whose manifest differs by one key but has all the same
pages -> "serves a DIFFERENT build", then "serving this checkout on :28384
instead", then a real verdict (16 -> 5 mapbox requests). packet-browser likewise,
once it re-navigates to the new origin — a dynamic import across origins is
refused by a plain static server, which cost one failed run to learn.
gates:post 40/42, the only skips being the two that genuinely have no input
(packets-all, zone-census); it was 4 before this.

The lock path default is product-named. The shared file several shells export
carries an internal code name, and check:no-codename refuses to let a repo we
hand over carry one — it caught this commit's first draft.
2026-09-24 17:43:32 +00:00

87 lines
4.3 KiB
JavaScript

/**
* _serves-this-tree.mjs — a browser gate must prove the server it talks to is THIS build.
*
* THE FAILURE THIS EXISTS FOR, MEASURED. Browser gates here default to
* http://127.0.0.1:4173 and adopt whatever answers 200 on it. On this machine that port was
* held by an unrelated process belonging to a different worktree, and the gospel-resources
* gate spent three runs reporting on a build that was not the one on disk — first a PASS,
* then a mount timeout that looked exactly like a regression in code that had nothing to do
* with it. A gate that can report on the wrong tree is worse than no gate: it produces
* confident findings about code nobody changed, and hides real ones.
*
* "Answers 200" and "is serving this checkout" are different questions. This asks the second.
*
* THE FINGERPRINT is public/doxa-maps/manifest.json, which generate-tree rewrites on every
* build and which names every bundle and profile in the tree. If the bytes served differ
* from the bytes on disk, it is a different build — a different worktree, or a stale server
* someone forgot to restart, both of which produce the same lie.
*
* Deliberately NOT fatal on its own: the caller decides whether to start its own server
* instead, or to skip. What it must never do is quietly continue.
*/
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { resolve } from 'node:path'
const MANIFEST = 'doxa-maps/manifest.json'
/**
* @param {string} base e.g. http://127.0.0.1:4173 (a trailing /doxa-maps/ is tolerated)
* @param {string} repo absolute path to the repo root
* @returns {Promise<{ok:boolean, why:string}>}
*/
export async function servesThisTree(base, repo) {
let local
try {
local = createHash('sha256').update(readFileSync(resolve(repo, 'public', MANIFEST))).digest('hex')
} catch {
return { ok: false, why: `no local public/${MANIFEST} to compare against — build the tree first` }
}
const root = String(base).replace(/\/+$/, '').replace(/\/doxa-maps$/, '')
const r = await fetch(`${root}/${MANIFEST}`).catch(() => null)
if (!r || !r.ok) return { ok: false, why: `nothing serving ${MANIFEST} at ${root}` }
const served = createHash('sha256').update(Buffer.from(await r.arrayBuffer())).digest('hex')
if (served !== local) {
return { ok: false, why: `${root} serves a DIFFERENT build than public/ — another worktree, or a server that needs restarting` }
}
return { ok: true, why: 'same build' }
}
/**
* Start a short-lived static server over THIS checkout, so a gate whose default port belongs
* to another worktree can still MEASURE instead of skipping.
*
* Skipping was the honest thing to do once the mismatch was visible, but a gate that skips on
* every wave is a gate nobody is running: check:provider-switch and check:packet-browser both
* went quiet the moment the guard landed, because their default ports (4173, 4898) are held by
* other worktrees on this box. Serving our own tree costs a few hundred milliseconds and gives
* the gate the one thing it needs — a server it can prove is this build.
*
* @param {string} dir absolute directory to serve (public/, or the repo root)
* @returns {Promise<{origin:string, stop:() => void}|null>} null when no server could be started
*/
export async function serveThisTree(dir) {
const { spawn } = await import('node:child_process')
for (let attempt = 0; attempt < 5; attempt++) {
// An ephemeral high port, re-rolled on collision — two agents doing this at once must not
// fight over one number.
const port = 20000 + Math.floor(Math.random() * 20000)
const child = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1', '--directory', dir],
{ stdio: 'ignore', detached: false })
const origin = `http://127.0.0.1:${port}`
let up = false
for (let i = 0; i < 40 && !up; i++) {
await new Promise((r) => setTimeout(r, 100))
up = await fetch(origin + '/', { signal: AbortSignal.timeout(500) }).then((r) => r.ok).catch(() => false)
}
if (up) {
// unref so a caller that forgets to stop() still exits — a gate that hangs on its own
// helper is a worse failure than the one it was written to catch.
child.unref()
return { origin, stop: () => { try { child.kill() } catch { /* already gone */ } } }
}
try { child.kill() } catch { /* nothing to kill */ }
}
return null
}