Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
A plugin's <profile>.meta.json targets (web, mobile, desktop, widget; absent = web)
now decide where it is built. internal/plugin-targets.mjs is the one reading.
desktop:build builds a desktop plugin into .desktop/<plugin>/ with a page that
names its engine, builds its backend as a Tauri sidecar for this machine, and runs
tauri build with a --config override; tauri.conf.json and public/ are untouched.
The shell starts the bundled rudder-engine (RUDDER_AUDIO=browser, RUDDER_HOME in the
app data folder) and stops it on exit. A plugin whose targets leave out web
(Rudder: desktop + widget) is no longer built for the web tree: run-parallel,
generate-tree and check-composite-freshness skip it, and its public/ folder is
removed; the dev server still gives the Studio its corner. check:targets
(gates:pre, with a self-test of five planted violations) validates the values,
widget corners and backend folders and fails if a non-web plugin reaches the built
tree. Measured: desktop:build rudder-voice on Linux produced Rudder_0.1.0_amd64.deb
(8.8 MB) with the app and the engine side by side; the app has not been launched.
A skipped or unmeasurable gate and a failing one shared one exit code, so real reds hid among skips
(regions measured 15 of 67 red at d3d3c3f8, mixed with gates that simply could not look).
- Exit 75 (EX_TEMPFAIL) from ANY step is NOT MEASURED, as is a gate that exits 0 announcing SKIP or NOT
MEASURED. Only any other non-zero exit is a FAIL. The summary line is
"PASS n · FAIL n · NOT MEASURED n", then the unmeasured by name, then each failure in full.
- Browser gates run only on the browser seat (DOXA_BROWSER=1; the project rule that browsers are one
agent's). Elsewhere they are NOT MEASURED without being started, instead of failing to launch.
- `verify` is graded static: verify-all runs its browser guards only with --full, so the plain step
no longer goes through the browser slot, where it printed "ALL GUARDS PASS (browser guards skipped)"
and still exited non-zero.
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.
Adding the second rendezvous made browser gates queue against every agent's
probes — and then report the queue as a product defect. MEASURED in the first
wave after that change: five browser gates "failed" at exactly 1200064ms, which
is the flock -w 1200 timeout to the millisecond. flock exits 1 on timeout, the
same code a failing gate uses, so `verify`, check:tabs-localized,
check:packet-browser, check:provider-switch and check:composite-embed-knobs all
printed ✗ having never run.
That is the oldest trap in this repo wearing new clothes: a signal that cannot
distinguish "did not run" from "found something". It is the reason gates grade by
exit code rather than by grepping output, and I reintroduced it by adding a lock
without giving its timeout a code of its own.
Both locks now pass -E 75 (EX_TEMPFAIL, distinct from 0 and 1), and the runner
treats that code as UNMEASURED: ⊘ rather than ✗, excluded from the failure count,
with the reason printed next to it. The wave still exits 0, because a lock it
could not get says nothing about the product.
CONTROLS, on isolated lock files so live contention could not confuse them:
nested success → 0
inner command fails → 1 (not swallowed into 75)
lock held by another → 75 → ⊘ "could not take the browser lock within Ns"
and the wave's own exit stays 0 in that last case.
Also fixed the line that disagreed with its own summary: a lock-busy step printed
✗ on its row while the footer counted it as skipped.
TWO LOCKS, FIXED ORDER. Browser gates queued on a product-named lock while every
agent's hand-run probe queued on a differently-named one, so gates and probes
were not serialised against each other at all — two queues, one machine, and the
dogpile the lock exists to prevent. The runner now takes BOTH, always product
first: flock can exec flock, so nesting is free, and a fixed order means two
processes can never take them in opposite orders and deadlock. Either file alone
still serialises everything that uses it.
The repo cannot simply adopt the other name: it carries an internal code name and
check:no-codename refuses to let a repo we are handing over contain one. Taking
both paths is how the rendezvous becomes shared without the repo spelling a word
it must not ship. VERIFIED in ps while a wave held both, another agent's probe
was waiting on the same file.
THE LOAD CEILING WAS UNREACHABLE, so the guard only ever cost time. It was set at
12 when the risk was seven agents starting chrome at once — but the FLOCK is what
prevents that; the load check is belt-and-braces on a belt. Measured today with
several agents working: 1-min load sits at 36-50 all day, so every browser gate
waited out its full 900s cap and ran anyway. That is a 15-minute tax per wave for
a guard that never opens, and its real effect is that people stop running browser
gates. Now cpus().length * 5 (40 here), scaled because 40 means something
different on 8 cores than on 64, with a 120s cap. Both still override by env.
Also: check:stale-sweep-claim advertised --poison inside an inline comment it can
never receive; the flag gets its own entry and the parent stops advertising it.
gates:pre 7/7, gates:post 42/44 with the two known precondition skips.
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.
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.
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.
The 29 gates in build:unlocked cost 84.5s chained with &&, but 21 of them
finish in under 700ms — they were queueing behind check:mjs-parse (26.9s)
and check:parse (18.7s) on an otherwise idle 8-core box. The 22 vite builds
cost 129.0s and were never ordered at all: vite.config.js gives each bundle
its own outDir with emptyOutDir:false and emits nothing outside it, so they
only ever needed to not overwrite each other.
internal/run-parallel.mjs runs a set of independent steps concurrently and
grades the set by exit code. build:unlocked now reads:
gates:pre -> library-index -> clean -> token -> build:bundles -> tree
-> gates:post -> gallery
Measured end-to-end, both chains green, two rounds each, clean+token
excluded as common to both: 165.7s -> 65.3s mean (170.7/160.8 vs 60.2/70.5).
No gate was removed, weakened, sampled, or made conditional on what changed.
The step set is identical — 31 in, 31 out, asserted at edit time — and each
step runs as the same command graded by the same exit code. Only start times
moved. The && BETWEEN phases is load-bearing and stays: a source gate still
blocks the build, and the build still blocks every gate reading its output.
Within a phase the runner reports ALL failures rather than stopping at the
first, so three broken gates cost one fix-and-rerun cycle instead of three.
It imposes no timeout of its own, because a command killed by a short
timeout is not a failure.
Negative-tested three ways, each required to exit 1: a failing gate inside a
phase, a red phase halting the chain, and a failing vite build inside
build:bundles. The bundle case also confirmed the runner enumerates
plugin-designs live (it picked up 23 dirs, not a cached 22).