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.
......
#!/usr/bin/env python3.12
"""check-gig — ONE command that says GO or NO-GO for the whole chain.
tools/check-gig.py the cold gate — safe anywhere, ~16s
tools/check-gig.py --fast skip the GHC sweep (~4s; for the boot path)
tools/check-gig.py --quiet only WARN/FAIL and the verdict
tools/check-gig.py --repo the set layer only (no machine probes)
tools/check-gig.py --machine the machine layer only (gig-preflight)
tools/check-gig.py --audio + check-tracks.sh (MAKES SOUND, ~10 min)
tools/check-gig.py --json machine-readable records
tools/check-gig.py --setlist armada/setlist_thu24.txt # prove THIS gig
Exit 0 = GO. Non-zero = do not start; the reasons print first, with their fix.
What this is
------------
The gate half of the pair. `gig-up.sh` DOES (starts, waits, restores,
converges); this PROVES, and it is read-only by construction — see the fence in
`gig_gate.mutations()`, enforced by `tools/tests/test_check_gig.py`. That is
what lets the launcher end by calling it on every boot.
It replaces the bash gate that used to live at `tools/gig-up.sh` and share a
name with the launcher. Nothing about the probes changed: the pipelines below are
the same pipelines, kept verbatim, because each was written the day a specific
failure was found and the pipeline IS the finding. What changed is that they are
now a TABLE — name, command, fix, severity, layer — instead of 620 lines of
control flow, so a check cannot quietly go missing (2026-08-02: `check-drift.sh`
was documented as guarding the gate and was never actually called) and the
severity of every probe is visible in one screen.
Two layers, one report
----------------------
repo — is the SET correct? boot helpers, setlist, surface grid, faders,
compiles, lint, preload, ghosts, the live chain, the recorder.
machine — is the MACHINE fit? perf regime, RT audio, xruns, sample
symlinks, MIDI surface, editor, background load, monitor path.
Owned by `tools/gig-preflight.py`, absorbed through its --json.
One fact, one owner: the gear / perf-mode probes the bash gate carried are NOT
repeated here. The machine layer already answers them, in more detail (governor,
EPP, clock ceiling, package power, live frequency), and two owners for one fact
is how the rig got a converge and a `thermal-mode status` that disagreed.
The live chain is auto-detected, not a flag
-------------------------------------------
The bash gate needed `--live` to assert a rig exists. Cold-by-default is right —
cold probes are safe on a train — but a FLAG you must remember is a check you do
not run, which is this gate's founding complaint. So: if scsynth is up, the live
probes are meaningful and they run. `--cold` forces them off, `--live` forces
them on (and will fail loudly when there is no rig, which is the point).
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from dataclasses import replace
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from gig_gate import ADVISE, MACHINE, WARN, Gate, Probe, shell # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
PY = sys.executable # whatever runs the gate runs its children: one interpreter
# --------------------------------------------------------------------------- #
# The repo layer. Order is load-bearing where noted; severity is a judgement
# written next to each probe, never derived.
# --------------------------------------------------------------------------- #
PROBES: tuple[Probe, ...] = (
# First because one parse error in BootTidal.hs silences EVERY track at
# once — the worst failure this rig has, and invisible while a stale ghci
# still holds the old definitions.
Probe("boot helpers", ["tools/check-boot.sh"],
fix="read the ghc error: tools/check-boot.sh"),
# THE THIRD TIME. gig-up's first run found check-boot.sh and
# check-tracks.sh at mode 644 — never runnable as `tools/…`, only ever
# working because a human typed `zsh tools/...` out of habit. Then the same
# bug turned up in the gate itself: committed 100644, so a fresh
# `git checkout` materialised it at 644 and it died with "permission
# denied" — and because the caller filtered the output, the run LOOKED like
# a clean no-op. A false green from a script that never executed.
Probe("tools executable", shell(r"""
bad=0
for f in tools/check-gig.py tools/check-boot.sh tools/check-tracks.sh \
tools/check-preload.sh tools/sc-watchdog.sh tools/setlist.py \
tools/check-drift.sh tools/take-segments.py \
tools/fader-baseline.py tools/check-audio-graph.sh \
tools/parvagues-protect.sh tools/install-protect.sh \
tools/gig-up-window.sh gig-up.sh; do
[ -e "$f" ] || continue
[ -x "$f" ] || { echo "not executable on disk: $f"; bad=1; }
m=$(git ls-files -s "$f" 2>/dev/null | awk '{print $1}')
[ -n "$m" ] && [ "$m" != "100755" ] && {
echo "git records $m (not 100755): $f"; bad=1; }
done
exit $bad"""),
fix="chmod +x the file(s) listed, then: git update-index --chmod=+x <file>"),
# HARD, and before every probe that READS the setlist: if this is stale then
# "compiles", "preload covers set" and "transition ghosts" are all answering
# about a set PLN is not playing. On 2026-08-01 the backlog said 15 tracks
# and all eight consumers said 13, so three banks went unwarmed and he heard
# it as crackle mid-transition. The remedy is `--emit`, never a hand-edit.
Probe("setlist vs backlog", [PY, "tools/setlist.py", "--check"],
fix="tools/setlist.py --emit (edit backlog.md, never the .txt)"),
# HARD. On 2026-08-02 PLN closed Pulsar and its stale buffer silently
# reverted wap.tidal's d4 knobs from ^32 back to ^52 — undoing a column
# migration committed AND pushed hours earlier. It happened AGAIN on
# 2026-09-22, to the same file, undoing an orbit compaction.
#
# Invisible in `git status` (the file just reads as modified, mixed in with
# real edits). What CAUGHT it was migrate-columns being idempotent: 0 moves
# one minute, 1 move the next. So assert both — the committed grid is
# intact, AND the working tree needs no migration.
Probe("surface grid intact", shell(r"""
bash tools/check-drift.sh >/dev/null 2>&1 || exit 1
n=$(python3 tools/migrate-columns.py --plan 2>/dev/null \
| sed -n 's/^ *\([0-9]\+\) move(s).*/\1/p' | tail -1)
[ "${n:-0}" = "0" ] || {
echo "migrate-columns wants $n move(s) — a buffer clobber reverts CCs"
python3 tools/migrate-columns.py --plan 2>/dev/null | grep '^ ' | head -20
exit 1; }"""),
fix="tools/check-drift.sh then tools/migrate-columns.py --plan (expect 0 moves)"),
# The D-row faders are MIDI-learned to CC 77-84, so the saved session and
# the physical desk drift independently in BOTH directions. An orbit at
# -inf was caught three sessions running with a different set of tracks each
# time: that is a property of the setup, not bad luck, hence a hard gate.
Probe("fader baseline", [PY, "tools/fader-baseline.py", "--check"],
fix="tools/fader-baseline.py --restore (close Ardour first) — "
"or --capture if these levels ARE the new intent"),
Probe("ardour faders", [PY, "tools/check-mix.py", "--quiet"],
fix="raise it on the desk, then Ctrl+S in Ardour, then re-run. "
"Still -inf after a save = genuinely down."),
# Added after a dropout NOTHING else in the gate could have caught: every
# component was healthy and the fault was in the graph around them. It also
# catches the laptop microphone that had wired itself into the d1 stem —
# invisible in Ardour's GUI and in the saved session. check-mix reads the
# SAVED session; this reads the LIVE graph. Different questions.
Probe("audio graph", ["bash", "tools/check-audio-graph.sh"],
fix="tools/check-audio-graph.sh -v # recording integrity, "
"Master->UMC, is the interface clocking, is the prune live"),
# Cold, seeded, no rig required — and the slowest probe in the gate at ~12s
# of GHC, which is why `--fast` exists for the boot path.
Probe("setlist compiles", [PY, "tools/silent-eval.py", "--seeded"],
fix="tools/silent-eval.py --seeded --keep # then read the ghc error "
"for the named track",
slow=True, timeout=600),
# gM1 = the kick alone, gM2 = the other percs, gM3 = bass + melodics. The
# filters group differently ON PURPOSE (gF1 = the whole rhythm bloc), so
# this exists precisely because the two maps LOOK like they should match.
Probe("mute map", [PY, "tools/fix-mute-roles.py", "--check"],
fix="tools/fix-mute-roles.py --apply # then re-read the diff before committing"),
# pvlint's `--setlist` is a MODIFIER ("these paths are ordered"), not a
# mode — it still needs the paths. Rather than teach a fourth file how to
# parse the setlist, borrow the one parser that is importable.
Probe("pvlint (setlist)", shell(r"""
cd tools && python3 -m pvlint --quiet --setlist $(python3 -c '
import importlib.util
spec = importlib.util.spec_from_file_location("sc", "set-coherence.py")
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
print(" ".join(str(p) for p in m.setlist_tracks()))')"""),
fix="cd tools && python3 -m pvlint --setlist $(...) # --fix applies the safe ones"),
# Cold, and HARD. On 2026-08-01 preload.scd was 4 days old and 3 tracks
# short, so take_5_drops' banks were read from DISK on first play and PLN
# heard it as crackle mid-transition. Every other check was green: the plan
# is GENERATED and GITIGNORED, so nothing kept it fresh and nothing
# compared it to the set. Its natural debut was a cold boot at the venue.
Probe("preload covers set", ["tools/check-preload.sh"],
fix="tools/check-preload.sh --fix # then restart parvagues-sc to warm it"),
# Advisory ON PURPOSE. Ghosting orbits are real and known-open; they make a
# transition messy, not the gig impossible. A gate that goes red for open
# work is a gate that gets ignored.
Probe("transition ghosts", [PY, "tools/orphan-orbits.py"],
fix="tools/orphan-orbits.py --matrix # the fix is #77, not tonight",
kind=ADVISE),
# READ-ONLY: asks ALSA who is on the bus, sends nothing. Advisory because
# the desk is legitimately unplugged at a kitchen table — but at the venue,
# a warn here means the set has no hands.
# Anchored on the CLIENT name, and that is the whole fix. The old probe
# grepped all of `aseqdump -l`, whose third column is the PORT name — and
# lcxl3-driver publishes a virtual output called "ParVagues LCXL3". So the
# gate reported the desk present by matching OUR OWN driver, and said so
# with the board physically unplugged (verified 2026-09-22, board off the
# bus: old probe "present", new probe "absent"). Exactly the shape of the
# `grep -A3` that used to pass unconditionally.
Probe("LCXL present",
shell("""aconnect -l 2>/dev/null \
| grep -qiE "^client [0-9]+: '[^']*(Launch Control XL|LCXL3)" """),
fix="replug the LCXL (USB OUT endpoint stalls — LEDs dark but input "
"alive), then tools/lcxl-init.py",
kind=ADVISE),
# ---- the live chain ---------------------------------------------------- #
# Everything above proves the SET is correct. None of it proves a RIG
# EXISTS. On 2026-08-01 scsynth had been dead for two days and the gate
# would still have printed GO: scsynth was read only to colour a banner.
#
# Severity per link, on purpose. HARD: no audio server / no listener / no
# path to the DAW = there is no gig. SOFT: Pulsar not open, desk unplugged,
# laptop on battery — all normal ten minutes before doors.
#
# Every one reads the thing that MAKES SOUND — a process, a socket, a real
# link — never `systemctl is-active`. The unit reported active for two days
# while the rig was mute.
Probe("scsynth alive", ["pgrep", "-x", "scsynth"],
fix="systemctl --user start parvagues-sc # the watchdog should beat you to it",
needs=("scsynth",)),
Probe("sclang :57120", shell('ss -lunp 2>/dev/null | grep -q ":57120"'),
fix="SuperDirt is not listening — systemctl --user restart parvagues-sc",
needs=("scsynth",)),
# 12 orbits x 2 channels = 24 at an absolute minimum; a healthy rig shows
# ~52. Zero here with scsynth alive means SuperCollider is running and
# going NOWHERE.
Probe("SC -> Ardour", shell(r"""
n=$(pw-link -l 2>/dev/null | grep -ci supercollider)
echo "supercollider links: $n"; [ "$n" -ge 24 ]"""),
fix="check the autoroute: systemctl --user restart tidal-ardour-autoroute",
needs=("scsynth", "ardour")),
# SOFT, and the severity is a judgement not an oversight: an unprotected rig
# still makes sound, so by this gate's own rule it cannot be HARD. But it is
# why the rig died on 2026-08-15 — systemd's user manager ships
# DefaultOOMScoreAdjust=200, so every restart of parvagues-sc hands scsynth
# back as the most killable process on the machine.
Probe("SC un-killable", ["tools/parvagues-protect.sh", "--check"],
fix="sudo tools/install-protect.sh # or re-run perf.sh; check with "
"tools/parvagues-protect.sh --check",
kind=ADVISE, needs=("scsynth",)),
Probe("Tidal :6010", shell('ss -lunp 2>/dev/null | grep -q ":6010"'),
fix="open the set in Pulsar and boot Tidal (nothing is sending patterns yet)",
kind=ADVISE, needs=("scsynth",)),
# This was `aconnect -l | grep -A3 "Launch Control XL" | grep -q "128:"`,
# which PASSED UNCONDITIONALLY: -A3 ran off the end of the LCXL block into
# the `client 128: 'SuperCollider'` header, so "128:" was always in the
# window even with the desk wired to nothing. lcxl-path.py walks the graph
# instead, and takes a recorded-graph argument so it can be tested for
# FAILURE without unplugging anything at the venue.
Probe("LCXL -> SC", [PY, "tools/lcxl-path.py"],
fix="replug the LCXL, then tools/lcxl-init.py — knobs will move "
"nothing until this is up",
kind=ADVISE, needs=("scsynth",)),
# The surface being WIRED is not the surface being LIT. Separate daemon,
# separate failure — the exact gap that produced "i dont see midi feedback
# visuel anymore" the day before a gig. Generation-aware: an LCXL3 is
# painted by lcxl3-driver, the original LCXL by lcxl-leds-watch. A v2-only
# grep made this probe pass by accident.
Probe("LCXL LEDs", shell(r"""
if aseqdump -l 2>/dev/null | grep -qi "LCXL3"; then
systemctl --user is-active --quiet lcxl3-driver.service
elif aseqdump -l 2>/dev/null | grep -qi "Launch Control XL"; then
systemctl --user is-active --quiet lcxl-leds-watch.service
else
exit 0
fi"""),
fix="start the painter: LCXL3 -> systemctl --user start lcxl3-driver; "
"original LCXL -> systemctl --user start lcxl-leds-watch",
kind=ADVISE, needs=("scsynth",)),
# Asks whether THIS instance warmed, by keying to the unit's own start time
# — not "was there ever a PRELOAD line", which a previous boot would satisfy
# forever. NOTE the query: `journalctl --user -u parvagues-sc` is NOT
# reliable on this box (it returned lines from a PREVIOUS instance while the
# current one had already logged "51/51 banks OK"). Filter the plain journal
# by SyslogIdentifier instead.
Probe("SuperDirt warmed", shell(r"""
t=$(systemctl --user show parvagues-sc.service -p ActiveEnterTimestamp --value)
[ -n "$t" ] || exit 1
journalctl -t parvagues-sc --since "$t" --no-pager 2>/dev/null | grep -q "banks OK" """),
fix="no PRELOAD line since this boot — systemctl --user restart "
"parvagues-sc to warm the set's banks",
kind=ADVISE, needs=("scsynth",)),
# Every other probe in this gate is COLD: pvlint parses files, silent-eval
# queries patterns against an empty control map, the grid check reads text.
# All three were green all afternoon while gMask sat ARMED AT 127 on the live
# board for 74 minutes, chopping an eighth out of every bar of every orbit.
# Nothing was broken; a global was simply switched on, and no tool in the
# chain looked at the switches. PLN found it by ear — and the ear is not
# supposed to be the smoke detector.
#
# This lived ONLY in the launcher's readiness report, where it could not
# block anything, and not in the gate at all. Exit 1 = a global is
# swallowing sound, which is precisely a NO-GO.
Probe("surface globals", [PY, "tools/gig-log.py", "preflight"],
fix="release the armed global on the board (the report names the CC), "
"or tools/lcxl-init.py to return to the #55 seed"),
# ---- the recorder ------------------------------------------------------ #
# HARD, not advisory, and the reason is a specific loss: CosmicFest produced
# NO session log. The unit had been installed and never enabled, and it was
# absent from rig_units.py, so nothing in the authored inventory knew it
# existed. A whole gig's thermals, xruns, gear state and — worse — its TRACK
# BOUNDARIES were lost, and recovering the tracklist took two DSP lenses and
# still left gaps.
#
# Deliberately NOT "is the unit active". A green unit is not a recording:
# the process can be up while the writer is wedged. So assert the property
# that matters — a session file whose mtime is within the last 20 seconds.
Probe("gig-log recording", shell(r"""
d="$HOME/.local/share/parvagues/gig-log"
n=$(ls -t "$d" 2>/dev/null | head -n1)
[ -n "$n" ] || exit 1
age=$(( $(date +%s) - $(stat -c%Y "$d/$n") ))
[ "$age" -le 20 ] || exit 2"""),
fix="systemctl --user restart gig-log # it is in rig_units now, so "
"--converge also starts it"),
# ---- the empirical gate, opt-in ---------------------------------------- #
# Last, loud, and only on request: it makes sound and takes ~45s per track.
# Booting the set to confirm what a typecheck just told you is ten wasted
# minutes at a venue, so it is never in the default path.
Probe("audio gate", ["tools/check-tracks.sh"],
fix="read the check-tracks log printed above",
loud=True, slow=True, needs=("scsynth",), timeout=1800),
)
def for_setlist(path: Path, probes=PROBES) -> tuple[Probe, ...]:
"""Point the set-scoped probes at ONE gig's setlist file.
`tools/setlist.py` derives its list from backlog.md, which is the SSOT for
the CURRENT backlog — and on 2026-09-22 that was 15 tracks while the set
being played on the 24th is the 17 in `armada/setlist_thu24.txt`. So the
gate proved that a set PLN is not playing compiles. The launcher had already
noticed half of this and passed `PV_PRELOAD_SETLIST` to the preload check
only; the compile probe was left answering about the wrong set.
The track paths come from `setlist_samples.read_setlist` + `resolve_track` —
never a fresh regex over the file. A `.tidal` is a program, not data, and
this repo has exactly one parser for that reason.
"""
sys.path.insert(0, str(ROOT / "tools"))
import setlist_samples as S
names = S.read_setlist(path)
tracks, missing = [], []
for n in names:
# resolve_track returns a LIST (a name can legitimately match more than
# one file). Flatten it — passing the repr of a list to silent-eval made
# all 17 tracks "fail" on the first run of this code.
hit = S.resolve_track(n)
tracks.extend(hit) if hit else missing.append(n)
if missing:
raise SystemExit(f"check-gig: --setlist {path} names unknown track(s): "
f"{', '.join(map(str, missing))}")
# check-preload.sh reads this itself, and Gate.probe inherits the
# environment, so the preload probe follows without being rewritten.
os.environ["PV_PRELOAD_SETLIST"] = str(path)
out = []
for p in probes:
if p.name == "setlist compiles":
p = replace(p, argv=[PY, "tools/silent-eval.py", "--seeded",
*(str(t) for t in tracks)])
elif p.name == "setlist vs backlog":
# Not applicable: this gig's set is an authored file, not a
# projection of backlog.md. Saying so beats quietly passing.
p = replace(p, kind=ADVISE)
out.append(p)
return tuple(out)
def rig_is_up() -> bool:
"""Is there an audio server? The interpretation key for every live probe.
`pgrep -x`, never `-f`: a `-f` pattern matches the pgrep itself and this is
about to be load-bearing on every boot.
"""
try:
return subprocess.run(["pgrep", "-x", "scsynth"],
capture_output=True).returncode == 0
except OSError:
return False
def machine_records(py: str = PY) -> list[dict]:
"""The machine layer, through gig-preflight's own --json.
A subprocess rather than an import, for one reason: preflight is referenced
standalone by rig-doctor.py, RUNBOOK.md and its own docstring, and that
contract is worth more than the 0.34s a fork costs. Its records already
speak `{check, state, detail, fix}` over OK/WARN/FAIL, so nothing is
translated — the shape is pinned by tools/tests/test_check_gig.py.
"""
tool = ROOT / "tools" / "gig-preflight.py"
if not tool.is_file():
return [{"check": "machine layer", "state": WARN,
"detail": f"{tool} is missing", "fix": "git status tools/"}]
try:
r = subprocess.run([py, str(tool), "--json"], cwd=ROOT,
capture_output=True, text=True, timeout=60)
return json.loads(r.stdout)
except (OSError, ValueError, subprocess.TimeoutExpired) as e:
return [{"check": "machine layer", "state": WARN,
"detail": f"gig-preflight unreadable: {e}",
"fix": "tools/gig-preflight.py # run it directly for the error"}]
# What a precondition name means, and how it is READ — never `systemctl
# is-active`, always the process that makes the sound. `pgrep -x`, never `-f`:
# a `-f` pattern matches the pgrep itself, and this is load-bearing on every
# boot now.
def _running(*names: str) -> bool:
for n in names:
try:
if subprocess.run(["pgrep", "-x", n], capture_output=True).returncode == 0:
return True
except OSError:
pass
return False
# `/usr/bin/ardour` is a SHELL WRAPPER that execs the real binary out of
# /usr/lib/ardour8, so the process name is `ardour` for the first moment and
# `ardour-8.x` after. Match both generations by exact name; the wrapper case is
# covered by "ardour" itself.
PRECONDITIONS = {
"scsynth": lambda: _running("scsynth"),
"ardour": lambda: _running("ardour", "ardour8", "ardour9",
"ardour-8.4.0", "ardour-8.6.0"),
}
_ABSENT = {
"scsynth": "scsynth is not running (rig is cold)",
"ardour": "Ardour is not open (headphones rig, or the DAW is closed)",
}
def plan(probes=PROBES, *, fast=False, audio=False, force=None) -> list[tuple]:
"""Which probes this run asks, and why it skips the rest.
Returns (probe, skip_reason|None). The precondition decision is DETECTED,
not demanded: a flag you must remember is a check you do not run, which is
this gate's founding complaint. `force=True` asks everything anyway (so a
missing rig fails loudly); `force=False` asks nothing that needs one.
"""
met = {k: fn() for k, fn in PRECONDITIONS.items()}
out = []
for p in probes:
if p.loud and not audio:
continue
if p.slow and fast and not p.loud:
continue
if force is True:
out.append((p, None))
continue
missing = [n for n in p.needs if not met.get(n, False)]
if missing or (force is False and p.needs):
why = ("asked to stay cold" if force is False and not missing
else "; ".join(_ABSENT.get(n, n) for n in (missing or p.needs)))
out.append((p, why))
else:
out.append((p, None))
return out
def main(argv=None) -> int:
ap = argparse.ArgumentParser(
prog="check-gig", description=__doc__.split("\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--setlist", metavar="PATH",
help="prove THIS set (e.g. armada/setlist_thu24.txt) instead "
"of whatever backlog.md currently projects")
ap.add_argument("--fast", action="store_true",
help="skip slow probes (the ~12s GHC sweep) — for the boot path")
ap.add_argument("--audio", action="store_true",
help="+ the empirical gate. MAKES SOUND, ~45s per track")
ap.add_argument("--repo", action="store_true", help="the set layer only")
ap.add_argument("--machine", action="store_true", help="the machine layer only")
g = ap.add_mutually_exclusive_group()
g.add_argument("--live", action="store_true",
help="force the live-chain probes (fails loudly with no rig)")
g.add_argument("--cold", action="store_true",
help="force the live-chain probes OFF")
ap.add_argument("-q", "--quiet", action="store_true", help="only WARN/FAIL")
ap.add_argument("--json", action="store_true", help="machine-readable records")
a = ap.parse_args(argv)
both = not (a.repo or a.machine)
live = True if a.live else (False if a.cold else None)
probes = for_setlist(Path(a.setlist), PROBES) if a.setlist else PROBES
gate = Gate(ROOT)
try:
if a.repo or both:
for p, skip_why in plan(probes, fast=a.fast, audio=a.audio, force=live):
gate.skip(p, skip_why) if skip_why else gate.probe(p)
if a.machine or both:
gate.absorb(machine_records(), MACHINE)
if a.json:
print(json.dumps([v.as_record() for v in gate.verdicts], indent=2))
else:
if not a.quiet:
up = rig_is_up()
print(f"check-gig — proving the chain. scsynth={'yes' if up else 'no'}"
f"{'' if up else ' (rig DOWN — cold probes only are meaningful)'}\n")
gate.render(quiet=a.quiet)
if not a.quiet:
# State the limit every time, in these words. check-mix reads
# the SAVED session, so a green fader line means "Ardour will
# BOOT with this fader up" — it says nothing about where the
# physical desk is right now, and the two drift independently.
# A clean report otherwise reads as the stronger claim, which is
# how the -inf bug survived three sessions.
print(" Proves the set BOOTS correct. NOT that the mixer IS "
"correct — check-mix reads\n the saved session, not the "
"live desk. Ctrl+S in Ardour before trusting a green\n"
" fader line.")
if not a.audio:
print(" Nothing here made sound: add --audio for the "
"empirical gate.")
return gate.exit_code()
finally:
gate.close()
if __name__ == "__main__":
sys.exit(main())
......@@ -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)
......
"""One vocabulary for proving a rig, shared by every gig gate.
Why this module exists (2026-09-22)
-----------------------------------
There were three tools with two names. `gig-up.sh` (repo root) LAUNCHES the rig;
`tools/gig-up.sh` PROVES the set; `tools/gig-preflight.py` PROVES the machine.
Both gates accepted `--converge` and `--quiet`, so the two paths looked
interchangeable and were not: the preload gate lived only on the path PLN does
not press, and the readiness report only on the path the Bridge does not call.
A launch opened a second Ardour and that is how it was found.
PLN's ruling: *"structural tooling should ensure we never mistake/forget whats
tooling in each part. unix philo, each thing does one clear role, and combines
with rest?"* So the split is by VERB, and it is mechanical:
gig-up.sh DOES — starts, waits, restores, converges
tools/check-gig.py PROVES — read-only by construction, two layers
tools/gig-up-window.sh SHOWS — owns the terminal and the log
This module is the noun both gates share: a `Probe` (a question plus the fix for
its answer), a `Verdict` (the answer), and a `Gate` that runs probes and renders
one report. `tools/gig-preflight.py` already emits exactly this record shape
(`{check, state, detail, fix}` over `OK/WARN/FAIL`) from its `--json`, which is
why the machine layer needed no rewrite to join in — see `Gate.absorb`.
Design rules, inherited from gig-preflight and now binding on the whole gate:
1. **Green is not evidence.** A probe asserts the property that MAKES SOUND
(a process, a socket, a real link, an mtime), never `systemctl is-active`.
A green unit is not a recording and a lit LED is not a painted surface.
2. **Every failure carries its fix**, as a command that can be typed. A gate
that says NO without saying what to type is a gate ignored under pressure.
3. **The gate mutates nothing.** No unit is started, no radio is blocked, no
`.tidal` is touched, no MIDI is sent. This is what makes it safe to call
from the launcher on every boot, and mid-set at 3am. It is enforced by
`tools/tests/test_check_gig.py`, which reads the probe table rather than
trusting this paragraph.
Rule 3 is the load-bearing one. It is the reason the launcher can end with the
gate: the worst a gate can do is be wrong, never leave the rig in a new state.
"""
from __future__ import annotations
import os
import re
import shlex
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
# The verdict vocabulary. Deliberately the same three strings gig-preflight.py
# has used since 2026-08-22, so its --json records absorb without translation.
OK, WARN, FAIL = "OK", "WARN", "FAIL"
# The fourth answer, and the honest one: this probe's PRECONDITION is absent, so
# it was not asked. "We don't know yet is a legitimate answer" (CLAUDE.md) — and
# the alternative is worse in both directions. Asked anyway, `SC -> Ardour`
# prints NO-GO on every --headphones launch, where there is legitimately no
# Ardour and the fold IS the monitor path; dropped silently, it reproduces the
# 2026-08-02 bug where check-drift.sh was documented as guarding the gate and
# simply never ran. SKIP blocks nothing and hides nothing.
SKIP = "SKIP"
# A probe's severity. The distinction is a judgement made per probe and written
# down next to it, never derived: BLOCK means "there is no gig", ADVISE means
# "this is known-open work or absent hardware". A gate that reddens for the
# second kind is a gate PLN stops reading, which is how a real FAIL gets missed.
BLOCK, ADVISE = "block", "advise"
# Which layer a probe belongs to. One fact, one owner: if the machine layer
# already answers a question, the repo layer does not ask it again.
REPO, MACHINE = "repo", "machine"
_COLOR = {OK: "\033[32m", WARN: "\033[33m", FAIL: "\033[31m", SKIP: "\033[2m"}
_RESET = "\033[0m"
_DIM = "\033[2m"
def shell(script: str) -> list[str]:
"""A probe whose question is genuinely a shell question.
Several probes ask ALSA, `ss`, `pw-link` or systemd something, and each of
those is a pipeline. Rewriting them in Python would buy nothing and spend
the one thing they have that matters: every one of them was written the day
a specific failure was found, and the pipeline IS the finding. So they stay
shell, as data, in one table, with the scar in the comment above them.
Python's triple-quoted strings also drop the nested-escaping level the bash
gate needed (`\\"` inside `'...'` inside `"..."`), which is a readability
win with identical semantics.
"""
return ["bash", "-c", script]
@dataclass(frozen=True)
class Probe:
"""One question, its fix, and how much its answer matters."""
name: str
argv: list[str]
fix: str
kind: str = BLOCK
layer: str = REPO
# Opt-in probes: slow (the GHC sweep) or loud (makes sound). Excluded unless
# asked for by name, because a launch must not wait on them and a gate run
# backstage must not make a noise.
slow: bool = False
# Makes sound. Never run unless asked for by name: the gate's whole value is
# being safe to run mid-set, backstage, and in a quiet room at 3am.
loud: bool = False
# What must already be true for this probe's answer to MEAN anything.
# Names resolved by the caller's precondition table ("scsynth", "ardour").
# Cold-by-default is the right default: cold probes are safe on a train and
# safe mid-set, and a gate that demands hardware is useless at a kitchen
# table. Unmet precondition => SKIP, never FAIL — the severity below is
# about the probe FAILING, not about it being unanswerable.
needs: tuple[str, ...] = ()
timeout: int = 120
def __post_init__(self) -> None:
if not self.argv:
raise ValueError(f"probe {self.name!r} has no command")
if not self.fix:
raise ValueError(f"probe {self.name!r} has no fix — see design rule 2")
@dataclass
class Verdict:
"""One answer. Same shape as a gig-preflight --json record, plus its layer."""
check: str
state: str
detail: str
fix: str = ""
layer: str = REPO
@classmethod
def from_record(cls, rec: dict, layer: str = MACHINE) -> "Verdict":
return cls(
check=str(rec.get("check", "?")),
state=str(rec.get("state", WARN)),
detail=str(rec.get("detail", "")),
fix=str(rec.get("fix", "")),
layer=layer,
)
def as_record(self) -> dict:
return {"check": self.check, "state": self.state,
"detail": self.detail, "fix": self.fix, "layer": self.layer}
class Gate:
"""Runs probes, absorbs foreign verdicts, renders one report, one exit code.
The log is a FILE, always, not a held terminal: it survives the window and
it survives success, and the question after a bad gig is almost always "what
did the launch before this one say".
"""
def __init__(self, root: Path, log: Path | None = None) -> None:
self.root = Path(root)
self.verdicts: list[Verdict] = []
self.log = log or (
Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
/ "parvagues" / "check-gig.log"
)
self.log.parent.mkdir(parents=True, exist_ok=True)
# One log per run, previous kept: the same rule gig-up-window.sh follows.
if self.log.exists():
try:
self.log.replace(self.log.with_suffix(self.log.suffix + ".1"))
except OSError:
pass
self._fh = self.log.open("w")
self._fh.write(f"# check-gig {time.strftime('%F %T')} in {self.root}\n")
# -- running ---------------------------------------------------------- #
def probe(self, p: Probe) -> Verdict:
"""Run one probe. Non-zero exit is FAIL if blocking, WARN if advisory.
A probe that crashes or hangs becomes a WARN naming itself, never an
exception: the gate is called from the boot path, and a broken check
must never be the reason a rig does not come up.
"""
self._fh.write(f"\n########## {p.name} : {shlex.join(p.argv)}\n")
self._fh.flush()
try:
r = subprocess.run(p.argv, cwd=self.root, capture_output=True,
text=True, timeout=p.timeout)
out = (r.stdout or "") + (r.stderr or "")
self._fh.write(out)
rc = r.returncode
except subprocess.TimeoutExpired:
self._fh.write(f"TIMEOUT after {p.timeout}s\n")
return self._add(Verdict(p.name, WARN,
f"timed out after {p.timeout}s",
p.fix, p.layer))
except OSError as e:
self._fh.write(f"OSError {e}\n")
return self._add(Verdict(p.name, WARN,
f"probe itself failed: {e}", p.fix, p.layer))
if rc == 0:
return self._add(Verdict(p.name, OK, self._detail(out, rc) or "ok",
"", p.layer))
state = FAIL if p.kind == BLOCK else WARN
return self._add(Verdict(p.name, state, self._detail(out, rc),
p.fix, p.layer))
@staticmethod
def _detail(out: str, rc: int) -> str:
"""The most useful line of output, not the whole log.
A verdict line is read standing up, in a field. The log holds the rest
and its path is printed once at the end.
"""
lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
if not lines:
return "" if rc == 0 else f"rc={rc}"
head = lines[0] if rc == 0 else lines[-1]
if len(head) > 96:
head = head[:93] + "…"
return head if rc == 0 else f"{head} (rc={rc})"
def skip(self, p: Probe, why: str) -> Verdict:
"""Record a probe that was not asked, and why. Never blocking."""
self._fh.write(f"\n########## {p.name} : SKIPPED ({why})\n")
return self._add(Verdict(p.name, SKIP, f"not asked — {why}", p.fix, p.layer))
def absorb(self, records: list[dict], layer: str = MACHINE) -> None:
"""Take another gate's verdicts as our own.
`tools/gig-preflight.py --json` already speaks this record shape, so the
machine layer joins the report without being rewritten. That is a seam,
not a coincidence, and it is pinned by a test — if preflight's records
ever drift from `{check, state, detail, fix}` the suite says so before a
launch does.
"""
for rec in records:
self._add(Verdict.from_record(rec, layer))
def _add(self, v: Verdict) -> Verdict:
self.verdicts.append(v)
return v
# -- reporting -------------------------------------------------------- #
@property
def failures(self) -> list[Verdict]:
return [v for v in self.verdicts if v.state == FAIL]
@property
def warnings(self) -> list[Verdict]:
return [v for v in self.verdicts if v.state == WARN]
def render(self, quiet: bool = False, stream=None) -> None:
"""One report. Failures FIRST and carrying their fix.
The read order in a field is "what is broken / what do I type", never
"here is a report" — so the verdict table is for the calm case and the
failure block is what a launch actually shows.
"""
stream = stream or sys.stdout
tty = stream.isatty()
def paint(s: str, colour: str) -> str:
return f"{colour}{s}{_RESET}" if tty else s
shown = [v for v in self.verdicts if not (quiet and v.state == OK)]
if shown:
width = max(len(v.check) for v in shown)
for v in shown:
tag = paint(f"{v.state:<4}", _COLOR[v.state])
print(f"{tag} {v.check:<{width}} {v.detail}", file=stream)
if self.failures:
print(file=stream)
print(paint(f"NO-GO — {len(self.failures)} blocking failure(s):",
_COLOR[FAIL]), file=stream)
for v in self.failures:
print(f" {paint('✗', _COLOR[FAIL])} {v.check}", file=stream)
print(f" fix: {v.fix}", file=stream)
if self.warnings and not quiet:
print(file=stream)
for v in self.warnings:
print(f" {paint('!', _COLOR[WARN])} {v.check} — {v.fix or v.detail}",
file=stream)
n_f, n_w = len(self.failures), len(self.warnings)
n_s = sum(1 for v in self.verdicts if v.state == SKIP)
if not quiet or n_f or n_w:
print(file=stream)
skipped = f", {n_s} skipped" if n_s else ""
print(f"{len(self.verdicts)} checks: {n_f} fail, {n_w} warn{skipped}, "
f"{len(self.verdicts) - n_f - n_w - n_s} ok", file=stream)
print(paint(f"detail: {self.log}", _DIM), file=stream)
def close(self) -> None:
try:
self._fh.close()
except OSError:
pass
def exit_code(self) -> int:
return 1 if self.failures else 0
# --------------------------------------------------------------------------- #
# The mutation fence, as code rather than as a promise.
# --------------------------------------------------------------------------- #
# Design rule 3 says the gate mutates nothing. A rule that lives only in a
# docstring is a rule that gets broken by the next well-meaning edit — the same
# migrator that promised it never rewrites comments was doubling `--` on 18
# lines at the time. So the forbidden shapes are written down HERE, mechanically,
# and the test suite reads the probe table through them.
#
# Note it inspects argv, never the `fix` text: a fix hint is SUPPOSED to say
# "systemctl --user restart gig-log". Telling the two apart is the whole trick.
_MUTATING_FLAGS = {"--apply", "--fix", "--restore", "--emit", "--capture",
"--ensure", "--write", "--converge"}
_MUTATING_BINS = {"rfkill", "pkill", "killall", "powerprofilesctl",
"thermal-mode"}
_SYSTEMCTL_VERBS = re.compile(
r"\bsystemctl\b[^\n;|&]*?\b(start|restart|stop|enable|disable|kill)\b")
def mutations(argv: list[str]) -> list[str]:
"""Every way this command could change the world. Empty = safe to gate with."""
found: list[str] = []
for tok in argv:
if tok in _MUTATING_FLAGS:
found.append(tok)
if Path(tok).name in _MUTATING_BINS:
found.append(Path(tok).name)
joined = " ".join(argv)
if _SYSTEMCTL_VERBS.search(joined):
found.append("systemctl <verb>")
for bad in _MUTATING_BINS:
# inside a shell fragment, where the binary is not its own argv token
if re.search(rf"(^|[\s;|&(]){bad}\b", joined):
found.append(bad)
return sorted(set(found))
"""The fence around the gate, as code rather than as a docstring.
`tools/check-gig.py` PROVES; `gig-up.sh` DOES. That split is only real if
something enforces it, because the failure mode is not a typo — it is a
well-meaning edit six weeks from now that adds `--apply` to a probe "while we're
in here", and turns the thing the launcher calls on every boot into a thing that
changes the rig at the venue. The same migrator that promised in its docstring
that it never rewrites comments was doubling `--` on 18 lines at the time.
So the rules that matter are asserted, not written down:
1. **No probe mutates anything.** Checked over the probe table's ARGV, never
over the source text — because a `fix` hint is SUPPOSED to say "systemctl
--user restart gig-log", and telling those two apart is the whole trick.
2. **Every probe carries a fix**, is uniquely named, and has a command.
3. **The port from bash lost nothing.** While `tools/gig-up.sh` still exists,
its `run`/`soft` names must all appear in the table (minus the two the
machine layer now owns). This is the transcription check, and it is why the
bash gate is not deleted in the same commit that replaces it.
4. **The absorbed seam holds.** `gig-preflight.py --json` must keep emitting
`{check, state, detail, fix}` over OK/WARN/FAIL, or the machine layer
silently degrades to a pile of "?" rows.
5. **An unmet precondition never blocks.** SKIP is not FAIL. `SC -> Ardour`
answering "4 links" with Ardour closed is correct and must not print NO-GO
on a --headphones launch.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import subprocess
import sys
import pytest
TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROOT = os.path.dirname(TOOLS)
sys.path.insert(0, TOOLS)
import gig_gate # noqa: E402
def _load(path: str, name: str):
"""Import a dash-named executable. The repo's established idiom."""
spec = importlib.util.spec_from_file_location(name, os.path.join(TOOLS, path))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
CG = _load("check-gig.py", "check_gig")
# The two probes the bash gate carried that the MACHINE layer now owns outright.
# gig-preflight answers both in more detail (governor, EPP, clock ceiling,
# package power, live frequency), and two owners for one fact is how the rig
# once had a converge and a `thermal-mode status` that disagreed about the gear.
DELEGATED_TO_MACHINE = {"gear", "perf mode"}
# --------------------------------------------------------------------------- #
# 1. the mutation fence
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("probe", CG.PROBES, ids=lambda p: p.name)
def test_no_probe_mutates_anything(probe):
found = gig_gate.mutations(probe.argv)
assert not found, (
f"probe {probe.name!r} would change the rig: {found}. The gate is called "
f"from the boot path and mid-set; move the action to gig-up.sh."
)
def test_the_fence_actually_catches_something():
"""The detector needs its own detector.
A guard that cannot fail is a guard that proves nothing — this is the same
lesson as `test_lcxl3_display`'s ghost-file assertions. So hand
`mutations()` the shapes it exists to reject and require each to be caught.
"""
assert gig_gate.mutations(["tools/check-preload.sh", "--fix"]) == ["--fix"]
assert gig_gate.mutations(["python3", "x.py", "--apply"]) == ["--apply"]
assert "rfkill" in gig_gate.mutations(gig_gate.shell("rfkill block bluetooth"))
assert "systemctl <verb>" in gig_gate.mutations(
gig_gate.shell("systemctl --user restart parvagues-sc"))
# …and does NOT reject the reads the gate legitimately makes.
assert gig_gate.mutations(
gig_gate.shell("systemctl --user is-active --quiet lcxl3-driver.service")) == []
assert gig_gate.mutations(
gig_gate.shell("systemctl --user show parvagues-sc.service "
"-p ActiveEnterTimestamp --value")) == []
assert gig_gate.mutations(["pgrep", "-x", "scsynth"]) == []
def test_a_fix_hint_may_name_a_mutating_command():
"""The distinction the fence rests on.
Design rule 2 says every failure carries its fix as a command to TYPE. Those
commands restart units — that is the point. The fence reads argv only, and
this test is what stops someone "tidying" it into a source-text grep.
"""
restarts = [p for p in CG.PROBES if "restart" in p.fix or "--fix" in p.fix]
assert restarts, "expected some fixes to name mutating commands"
for p in restarts:
assert gig_gate.mutations(p.argv) == []
# --------------------------------------------------------------------------- #
# 2. the table is well-formed
# --------------------------------------------------------------------------- #
def test_every_probe_is_complete_and_uniquely_named():
names = [p.name for p in CG.PROBES]
assert len(names) == len(set(names)), "duplicate probe name"
for p in CG.PROBES:
assert p.argv, f"{p.name}: no command"
assert p.fix.strip(), f"{p.name}: no fix (design rule 2)"
assert p.kind in (gig_gate.BLOCK, gig_gate.ADVISE), f"{p.name}: bad severity"
for n in p.needs:
assert n in CG.PRECONDITIONS, f"{p.name}: unknown precondition {n!r}"
def test_the_loud_probe_is_never_in_a_default_run():
"""--audio makes sound for ~45s a track. A gate run backstage must not."""
default = [p for p, skip in CG.plan() if skip is None]
assert not any(p.loud for p in default)
with_audio = [p for p, skip in CG.plan(audio=True) if skip is None]
assert any(p.loud for p in with_audio)
def test_fast_drops_the_slow_probe_and_nothing_else():
"""The boot path's budget. Measured 2026-09-22: 16.5s full, 3.4s --fast."""
full = {p.name for p, _ in CG.plan()}
fast = {p.name for p, _ in CG.plan(fast=True)}
assert full - fast == {p.name for p in CG.PROBES if p.slow and not p.loud}
assert "setlist compiles" in full - fast
# --------------------------------------------------------------------------- #
# 3. the port from bash lost nothing
# --------------------------------------------------------------------------- #
def test_every_bash_gate_check_survived_the_port():
"""The transcription check.
Deliberately reads the OLD script rather than a golden file: a golden of the
output would rot on the first run (the machine layer prints live MHz and
xrun rates), but the NAME SET is stable and is what "lost nothing" means.
Skips once the bash gate is deleted, at which point this table is the truth.
"""
old = os.path.join(TOOLS, "gig-up.sh")
if not os.path.exists(old):
pytest.skip("bash gate deleted — the probe table is now the only source")
text = open(old, encoding="utf-8").read()
bash_names = set(re.findall(r'^\s*(?:run|soft) "([^"]+)"', text, re.M))
assert bash_names, "could not read the bash gate's check names"
ported = {p.name for p in CG.PROBES}
missing = bash_names - ported - DELEGATED_TO_MACHINE
assert not missing, f"checks lost in the port: {sorted(missing)}"
def test_the_delegated_checks_are_answered_by_the_machine_layer():
"""One fact, one owner — but the fact must still have AN owner.
Dropping `gear`/`perf mode` from the repo layer is only correct because
gig-preflight asks about the perf regime. If it ever stops, this is a silent
hole in the gate rather than a tidy-up.
"""
src = open(os.path.join(TOOLS, "gig-preflight.py"), encoding="utf-8").read()
assert 'add("perf regime"' in src
assert 'add("cpu governor"' in src
# --------------------------------------------------------------------------- #
# 4. the absorbed seam
# --------------------------------------------------------------------------- #
def test_preflight_json_still_speaks_the_shared_record_shape():
"""The machine layer joins the report without translation. Keep it that way.
This runs the real tool — it is read-only by its own design rule 3, takes
~0.3s, and asserting the CONTRACT rather than the values is what keeps it
stable on any machine.
"""
out = subprocess.run([sys.executable, os.path.join(TOOLS, "gig-preflight.py"),
"--json"], capture_output=True, text=True, timeout=120)
records = json.loads(out.stdout)
assert records, "gig-preflight --json emitted nothing"
for rec in records:
assert set(rec) >= {"check", "state", "detail", "fix"}, rec
assert rec["state"] in (gig_gate.OK, gig_gate.WARN, gig_gate.FAIL), rec
absorbed = [gig_gate.Verdict.from_record(r) for r in records]
assert all(v.layer == gig_gate.MACHINE for v in absorbed)
assert all(v.check for v in absorbed), "a '?' row means the shape drifted"
# --------------------------------------------------------------------------- #
# 5. an unmet precondition never blocks
# --------------------------------------------------------------------------- #
def test_sc_to_ardour_requires_ardour_not_merely_scsynth():
"""The bug this mechanism exists for.
With scsynth up and Ardour closed the link count is legitimately ~4, and in
--headphones mode there is no Ardour at all — the fold IS the monitor path.
Gating that probe on scsynth alone printed NO-GO on a healthy travel rig.
"""
probe = next(p for p in CG.PROBES if p.name == "SC -> Ardour")
assert "ardour" in probe.needs
def test_an_unmet_precondition_skips_and_does_not_block(tmp_path, monkeypatch):
monkeypatch.setitem(CG.PRECONDITIONS, "scsynth", lambda: False)
monkeypatch.setitem(CG.PRECONDITIONS, "ardour", lambda: False)
planned = dict((p.name, skip) for p, skip in CG.plan())
assert planned["scsynth alive"], "should have been skipped with no rig"
gate = gig_gate.Gate(ROOT, log=tmp_path / "g.log")
try:
probe = next(p for p in CG.PROBES if p.name == "scsynth alive")
v = gate.skip(probe, "scsynth is not running")
assert v.state == gig_gate.SKIP
assert gate.exit_code() == 0, "a SKIP must never be a NO-GO"
assert v not in gate.failures and v not in gate.warnings
finally:
gate.close()
def test_force_live_asks_anyway_so_a_missing_rig_fails_loudly(monkeypatch):
monkeypatch.setitem(CG.PRECONDITIONS, "scsynth", lambda: False)
monkeypatch.setitem(CG.PRECONDITIONS, "ardour", lambda: False)
asked = {p.name for p, skip in CG.plan(force=True) if skip is None}
assert "scsynth alive" in asked, "--live must be able to prove a rig is absent"
def test_a_blocking_failure_is_a_no_go_and_an_advisory_one_is_not(tmp_path):
gate = gig_gate.Gate(ROOT, log=tmp_path / "g.log")
try:
gate.probe(gig_gate.Probe("advisory", ["false"], fix="x",
kind=gig_gate.ADVISE))
assert gate.exit_code() == 0
gate.probe(gig_gate.Probe("blocking", ["false"], fix="x"))
assert gate.exit_code() == 1
finally:
gate.close()
def test_a_probe_that_hangs_becomes_a_warning_not_an_exception(tmp_path):
"""A broken check must never be the reason a rig does not come up."""
gate = gig_gate.Gate(ROOT, log=tmp_path / "g.log")
try:
v = gate.probe(gig_gate.Probe("hangs", ["sleep", "5"], fix="x", timeout=1))
assert v.state == gig_gate.WARN
assert "timed out" in v.detail
assert gate.exit_code() == 0
finally:
gate.close()
def test_a_probe_with_no_fix_is_rejected_at_construction():
with pytest.raises(ValueError):
gig_gate.Probe("nameless", ["true"], fix="")
with pytest.raises(ValueError):
gig_gate.Probe("commandless", [], fix="something")
# --------------------------------------------------------------------------- #
# 6. the launcher keeps its own half of the split
# --------------------------------------------------------------------------- #
def test_the_launcher_does_not_grow_its_own_gate():
"""gig-up.sh DOES; it must not start re-implementing PROVES.
The whole failure being fixed here is a gate that existed but was
unreachable from the path PLN actually presses. If the launcher grows a
second copy of a check, that comes straight back.
"""
src = open(os.path.join(ROOT, "gig-up.sh"), encoding="utf-8").read()
body = "\n".join(ln for ln in src.splitlines() if not ln.lstrip().startswith("#"))
assert 'run "' not in body, "the launcher is growing gate assertions"
assert "check-gig.py" in body, "the launcher must END by calling the gate"
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