Counter ASIC 3.0 PC 1 AMD: the runner owns a job's card switch: --cards-off <key,key> (matched with or without the device index, switched through the app's card path before the script, restored exactly on any exit including the app quitting; report lines; two tests, igneum-app 114 of 114 on igneum-build-1); playbook-quit-check rule 2 fails any script that requests api/cards (pre-rule playbooks on a dated allow list)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-josh 2026-10-06 19:07:52 +01:00
parent b33e28878f
commit 54557cc2e6
4 changed files with 250 additions and 10 deletions

View file

@ -18,7 +18,7 @@ use std::time::{Duration, Instant};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
const POW_EPOCH_BLOCKS: u64 = 3_600;
#[derive(Clone)]
#[derive(Clone, Debug, PartialEq)]
pub struct CardChoice {
pub key: String,
pub enabled: bool,
@ -2382,6 +2382,25 @@ impl Engine {
}
self.jobs.miners_stopped(&self.shared);
}
Action::CardsOff(keys) => {
// `--cards-off` (6 October 2026): the runner switches the job's cards through the app's own card path
// and keeps the exact choices that put them back; the script never touches /api/cards
let live: Vec<crate::jobrun::LiveCard> = self.st().mining.cards.iter().map(|c| (c.key.clone(), c.enabled, c.identities, c.power_pct, c.present())).collect();
let (off, restore) = crate::jobrun::cards_off_choices(&keys, &live);
if off.is_empty() {
self.shared.event("info", &format!("remote job asked for cards off ({}) but no present card matched; nothing switched", keys.join(",")));
} else {
self.shared.event("info", &format!("remote job: cards off for the job: {}", off.iter().map(|c| c.key.as_str()).collect::<Vec<_>>().join(", ")));
self.apply_cards(off);
}
if let Some(next) = self.jobs.cards_off_done(&self.shared, restore) {
self.job_action(next);
}
}
Action::RestoreCards(choices) => {
self.shared.event("info", &format!("remote job: cards restored: {}", choices.iter().map(|c| format!("{} enabled={} identities={}", c.key, c.enabled, c.identities)).collect::<Vec<_>>().join("; ")));
self.apply_cards(choices);
}
Action::RestartMiners => {
self.stop_miners("remote job: restart miners");
for m in self.miners.iter_mut() {
@ -3307,7 +3326,10 @@ impl Engine {
fn shutdown(&mut self) {
self.shared.log("quit: stopping the miners, then the node");
self.jobs.abort(&self.shared, "the app is quitting");
if let Some(a) = self.jobs.abort(&self.shared, "the app is quitting") {
// the job's cards back before the exit (an app update under a job left the 9070 XT off on 6 October 2026)
self.job_action(a);
}
if self.sweep.is_some() || self.sweep_pending.is_some() {
self.sweep_abort("the app is quitting");
}

View file

@ -90,6 +90,76 @@ pub enum Action {
/// The relaunch helper was started; the engine quits now.
RestartApp,
UpdateNow,
/// `--cards-off <key,key>` (6 October 2026): switch these cards off through the app's own card path before the
/// script starts; then call `cards_off_done` with the exact choices that put them back.
CardsOff(Vec<String>),
/// The job ended (done, failed, timeout, aborted, the app quitting): put the cards it held back exactly as they were.
RestoreCards(Vec<crate::engine::CardChoice>),
}
/// The cards a job holds switched off, and the exact choices that restore them (6 October 2026, the watts job
/// run-ca3-pc1-amd-watts-20261006: a script switched the 9070 XT off itself, an app update killed the script mid-run,
/// its finally never ran, and the card stayed off until a hand POST; so the runner owns the switch and the restore).
/// `take` hands the restore list out once: whoever ends the job (Finished, abort on quit) applies it, never twice.
#[derive(Default, Clone)]
pub struct CardHold {
restore: Vec<crate::engine::CardChoice>,
taken: bool,
}
impl CardHold {
pub fn hold(restore: Vec<crate::engine::CardChoice>) -> Self {
CardHold { restore, taken: false }
}
pub fn is_empty(&self) -> bool {
self.restore.is_empty()
}
pub fn keys(&self) -> String {
self.restore.iter().map(|c| c.key.as_str()).collect::<Vec<_>>().join(", ")
}
pub fn take(&mut self) -> Vec<crate::engine::CardChoice> {
if self.taken {
return vec![];
}
self.taken = true;
self.restore.clone()
}
}
/// `vendor:N:code` -> `vendor:code` (the settings.json key with the device index against the live state's key without it:
/// the 6 October finding that one form switched nothing).
pub fn key_without_index(k: &str) -> String {
let p: Vec<&str> = k.split(':').collect();
if p.len() >= 3 && !p[1].is_empty() && p[1].bytes().all(|b| b.is_ascii_digit()) {
format!("{}:{}", p[0], p[2..].join(":"))
} else {
k.to_string()
}
}
/// One live card as `cards_off_choices` sees it: key, enabled, identities, power_pct, present.
pub type LiveCard = (String, bool, u32, u32, bool);
/// The `--cards-off` match: every present live card whose key equals a requested key with or without the device
/// index. Returns (the choices that switch them off, the choices that put them back exactly: enabled flag, identities
/// and cap as they were). A requested key that matches no live card switches nothing (apply_cards skips unknown keys).
pub fn cards_off_choices(requested: &[String], live: &[LiveCard]) -> (Vec<crate::engine::CardChoice>, Vec<crate::engine::CardChoice>) {
let mut off = Vec::new();
let mut restore = Vec::new();
for (key, enabled, identities, power_pct, present) in live {
if !present {
continue;
}
let bare = key_without_index(key);
let hit = requested.iter().any(|r| r == key || key_without_index(r) == bare);
if !hit {
continue;
}
let pp = if *power_pct > 0 { Some(*power_pct) } else { None };
off.push(crate::engine::CardChoice { key: key.clone(), enabled: false, identities: *identities, power_pct: pp });
restore.push(crate::engine::CardChoice { key: key.clone(), enabled: *enabled, identities: *identities, power_pct: pp });
}
(off, restore)
}
/// Shared with the job's thread: the abort flag and the child's pid, so the engine can end a job on quit.
@ -112,6 +182,9 @@ struct Active {
started_unix: u64,
waiting_for_miners: bool,
holds_miners: bool,
/// `--cards-off`: the engine switches the cards before the script runs
waiting_for_cards: bool,
cards: CardHold,
ctl: Arc<Ctl>,
}
@ -266,18 +339,27 @@ impl Jobs {
}
/// Ends the running job (quit, or the switch turned off). The engine releases the miners itself.
pub fn abort(&mut self, shared: &Arc<Shared>, why: &str) {
let Some(a) = self.active.as_ref() else { return };
/// Returns the cards the job held, for the engine to put back BEFORE it goes on (on quit this runs on the engine
/// thread ahead of the exit, so an app update under a job no longer leaves a card off: 6 October 2026, 17:52Z).
pub fn abort(&mut self, shared: &Arc<Shared>, why: &str) -> Option<Action> {
let Some(a) = self.active.as_mut() else { return None };
a.ctl.abort.store(true, Ordering::Relaxed);
if let Some(pid) = *a.ctl.pid.lock().unwrap() {
kill_tree(pid);
}
shared.log(&format!("job {}: aborted ({why})", a.job.id));
let id = a.job.id.clone();
let restore = a.cards.take();
self.ledger.finish(&id, "aborted", -1, why, false, crate::platform::unix_now());
let _ = self.ledger.save(&self.ledger_path);
self.active = None;
self.publish(shared);
if restore.is_empty() {
None
} else {
shared.event("info", &format!("job {}: cards restored by the runner (aborted: {why}): {}", id, restore.iter().map(|c| c.key.as_str()).collect::<Vec<_>>().join(", ")));
Some(Action::RestoreCards(restore))
}
}
// ---- the tick ------------------------------------------------------------------------------------------------
@ -426,7 +508,8 @@ impl Jobs {
if a.job.id != id {
return None;
}
let a = self.active.take().unwrap();
let mut a = self.active.take().unwrap();
let restore = a.cards.take();
let now = crate::platform::unix_now();
self.ledger.finish(&id, &outcome.status, outcome.exit, &outcome.summary, outcome.uploaded, now);
let _ = self.ledger.save(&self.ledger_path);
@ -442,7 +525,12 @@ impl Jobs {
st.jobs.last_results = outcome.results.clone();
}
self.publish(shared);
None
if restore.is_empty() {
None
} else {
shared.event("info", &format!("job {}: cards restored by the runner ({}): {}", id, outcome.status, restore.iter().map(|c| c.key.as_str()).collect::<Vec<_>>().join(", ")));
Some(Action::RestoreCards(restore))
}
}
}
}
@ -473,7 +561,8 @@ impl Jobs {
shared.event("info", &format!("job {} ({}) starts: {}", job.id, job.kind, job.label()));
let ctl = Arc::new(Ctl::default());
let needs_miners_stopped = job.kind == "shard-benchmark" || (job.kind == "run" && job.bool_param("stop_miners_first"));
self.active = Some(Active { job: job.clone(), run_id: run_id.clone(), started: Instant::now(), started_unix: now, waiting_for_miners: needs_miners_stopped, holds_miners: false, ctl: ctl.clone() });
let cards_off: Vec<String> = if job.kind == "run" { job.list_param("cards_off") } else { vec![] };
self.active = Some(Active { job: job.clone(), run_id: run_id.clone(), started: Instant::now(), started_unix: now, waiting_for_miners: needs_miners_stopped, holds_miners: false, waiting_for_cards: !cards_off.is_empty(), cards: CardHold::default(), ctl: ctl.clone() });
match job.kind.as_str() {
"restart" | "update-now" => {
// engine-side; the report says what was asked and the ledger closes at once
@ -498,6 +587,8 @@ impl Jobs {
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o });
Some(action)
}
// the cards first (the engine answers through cards_off_done), then the miners, then the script
_ if !cards_off.is_empty() => Some(Action::CardsOff(cards_off)),
_ if needs_miners_stopped => Some(Action::StopMiners(format!("job {}: {}", job.id, job.label()))),
_ => {
self.spawn_run(shared, job, run_id, ctl, now);
@ -506,14 +597,42 @@ impl Jobs {
}
}
/// Called by the engine once a job's `--cards-off` cards are switched off; `restore` puts them back exactly.
/// Next: the miners, if the job asked for them too, else the script.
pub fn cards_off_done(&mut self, shared: &Arc<Shared>, restore: Vec<crate::engine::CardChoice>) -> Option<Action> {
let Some(a) = self.active.as_mut() else { return None };
if !a.waiting_for_cards {
return None;
}
a.waiting_for_cards = false;
a.cards = CardHold::hold(restore);
if a.waiting_for_miners {
return Some(Action::StopMiners(format!("job {}: {}", a.job.id, a.job.label())));
}
let (job, run_id, ctl, started) = (a.job.clone(), a.run_id.clone(), a.ctl.clone(), a.started_unix);
self.spawn_run(shared, job, run_id, ctl, started);
self.publish(shared);
None
}
fn spawn_run(&self, shared: &Arc<Shared>, job: Job, run_id: String, ctl: Arc<Ctl>, started: u64) {
let shared2 = shared.clone();
let dir = self.dir.clone();
let data_root = self.data_root.clone();
let jobs_url = self.url.clone();
let held = self.active.as_ref().map(|a| a.cards.clone()).unwrap_or_default();
let asked: Vec<String> = if job.kind == "run" { job.list_param("cards_off") } else { vec![] };
std::thread::spawn(move || {
let sink = Sink::new(&shared2, &job, &dir);
sink.line(&format!("job {} ({}) on {} machine {} run {run_id}, started {}", job.id, job.kind, shared2.runtime.host, shared2.runtime.machine_id, jobs::format_time(started)));
if !asked.is_empty() {
// the runner's own lines: what it switched off and what it will put back, whatever the script does
if held.is_empty() {
sink.line(&format!("cards-off: {} asked, no present card matched (nothing switched; the script's card may be loaded)", asked.join(",")));
} else {
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards restored: {} by the runner on any exit (done, failed, timeout, aborted, app quit), with their own enabled flag, identities and cap", held.keys(), held.keys()));
}
}
let ctx = account_context();
sink.line(&format!("account: {ctx}"));
let warn = account_warning(&ctx);
@ -1263,6 +1382,46 @@ fn collect_done(uploaded: u32, failed: u32, names: Vec<String>, ran: Option<Ran>
mod tests {
use super::*;
#[test]
fn cards_off_matches_keys_with_and_without_the_device_index_and_restores_exactly() {
// PC 1, 6 October 2026: settings.json holds amd:1:gfx1201 and amd:3:gfx1201, the live state's key is amd:gfx1201
let live: Vec<LiveCard> = vec![
("amd:gfx1201".into(), true, 8, 0, true),
("nvidia:NVIDIA GeForce RTX 5090".into(), true, 8, 75, true),
("nvidia:NVIDIA GeForce RTX 4070".into(), false, 2, 80, true),
("amd:gfx1036".into(), false, 1, 0, false),
];
let (off, restore) = cards_off_choices(&["amd:1:gfx1201".to_string()], &live);
assert_eq!(off.len(), 1);
assert_eq!((off[0].key.as_str(), off[0].enabled, off[0].identities, off[0].power_pct), ("amd:gfx1201", false, 8, None));
assert_eq!((restore[0].key.as_str(), restore[0].enabled, restore[0].identities), ("amd:gfx1201", true, 8));
// the live key itself, and the name form of an NVIDIA card: its cap rides along; a disabled card restores as disabled
let (off, restore) = cards_off_choices(&["amd:gfx1201".to_string(), "nvidia:1:NVIDIA GeForce RTX 4070".to_string()], &live);
assert_eq!(off.iter().map(|c| c.key.as_str()).collect::<Vec<_>>(), vec!["amd:gfx1201", "nvidia:NVIDIA GeForce RTX 4070"]);
assert_eq!((restore[1].enabled, restore[1].identities, restore[1].power_pct), (false, 2, Some(80)));
// a key that matches nothing switches nothing; a removed card is never touched
assert!(cards_off_choices(&["amd:gfx1036".to_string(), "intel:0:x".to_string()], &live).0.is_empty());
assert_eq!(key_without_index("nvidia:0:NVIDIA GeForce RTX 5090"), "nvidia:NVIDIA GeForce RTX 5090");
assert_eq!(key_without_index("amd:gfx1201"), "amd:gfx1201");
}
#[test]
fn a_card_hold_restores_once_on_any_exit() {
// the known-failed case: the script dies mid-run (exit 1, or the app quits under it); the restore list comes
// out exactly once, whichever path ends the job, and never a second time
let restore = vec![crate::engine::CardChoice { key: "amd:gfx1201".into(), enabled: true, identities: 8, power_pct: None }];
let mut done = CardHold::hold(restore.clone());
assert_eq!(done.take(), restore, "the known-finished case (done) restores");
assert!(done.take().is_empty(), "never twice");
let mut failed = CardHold::hold(restore.clone());
assert_eq!(failed.take(), restore, "the known-failed case (the script died, exit 1) restores the same list");
let mut quit = CardHold::hold(restore.clone());
assert_eq!(quit.take(), restore, "the app quitting under the job (abort) restores the same list");
assert!(quit.take().is_empty());
assert!(CardHold::default().take().is_empty(), "a job without --cards-off restores nothing");
assert_eq!(CardHold::hold(restore).keys(), "amd:gfx1201");
}
#[test]
fn a_refused_administrator_prompt_is_a_failure_not_done() {
// 5 October 2026: the elevated launcher exited 0 after Windows cancelled an unanswered UAC prompt

View file

@ -10,7 +10,10 @@
# as run_id job-<id>-<machine id8> (read back with tools/jobs.mjs).
#
# packaging/ota/publish-jobs.sh add --kind run --target 1ccfe586 --script path.ps1 [--elevated] [--stop-miners] \
# [--timeout-minutes 60] [--shell powershell|bash] --title "..." [--expires-hours 48] [--deploy]
# [--cards-off key,key] [--timeout-minutes 60] [--shell powershell|bash] --title "..." [--expires-hours 48] [--deploy]
# (--cards-off: the RUNNER switches these cards off through the app's own card path before the script and puts them
# back exactly on any exit, done, failed, timeout, aborted or the app quitting; keys with or without the device
# index; a script never posts to /api/cards itself: tools/ci/playbook-quit-check.sh, 6 October 2026)
# packaging/ota/publish-jobs.sh add --kind fetch --target all --file ~/Desktop/x.zip [--dir jobs|prove|packs|updates] \
# [--to name] [--extract] [--extract-dir sub] [--fresh] (or --url https://... --sha256 ... [--size N])
# packaging/ota/publish-jobs.sh add --kind collect --target all --glob "logs/app-*.log" [--glob ...] [--command "nvidia-smi"]
@ -48,7 +51,7 @@ SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign"
CMD="${1:-}"; [ $# -gt 0 ] && shift
KIND="" TARGET="" PLATFORM="" REQUIRES="" REQUIRES_SET=0 ID="" TITLE="" EXPIRES_H="48" DEPLOY=0 BASE="" DEST="" TRIES=12
SCRIPT="" SHELL_KIND="" ELEVATED=0 STOP_MINERS=0 TIMEOUT_MIN=""
SCRIPT="" SHELL_KIND="" ELEVATED=0 STOP_MINERS=0 TIMEOUT_MIN="" CARDS_OFF=""
FILE="" URL="" SHA="" SIZE="" DIR="" TO="" EXTRACT=0 EXTRACT_DIR="" FRESH=0
GLOBS=() COMMAND="" WHAT=""
ZIP="" FIXTURES="" CAP_MIN="" DISTRO="" WSL_USER="" REMOVE_ID=""
@ -69,6 +72,7 @@ while [ $# -gt 0 ]; do
--shell) SHELL_KIND="$2"; shift 2 ;;
--elevated) ELEVATED=1; shift ;;
--stop-miners) STOP_MINERS=1; shift ;;
--cards-off) CARDS_OFF="$2"; shift 2 ;;
--timeout-minutes) TIMEOUT_MIN="$2"; shift 2 ;;
--file) FILE="$2"; shift 2 ;;
--url) URL="$2"; shift 2 ;;
@ -272,7 +276,7 @@ if [ "$CMD" = add ]; then
fi
out="$(bash "$ROOT/tools/ci/prover-socket-check.sh" "$SCRIPT" 2>&1)" || { echo "run: prover-socket-check.sh refuses $SCRIPT:" >&2; printf '%s\n' "$out" >&2; exit 1; }
out="$(bash "$ROOT/tools/ci/kit-path-check.sh" "$SCRIPT" 2>&1)" || { echo "run: kit-path-check.sh refuses $SCRIPT:" >&2; printf '%s\n' "$out" >&2; exit 1; }
PARAMS="$(python3 -c 'import json,sys; print(json.dumps({"script": open(sys.argv[1]).read(), "shell": sys.argv[2], "elevated": sys.argv[3]=="1", "stop_miners_first": sys.argv[4]=="1", **({"timeout_minutes": int(sys.argv[5])} if sys.argv[5] else {})}))' "$SCRIPT" "$SHELL_KIND" "$ELEVATED" "$STOP_MINERS" "$TIMEOUT_MIN")"
PARAMS="$(python3 -c 'import json,sys; print(json.dumps({"script": open(sys.argv[1]).read(), "shell": sys.argv[2], "elevated": sys.argv[3]=="1", "stop_miners_first": sys.argv[4]=="1", **({"timeout_minutes": int(sys.argv[5])} if sys.argv[5] else {}), **({"cards_off": [k.strip() for k in sys.argv[6].split(",") if k.strip()]} if sys.argv[6] else {})}))' "$SCRIPT" "$SHELL_KIND" "$ELEVATED" "$STOP_MINERS" "$TIMEOUT_MIN" "$CARDS_OFF")"
[ -n "$TITLE" ] || TITLE="run $(basename "$SCRIPT")"
;;
fetch)

View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# The standing rule of 5 October 2026, 23:05 UTC (CLAUDE.md): a job never quits, pauses, resumes or restarts the
# installed app it did not start. A test engine started by a job runs on its own data dir with its own URL file; a
# job may send quit, pause or resume only to an engine it started itself (the URL it created); the installed app is
# touched only through the signed `restart` and `update-now` job kinds. This check fails any playbook or script under
# relay/playbooks, tools/windows, tools/proving-v1 or packaging that reads the INSTALLED app's URL file
# (%LOCALAPPDATA%\igneum\app\app.url, $env:IGNEUM_APP_DIR\app.url, ~/Library/Application Support/Igneum/app/app.url)
# and sends api/quit, api/pause or api/resume. A scratch root's own app.url (igneum-tune-*, igneum-sweep) is fine.
# Rule 2 (6 October 2026, 18:xx UTC, main): a script never switches the installed app's cards either. A POST to
# /api/cards from a script is the same class as /api/pause from a script: the watts job run-ca3-pc1-amd-watts-20261006
# posted the 9070 XT off, an app update killed the script mid-run, its finally never ran and the card stayed off
# until a hand POST. A job that needs a card alone asks the RUNNER for it (`publish-jobs.sh add --kind run
# --cards-off <key,key>`, app/igneum-app/src/jobrun.rs: switched before the script, restored on ANY exit). This check
# fails any script that sends a request to api/cards (any method, any enabled value: the restore half is a POST too).
# Reading /api/state stays fine.
# bash tools/ci/playbook-quit-check.sh [--self-test]
set -euo pipefail
cd "$(dirname "$0")/../.."
check_file() {
local f="$1" bad=0
# rule 2: any request to api/cards outside a comment (Invoke-RestMethod, Invoke-WebRequest, curl, fetch: the shape is the URL)
if grep -vE '^\s*#' "$f" | grep -qE "api/cards"; then
echo "playbook-quit: $f sends a request to the installed app's api/cards (a script never switches cards; ask the runner: publish-jobs.sh add --kind run --cards-off <key,key>)"; bad=1
fi
grep -qE "api/(quit|pause|resume)" "$f" || return $bad
if grep -vE '^\s*#' "$f" | grep -qE "igneum\\\\app\\\\app\.url|igneum/app/app\.url|Application Support/Igneum/app/app\.url|IGNEUM_APP_DIR[^\n]*app\.url|\\\$appDir[^\n]*'app\.url'"; then
echo "playbook-quit: $f reads the installed app's URL file and sends quit, pause or resume to it (a job may only quit an engine it started: its own scratch URL file)"; bad=1
fi
return $bad
}
if [ "${1:-}" = "--self-test" ]; then
t="$(mktemp -d)"
printf '%s\n' '$urlFile = Join-Path $env:LOCALAPPDATA '"'"'igneum\app\app.url'"'"'' 'Invoke-WebRequest -Uri ($url + '"'"'api/quit'"'"') -Method POST' > "$t/bad.ps1"
printf '%s\n' '$u = Join-Path $sApp '"'"'app.url'"'"' # $sApp = $root\app, $root = igneum-tune-<stamp>' 'Invoke-WebRequest -Uri ((Get-Content $u) + '"'"'api/quit'"'"')' > "$t/good.ps1"
printf '%s\n' '$body = @{ cards = @(@{ key = $k; enabled = $false; identities = 2 }) } | ConvertTo-Json' 'Invoke-RestMethod -Uri "$base/api/cards" -Method POST -Body $body' > "$t/cards.ps1"
printf '%s\n' '# the old shape posted enabled=False to api/cards; now the runner does it' '$st = Invoke-RestMethod -Uri "$base/api/state" -Method GET' > "$t/state.ps1"
if check_file "$t/bad.ps1" >/dev/null; then echo "self-test FAILED: the bad playbook passed"; exit 1; fi
if ! check_file "$t/good.ps1"; then echo "self-test FAILED: the good playbook failed"; exit 1; fi
if check_file "$t/cards.ps1" >/dev/null; then echo "self-test FAILED: the api/cards switch passed"; exit 1; fi
if ! check_file "$t/state.ps1"; then echo "self-test FAILED: a read of api/state (api/cards only in a comment) failed"; exit 1; fi
rm -rf "$t"; echo "self-test passed: the installed app's URL file with a quit fails, a scratch URL file passes, a request to api/cards fails, a read of api/state passes"; exit 0
fi
# allowed senders: the installer's own stop step (the update-now path the rule names), and, pending the rule owner's
# word (6 October 2026, 15:10 UTC): tools/proving-v1/pc2-agg-cost.ps1 and its restore step pc2-agg-cost-restore.ps1, which switch the 5090 off through /api/cards
# and falls back to /api/pause with /api/resume in its finally block (the aggregation-cost agent's measurement; the
# rule's letter forbids pause and resume of the installed app, its owner decides whether a card switch's fallback is one)
# rule 2's pre-rule playbooks (5 and 6 October 2026, before 18:xx UTC on the 6th), kept as the record of runs already made and
# never republished: the 5 October PC 1 benches (mixer-x4, readwidth, era, dot4, hot), the 6 October PC 2 family probe and
# derive job, and the coordinator's hand restore of the 9070 XT (pc1-amd-identities, 6 October: the rule owner's call)
PRE_RULE='^(relay/playbooks/(mixer-x4-pc1-bench|mixer-x4-5090-bench|mixer-x4-9070-bench|readwidth-5090-bench|readwidth-5090|readwidth-9070-bench|readwidth-9070|ca2-era-pc1|ca2-hot-5090-bench|ca2-hot-9070-bench|dot4-probe|ca3-derive-pc2)\.ps1|tools/ca3-reserve/pc2-family-probe\.ps1|tools/ca3-pc1-amd/pc1-amd-identities\.ps1)$'
ALLOW='^(packaging/windows/stop-igneum\.ps1|tools/proving-v1/pc2-agg-cost\.ps1|tools/proving-v1/pc2-agg-cost-restore\.ps1)$'
fail=0
while IFS= read -r f; do [[ "$f" =~ $ALLOW ]] && continue; [[ "$f" =~ $PRE_RULE ]] && continue; check_file "$f" || fail=1; done < <(git ls-files 'relay/playbooks/**' 'tools/**' 'packaging/**' | grep -E '\.(ps1|sh)$' | grep -v '^tools/ci/')
[ "$fail" = 0 ] && echo "playbook-quit: no playbook quits, pauses, resumes or switches the cards of the installed app"
exit $fail