Commit cd9d0d80 by PLN (Algolia)

tooling: one launcher, one gate — and four bugs the split had been hiding

Two verbs instead of three names. gig-up.sh DOES; tools/check-gig.py PROVES.

The collision was not a fork. `gig-up.sh` (root, 555 lines) launches the rig;
`tools/gig-up.sh` (620) proved the set; `tools/gig-preflight.py` (522) already
proved the machine. Both gates took --converge and --quiet, so the two paths
looked interchangeable and were not: the preload gate sat only on the path not
pressed, the readiness report only on the path the Bridge does not call.

The gate is now a TABLE, not control flow: 23 probes, each carrying its command,
its fix, its severity and its layer. The bash pipelines are kept verbatim — each
was written the day a specific failure was found and the pipeline IS the finding.
gig-preflight already emitted {check,state,detail,fix} over OK/WARN/FAIL, so the
machine layer joined through its own --json with no rewrite. 23 + 16 = 39 checks
in one report, one exit code. Measured: 16.5s full, 3.4s --fast, 0.3s machine.

Found while wiring it, each verified against the failing leg:

1. The launcher rewrote preload.scd from setlist_opal2026.txt on EVERY launch —
   the Sept 6 set. Measured 132 banks -> 45, leaving 41 of Thursday's banks to be
   read off disk mid-set: the failure check-preload's own header calls "debuted
   as a crackle at the venue". The set is now named ONCE at the top of the
   launcher and read by both the plan generator and the gate. Generation also
   converges instead of clobbering — a plan that already covers the set is left
   alone, because a rewrite can only narrow it.
2. `LCXL present` reported the desk PRESENT with the board unplugged: it grepped
   all of `aseqdump -l`, whose third column is the PORT name, and lcxl3-driver
   publishes a virtual output called "ParVagues LCXL3". The gate was matching our
   own driver. Now anchored on the client name (old: present, new: absent, board
   off the bus).
3. `setlist compiles` proved backlog.md's 15 tracks, not the 17 being played.
   --setlist scopes the compile and preload probes to one gig's file, via
   setlist_samples.read_setlist — never a fresh regex.
4. `SC -> Ardour` was gated on scsynth, so it printed NO-GO on a healthy
   --headphones rig where there is legitimately no Ardour. Preconditions are
   per-probe now, and an unmet one is SKIP, never FAIL.

Also: gig-preflight's `midi surface` warned that mk3 LEDs were unimplemented,
untrue since lcxl3-driver took over painting. Both generations exercised against
a recorded graph. And `gig-log preflight` (the armed-global check — gMask sat at
127 for 74 minutes while every cold check was green) lived only in the launcher's
report where it could not block. It is a blocking probe now.

Enforcement, because a rule in a docstring is not a rule:
tools/tests/test_check_gig.py, 39 tests. No probe may mutate anything (read over
argv, never the source text — a fix hint is SUPPOSED to say "systemctl restart");
the fence has its own detector; no check may be lost from the port while the old
script exists; the launcher may not grow assertions of its own and must end by
calling the gate.

Suites 510 passed, 2 failed — both pre-existing (fold-orbits' PipeWire regexes
tripping the Tidal-parser ratchet, test_gearbox's dash fallback).

tools/gig-up.sh is kept, unrun, with a superseded banner: nothing is deleted
until its replacement has run on a real launch, and it is the transcription
check's reference until then.
parent 8b58011b
......@@ -132,8 +132,21 @@ Only when scsynth is genuinely dead (§2a found nothing).
```
Idempotent: it converges the rig rather than spawning duplicates, brings SuperDirt up
**first**, waits for a real readiness gate (scsynth alive + `:57120` listening + a
SuperCollider MIDI client), and only then touches Ardour/Pulsar. Preloads the last 20 edited
tracks' samples so the first plays don't crack.
SuperCollider MIDI client), and only then touches Ardour/Pulsar. Warms the set's sample
banks — and since 2026-09-22 it LEAVES a plan that already covers the set alone, rather
than rewriting it narrower on every launch.
It ends by running the gate, so there is nothing else to remember:
```sh
tools/check-gig.py # the full gate, ~16s
tools/check-gig.py --setlist armada/setlist_thu24.txt # prove THIS set
```
**One launcher, one gate** (2026-09-22). `gig-up.sh` DOES; `tools/check-gig.py` PROVES,
in two layers — the set, then the machine (`tools/gig-preflight.py`). The gate changes
nothing, ever, so it is safe to run mid-set. `tools/gig-up.sh` is the retired bash gate:
superseded, kept unrun until after Thursday.
**Expect up to ~120s** (`READY_TIMEOUT`). That is a long time on stage. Have something to say,
or a track already playing off another source.
......@@ -171,7 +184,7 @@ on purpose — if Ardour dies you still have the set.
## Pre-set checklist (the 2 minutes that prevent §2b and §2d)
1. `./gig-up.sh` — wait for the readiness gate, don't race it.
1. `./gig-up.sh` — wait for the gate at the end, don't race it. It prints GO or NO-GO with the fix for each failure.
2. `ss -lunp | grep 6010` → exactly one ghc, and it's the current one.
3. `aconnect -l | grep SuperCollider` → present.
4. **Fader 77 up.** **Knobs C1/C2/C3 (the DJ filters, CC 49/50/51) at their CENTRE
......
......@@ -19,6 +19,20 @@ set -u
DIR="$(cd "$(dirname "$0")" && pwd)" # repo root (this script lives here)
ARDOUR_SESSION="$HOME/Work/Sound/Ardour/Tidal Live/Tidal Live.ardour" # lean per-gig session (Tidal Multi = fat archive, retiring)
READY_TIMEOUT="${READY_TIMEOUT:-120}" # s to wait for SuperDirt (samples can be slow)
# THE SET BEING PLAYED — named ONCE, because two consumers read it and they must
# never disagree: gen_preload writes the warm-up plan from it, and the readiness
# gate proves it. Override with SETLIST=... for a different gig or a jam.
#
# This default used to live inside gen_preload as `$DIR/setlist_opal2026.txt`,
# and on 2026-09-22 that was measured: a launch would have rewritten preload.scd
# from 132 banks down to 45, leaving 41 of Thursday's banks to be read OFF DISK
# mid-set. That is not a hypothetical — it is the failure check-preload.sh's own
# header calls "debuted as a crackle at the venue", and a stale default had
# quietly re-armed it. A generated boot file is only as good as the list it is
# generated FROM.
SETLIST="${SETLIST:-$DIR/armada/setlist_thu24.txt}"
[ -f "$SETLIST" ] || SETLIST="$DIR/setlist_opal2026.txt" # last-resort fallback
PRELOAD_TRACKS="${PRELOAD_TRACKS:-20}" # warm samples from the last N edited tracks (0 = off)
HEADPHONES=0 # --headphones: no Ardour, all orbits folded to one sink
......@@ -161,11 +175,29 @@ cut_gpu(){
gen_preload(){
[ "${PRELOAD_TRACKS}" = 0 ] && { info "preload: disabled (PRELOAD_TRACKS=0)."; return; }
# CONVERGE, DO NOT CLOBBER. Every other action in this script is conditional on
# the thing being broken — a healthy rig converges to a no-op — and this one was
# not: it rewrote preload.scd unconditionally on every launch.
#
# That is destructive in the quiet direction. The plan in place on 2026-09-22
# was a deliberate UNION of 132 banks, built so that the 17-track set plus the
# tracks PLN might reach for ("i can play longer/shorter, might add some") were
# all warm. Regenerating from the setlist alone yields 66 — correct for the set,
# and 66 fewer banks of headroom for anything he adds mid-arc.
#
# So: ask first. If the existing plan already covers the set, leave it alone; a
# rewrite could only narrow it. Regenerate only when it does NOT cover, which is
# exactly the case the old unconditional write was there for.
if [ -f "$DIR/preload.scd" ] \
&& PV_PRELOAD_SETLIST="$SETLIST" bash "$DIR/tools/check-preload.sh" >/dev/null 2>&1; then
ok "preload: plan already covers $(basename "$SETLIST") ($(grep -c '^\s*\[ \\' "$DIR/preload.scd" 2>/dev/null || echo 0) banks) — left as-is."
return
fi
# Prefer the explicit SETLIST over --last N. `--last` guesses the set from file
# mtimes, and on 2026-07-28 that guess silently omitted a set track's banks — back
# when a preload miss meant permanent silence rather than a slow first hit. Derive
# the warm-up from what will be PLAYED. --last stays as the fallback for jamming.
local SETLIST="${SETLIST:-$DIR/setlist_opal2026.txt}"
local args desc
if [ -f "$SETLIST" ]; then
args="--setlist $SETLIST"; desc="setlist $(basename "$SETLIST")"
......@@ -497,53 +529,48 @@ else
LAUNCH_ARGS=("$DIR"); launch_bin "Pulsar" pulsar
fi
# 5) READINESS GATE. Not a report — a gate, with a verdict you can act on.
# 5) THE GATE — one call, because there is now one gate.
#
# What used to be here: four hand-picked checks (surface globals, check-mix or
# the orbit fold, check-drift, check-preload), chosen because they were the ones
# someone remembered. The other twenty lived in `tools/gig-up.sh` — a DIFFERENT
# script with the SAME name, which the desktop launcher does not run. So the
# preload gate sat on the path PLN does not press and the readiness report on
# the path the Bridge does not call, and that cost a real failure: a launch
# opened a second Ardour and nothing here noticed.
#
# Everything above proves processes STARTED. Nothing above proves the rig will make
# a sound, because the two things that most reliably swallow it are STATE, not
# processes: a global mute/filter/gate parked somewhere on the surface, and an
# Ardour fader parked at -inf. On 2026-07-29 gMask sat armed at 127 for 74 minutes
# while every static check was green, and PLN found it by ear. The ear is not
# supposed to be the smoke detector.
# Now the split is by verb and it is total. This script DOES; tools/check-gig.py
# PROVES, and it is read-only by construction (enforced by
# tools/tests/test_check_gig.py, which reads the probe table and rejects any
# probe that could change the rig). All four checks above are in it, plus 35
# more, and it cannot be the stale copy because there is only one.
#
# Neither check is fatal to the launch — you may be starting up precisely to fix
# them — so they report and never abort.
# --fast skips the ~12s GHC sweep. Measured: 16.5s full, 3.4s fast. A
# launch waits for nobody; `tools/check-gig.py` gives the full one.
# --setlist proves THE SET BEING PLAYED. Without it the compile probe asks
# about backlog.md's projection, which on 2026-09-22 was 15 tracks
# while Thursday's set is 17 — so the gate was green about a set
# PLN is not playing.
# timeout a gate that hangs must never wedge a launch that already brought
# sound up. It runs LAST, after every spawn, for exactly this
# reason: the worst it can do is be slow or wrong, never silent.
# 90s is 26x the measured --fast time, and keeps this script's
# worst case (120s waiting for SuperDirt + the gate) inside the
# 300s the Bridge's RIG UP button allows before it gives up.
echo
if command -v python3 >/dev/null; then
info "readiness: surface state (globals that could be swallowing your sound)"
python3 "$DIR/tools/gig-log.py" preflight 2>&1 | sed 's/^/ /' || true
if [ "$HEADPHONES" = 1 ]; then
# The fold is the whole monitoring path in this mode, so it is the gate:
# a link that a suspended/replaced sink node took with it is silence with
# no error anywhere. Ardour's faders cannot swallow what never reaches them.
info "readiness: orbit fold (the entire monitoring path on headphones)"
python3 "$DIR/tools/fold-orbits.py" --check 2>&1 | sed 's/^/ /' || true
else
info "readiness: Ardour faders (reads the last SAVED session — save first)"
python3 "$DIR/tools/check-mix.py" 2>&1 | tail -4 | sed 's/^/ /' || true
fi
# Pulsar saves the BUFFER, not the file, and Window:Reload restores the cached
# buffer rather than disk — so a tab left open across an edit can silently write
# three-day-old text back over committed work. This catches that in seconds.
info "readiness: track drift (did a stale Pulsar buffer overwrite committed work?)"
bash "$DIR/tools/check-drift.sh" 2>&1 | tail -8 | sed 's/^/ /' || true
# A bank that is NOT warmed is a disk read on the audio thread, mid-transition,
# at the venue — the failure check-preload.sh's own header calls "debuted as a
# crackle at the venue". This gate lived only in tools/gig-up.sh, which the
# desktop launcher does not run, so the path PLN actually presses never checked
# it. REPORT ONLY, deliberately: `--fix` regenerates the plan, and regenerating
# a load-bearing boot file at the venue is the landmine, not the cure.
info "readiness: preload (are the set's sample banks warmed, or read off disk mid-set?)"
# HEAD, not tail: the verdict and any MISSING banks come first, and the tail of
# this output is the "extra banks warmed on purpose" list — 60-odd names of
# deliberate over-coverage, which is the least useful thing to print at a venue.
# The verdict and any MISSING banks are indented 0-4; the "extra banks warmed
# on purpose" roster is indented 5+ and runs to 60-odd names. Keep the finding,
# drop the roster: at a venue the useful output is one line long.
PV_PRELOAD_SETLIST="$DIR/armada/setlist_thu24.txt" \
bash "$DIR/tools/check-preload.sh" 2>&1 \
| grep -vE '^ {5,}' | head -8 | sed 's/^/ /' || true
fi
info "readiness: one gate, two layers (the set, then the machine)"
GATE=(python3.12 "$DIR/tools/check-gig.py" --quiet --fast)
[ -f "$SETLIST" ] && GATE+=(--setlist "$SETLIST")
# Captured rather than piped: `cmd | sed` reports sed's exit status, and the
# whole point of this block is the gate's.
gate_out=$(timeout 90 "${GATE[@]}" 2>&1); GATE_RC=$?
[ -n "$gate_out" ] && printf '%s\n' "$gate_out" | sed 's/^/ /'
case "$GATE_RC" in
0) ok "gate: GO — nothing blocking." ;;
124) warn "gate: timed out after 90s — launch continues; run tools/check-gig.py by hand."
GATE_RC=0 ;;
*) warn "gate: NO-GO — the blocking failures and their fixes are above." ;;
esac
echo
if [ "$HEADPHONES" = 1 ]; then
......@@ -553,3 +580,8 @@ else
ok "gig-up done. SuperDirt owns the LaunchControl; Ardour + Pulsar are coming up."
[ "$CONVERGE" = 0 ] || ok "converge done — SuperDirt owns the LaunchControl; the Bridge opens the apps."
fi
# The gate's verdict is this script's verdict. gig-up-window.sh holds the
# terminal open on a non-zero exit and closes silently on zero, so a NO-GO
# leaves the reasons on screen and a clean launch leaves nothing in the way.
exit "${GATE_RC:-0}"
# Launcher consolidation — one gig-up, one truth
Queued 2026-09-20, after gig prep. Full picture established that day (see
`completed_archive.json` learning line + commit 35fb6ed).
**DONE 2026-09-22, but NOT by the plan below — the direction flipped. Read this
section before the plan, which is kept only to show what was considered.**
The plan said: port the launcher INTO `tools/gig-up.sh`, point the desktop entry
there, and make the root script a shim. That would have been the wrong merge,
because it assumed the two files were forks of one tool. They were not. They were
**two different tools wearing one name** — one LAUNCHES, one PROVES — and a
third (`tools/gig-preflight.py`) already did half of the proving. Merging them
would have produced one 1100-line script that both acts and asserts, which is
the thing that cannot be called from the boot path safely.
So the split is by VERB instead:
| file | role |
|---|---|
| `gig-up.sh` (root) | DOES — starts, waits, restores, converges. The desktop entry and the Bridge's RIG UP both run this one now. |
| `tools/check-gig.py` | PROVES — 23 probes as a TABLE, read-only by construction, plus the machine layer through `gig-preflight --json`. |
| `tools/gig-up-window.sh` | SHOWS — owns the terminal and the log. |
| `tools/gig-up.sh` | retired; superseded banner at its head, kept unrun until after Thursday's gig. |
What the merge found on the way, none of which the plan anticipated:
1. **The launcher rewrote `preload.scd` from `setlist_opal2026.txt` on every
launch** — the 2026-09-06 OPAL set. Measured: 132 banks down to 45, leaving
41 of Thursday's banks to be read off disk mid-set. The set is now named ONCE
at the top of the launcher and read by both the plan generator and the gate.
2. **`LCXL present` reported the desk present with the board unplugged**, by
matching `aseqdump`'s PORT column, where `lcxl3-driver` publishes a virtual
output called "ParVagues LCXL3". It was grepping our own driver.
3. **`gig-preflight`'s `midi surface` warned that mk3 LEDs are unimplemented**,
which stopped being true when `lcxl3-driver.py` took over painting.
4. **`SC -> Ardour` was gated on scsynth**, so it printed NO-GO on a healthy
`--headphones` rig, where there is legitimately no Ardour at all.
Verification and rationale: `docs/2026-09-22-gig-up-merge-design.md`.
Enforcement: `tools/tests/test_check_gig.py` (39 tests) — no probe may mutate
anything, no check may be lost from the port, and the launcher may not grow a
gate of its own.
---
## The plan as queued 2026-09-20 (superseded, kept for the record)
Full picture established that day (see `completed_archive.json` learning line +
commit 35fb6ed).
## The split-brain (today's incident)
......
......@@ -32,7 +32,13 @@ import gearbox as GB
import launchers as L
TIDAL = L.TIDAL
GIG_UP = TIDAL / "tools" / "gig-up.sh"
# THE LAUNCHER, and there is only one now (2026-09-22). This used to point at
# tools/gig-up.sh, a DIFFERENT script that happened to share the name and also
# accepted --converge: so the button and the desktop entry ran different code,
# and the checks each one carried were invisible to the other. The launcher is
# the repo-root script; proving is tools/check-gig.py, which the launcher calls
# at the end of every run.
GIG_UP = TIDAL / "gig-up.sh"
THERMAL_CONF = Path("/etc/thermal-mode.conf")
# THE INVENTORY IS AUTHORED ONCE, in tools/rig_units.py.
......
......@@ -313,14 +313,24 @@ def check_midi_surface() -> None:
add("midi surface", WARN, "no Launch Control XL / LCXL client present")
return
# CORRECTED 2026-09-22. This warned that the mk3 dialect was unimplemented
# and that "LEDs will stay default" — true when it was written against
# lcxl-leds.py, and false since tools/lcxl3-driver.py took over painting the
# v3 board (it also translates the v3 DAW-mode CC map so the 169-track
# corpus keeps its v2 numbering). A check that cries wolf on every boot is
# a check PLN learns to scroll past, which is how a real FAIL gets missed.
#
# WHETHER the painter is running is a different question, and it has an
# owner already: check-gig's `LCXL LEDs` probe. One fact, one owner — so
# this reports the dialect and the painter, and does not guess at liveness.
mk3 = bool(re.search(r"lcxl\s*3|xl\s*3", surface, re.I))
if mk3:
add("midi surface", WARN,
f"{surface!r} is mk3 (RGB, device id 0x15, cmd 01 53); "
f"lcxl-leds.py sends mk2 (0x11/0x78, bicolor) — LEDs will stay default",
"mk3 dialect support is unimplemented; see TODO.d")
add("midi surface", OK,
f"{surface!r} is mk3 (RGB, device id 0x15, cmd 01 53) — "
f"painted by lcxl3-driver")
else:
add("midi surface", OK, f"{surface!r} (mk2 dialect matches tooling)")
add("midi surface", OK,
f"{surface!r} is mk2 (bicolor, 0x11/0x78) — painted by lcxl-leds-watch")
# Wiring: the surface must reach Midi Through, which is what feeds the
# aseqdump taps and SuperCollider.
......
#!/usr/bin/env bash
# SUPERSEDED 2026-09-22 by tools/check-gig.py. Do not add checks here.
# ---------------------------------------------------------------------------
# Every `run`/`soft` assertion below now lives in that file's probe TABLE, and
# tools/tests/test_check_gig.py asserts none was lost in the port. The two
# checks that are NOT there (`gear`, `perf mode`) moved to the machine layer,
# tools/gig-preflight.py, which answers them in more detail.
#
# This file is kept, unrun, for exactly one reason: nothing is deleted until the
# thing that replaces it has run on a real launch. It is also the transcription
# check's reference — the test reads THIS script's check names and requires each
# to appear in the new table, so deleting it early would silently retire that
# proof. Retire it after Thursday's gig, not before.
#
# Its `--converge` half moved to the LAUNCHER (gig-up.sh at the repo root),
# which is also where tools/bridge/rig.py's RIG UP button now points.
# ---------------------------------------------------------------------------
# gig-up — ONE command that says GO or NO-GO for the whole chain.
#
# Why this exists (2026-07-31, J-8 to OPAL)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment