diff --git a/README.md b/README.md index 720b22e37..2b7c0c9df 100644 --- a/README.md +++ b/README.md @@ -209,8 +209,8 @@ iframe at it. More about method 2 — mobile app [Capacitor](https://capacitorjs.com) wraps the built maps into an Android app — the same files, -no new code. Shown here: the UPG-100 prayer lists and posters, one poster per row, with print -options that fit a phone. +no new code. Shown here: the UPG-100 prayer lists and posters at phone size, each poster with its +own print options. ```bash bun run check:mobile # checks the app is wired up correctly — needs no Android tools diff --git a/documentation/readme-images/cap1-single-element.png b/documentation/readme-images/cap1-single-element.png index 372c4d3dc..1651fc8fe 100644 Binary files a/documentation/readme-images/cap1-single-element.png and b/documentation/readme-images/cap1-single-element.png differ diff --git a/documentation/readme-images/cap2-adoption.png b/documentation/readme-images/cap2-adoption.png index 8d354a5e7..a8e994b07 100644 Binary files a/documentation/readme-images/cap2-adoption.png and b/documentation/readme-images/cap2-adoption.png differ diff --git a/documentation/readme-images/cap2-engagement.png b/documentation/readme-images/cap2-engagement.png index 52f8ef714..2d7047d1b 100644 Binary files a/documentation/readme-images/cap2-engagement.png and b/documentation/readme-images/cap2-engagement.png differ diff --git a/documentation/readme-images/cap2-prayer.png b/documentation/readme-images/cap2-prayer.png index 9e0d23f5b..4b471d9b4 100644 Binary files a/documentation/readme-images/cap2-prayer.png and b/documentation/readme-images/cap2-prayer.png differ diff --git a/documentation/readme-images/cap3-monolithic-research.png b/documentation/readme-images/cap3-monolithic-research.png index b5fabd338..9325d5897 100644 Binary files a/documentation/readme-images/cap3-monolithic-research.png and b/documentation/readme-images/cap3-monolithic-research.png differ diff --git a/documentation/readme-images/cap4-upg100-list.png b/documentation/readme-images/cap4-upg100-list.png index 8809750b9..6ee94a503 100644 Binary files a/documentation/readme-images/cap4-upg100-list.png and b/documentation/readme-images/cap4-upg100-list.png differ diff --git a/documentation/readme-images/cap4-upg100-mfe.png b/documentation/readme-images/cap4-upg100-mfe.png index 92b896372..35afdc1a9 100644 Binary files a/documentation/readme-images/cap4-upg100-mfe.png and b/documentation/readme-images/cap4-upg100-mfe.png differ diff --git a/documentation/readme-images/cap4-upg100-poster-wall.png b/documentation/readme-images/cap4-upg100-poster-wall.png index 6ead9074e..b740da851 100644 Binary files a/documentation/readme-images/cap4-upg100-poster-wall.png and b/documentation/readme-images/cap4-upg100-poster-wall.png differ diff --git a/documentation/readme-images/deploy2-mobile-upg100.png b/documentation/readme-images/deploy2-mobile-upg100.png index d4914890a..e24bbaba4 100644 Binary files a/documentation/readme-images/deploy2-mobile-upg100.png and b/documentation/readme-images/deploy2-mobile-upg100.png differ diff --git a/documentation/readme-images/shot-bundles.json b/documentation/readme-images/shot-bundles.json index 538a39720..32aa1039e 100644 --- a/documentation/readme-images/shot-bundles.json +++ b/documentation/readme-images/shot-bundles.json @@ -1,35 +1,35 @@ { "cap1-single-element": { "bundle": "all-people-groups", - "sha256": "db9e293144928477bc7fc1ef50f469db073d81575b29ba394b50d33273c6e1a5" + "sha256": "312b3a0f64b0842b498ae8c0c88491a9225c353a60c0420e05ac18fa0333a0a5" }, "cap2-adoption": { "bundle": "doxa-simple-map", - "sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778" + "sha256": "95ab02edf30ab19b1637238bb50f580286daa2808fbcb43478dbb0c3d0115d95" }, "cap2-engagement": { "bundle": "doxa-simple-map", - "sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778" + "sha256": "95ab02edf30ab19b1637238bb50f580286daa2808fbcb43478dbb0c3d0115d95" }, "cap2-prayer": { "bundle": "doxa-simple-map", - "sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778" + "sha256": "95ab02edf30ab19b1637238bb50f580286daa2808fbcb43478dbb0c3d0115d95" }, "cap3-monolithic-research": { "bundle": "doxa-research-map", - "sha256": "56845dc5bd33198a7dd8fb42895700f21b4bc1c3b7f98f57832e8373c7782a83" + "sha256": "7e3637a635faf8223488a4bc21c1f308c5b3ea94fa6c80d5f1560a04ef7ec9cb" }, "cap4-upg100-list": { "bundle": "my-upg-100-list", - "sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7" + "sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d" }, "cap4-upg100-mfe": { "bundle": "my-upg-100-list", - "sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7" + "sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d" }, "cap4-upg100-poster-wall": { "bundle": "my-upg-100-list", - "sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7" + "sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d" }, "cap5-doxa-maps-spa": { "bundle": "doxa-maps-spa", @@ -41,6 +41,6 @@ }, "deploy2-mobile-upg100": { "bundle": "my-upg-100-list", - "sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7" + "sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d" } } diff --git a/internal/browser-slot.sh b/internal/browser-slot.sh new file mode 100755 index 000000000..217658959 --- /dev/null +++ b/internal/browser-slot.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# browser-slot.sh — run ONE browser command inside a memory-floored, N-slot rendezvous. +# +# WHY A SEMAPHORE AND NOT A LOCK. A single lock made every agent's browser proof wait behind one +# chrome: measured 2026-09-25, the queue sat 10-19 deep for hours. The real constraint is MEMORY, +# not exclusivity — a headless chrome costs ~300-500MB — so the right shape is "as many browsers as +# the box can hold", which is a counting semaphore with a memory floor, not a mutex. +# +# HOW: N slot files. Try each with flock -n (no wait). If all are busy, WAIT on slot 1. Before +# taking any slot, require MemAvailable above the floor — a slot that is free while the box is out +# of memory is an invitation to be OOM-killed mid-measurement, which reads as a flaky gate. +# +# ★ EXIT 75 MEANS "NOT MEASURED", NEVER "FAILED". flock returns 1 on timeout, the same code a +# failing gate uses, and that once turned a busy queue into five red product gates. 75 is +# EX_TEMPFAIL: distinct from 0 and from any gate's own verdict, so a caller can report it as the +# precondition it is. +# +# Usage: internal/browser-slot.sh [args...] +# Env: BROWSER_SLOTS (default 2), BROWSER_SLOT_DIR (default /tmp), +# BROWSER_MEM_FLOOR_MB (default 2500), BROWSER_LOCK_WAIT (default 900), +# BROWSER_MEM_WAIT (default 300) +set -u + +SLOTS="${BROWSER_SLOTS:-2}" +DIR="${BROWSER_SLOT_DIR:-/tmp}" +BASE="$DIR/doxa-maps-browser-gate.lock" +FLOOR_MB="${BROWSER_MEM_FLOOR_MB:-2500}" +WAIT_S="${BROWSER_LOCK_WAIT:-900}" +MEM_WAIT_S="${BROWSER_MEM_WAIT:-300}" +BUSY=75 + +slot_path() { [ "$1" = "1" ] && echo "$BASE" || echo "$BASE$1"; } + +mem_available_mb() { + awk '/^MemAvailable:/ { printf "%d", $2 / 1024 }' /proc/meminfo 2>/dev/null || echo 999999 +} + +# Hold off while the box is below the floor. Proceeding anyway is not an option here the way it is +# for a load average: load makes a measurement slow, memory makes it get killed halfway through. +waited=0 +while [ "$(mem_available_mb)" -lt "$FLOOR_MB" ]; do + if [ "$waited" -ge "$MEM_WAIT_S" ]; then + echo "browser-slot: MemAvailable $(mem_available_mb)MB below floor ${FLOOR_MB}MB after ${waited}s — not measuring" >&2 + exit "$BUSY" + fi + sleep 10 + waited=$((waited + 10)) +done + +# Fast path: any free slot, in a FIXED order so two processes can never take them in opposite +# orders. -n means "do not wait": if this slot is busy we move on rather than queue behind it. +i=1 +while [ "$i" -le "$SLOTS" ]; do + exec 9>"$(slot_path "$i")" 2>/dev/null || true + if flock -n 9; then + exec "$@" + fi + i=$((i + 1)) +done + +# Everything busy: queue on slot 1, and report a timeout as UNMEASURED rather than as a verdict. +exec 9>"$(slot_path 1)" +if flock -w "$WAIT_S" 9; then + exec "$@" +fi +echo "browser-slot: no slot within ${WAIT_S}s (${SLOTS} slots, floor ${FLOOR_MB}MB) — not measuring" >&2 +exit "$BUSY" diff --git a/internal/run-parallel.mjs b/internal/run-parallel.mjs index e65c579c1..57ed01d93 100644 --- a/internal/run-parallel.mjs +++ b/internal/run-parallel.mjs @@ -84,10 +84,10 @@ const CONCURRENCY = Math.max( // 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. -const BROWSER_LOCK = '/tmp/doxa-maps-browser-gate.lock' -const EXTRA_LOCK = process.env.BROWSER_GATE_LOCK && process.env.BROWSER_GATE_LOCK !== BROWSER_LOCK - ? process.env.BROWSER_GATE_LOCK - : '' +// 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 @@ -177,18 +177,13 @@ function buildSteps() { // 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. - // flock can exec flock: nesting is how one command waits on both rendezvous points. - // ★ -E 75: A LOCK TIMEOUT IS NOT A GATE FAILURE, AND flock CANNOT SAY SO BY DEFAULT. - // Without it flock exits 1 on timeout — the same code a failing gate uses — and a wave that - // simply could not get the browser lock prints ✗ against five product gates. MEASURED: five - // browser gates each "failed" at exactly 1200064ms, which is the timeout to the millisecond - // and not a verdict about the product at all. 75 (EX_TEMPFAIL) is distinct, so the runner can - // report "could not take the lock" as the precondition it is. - const lockArgs = ['-w', String(LOCK_WAIT_S), '-E', String(LOCK_BUSY_CODE)] - const args = EXTRA_LOCK - ? [...lockArgs, BROWSER_LOCK, 'flock', ...lockArgs, EXTRA_LOCK, 'bun', 'run', name] - : [...lockArgs, BROWSER_LOCK, 'bun', 'run', name] - return { ...step, browser: true, cmd: 'flock', args } + // ★ 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// directory — the same selector build:unlocked used