diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 5278f089b..29b84dabf 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -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 = 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::>().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::>().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"); } diff --git a/app/igneum-app/src/jobrun.rs b/app/igneum-app/src/jobrun.rs index d7a4765cd..90a9b08fb 100644 --- a/app/igneum-app/src/jobrun.rs +++ b/app/igneum-app/src/jobrun.rs @@ -90,6 +90,76 @@ pub enum Action { /// The relaunch helper was started; the engine quits now. RestartApp, UpdateNow, + /// `--cards-off ` (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), + /// The job ended (done, failed, timeout, aborted, the app quitting): put the cards it held back exactly as they were. + RestoreCards(Vec), +} + +/// 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, + taken: bool, +} + +impl CardHold { + pub fn hold(restore: Vec) -> 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::>().join(", ") + } + pub fn take(&mut self) -> Vec { + 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, Vec) { + 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, } @@ -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, 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, why: &str) -> Option { + 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::>().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::>().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 = 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, restore: Vec) -> Option { + 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, job: Job, run_id: String, ctl: Arc, 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 = 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, ran: Option 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 = 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!["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 diff --git a/packaging/ota/publish-jobs.sh b/packaging/ota/publish-jobs.sh index a6f0577a0..36c410de6 100755 --- a/packaging/ota/publish-jobs.sh +++ b/packaging/ota/publish-jobs.sh @@ -10,7 +10,10 @@ # as run_id job-- (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) diff --git a/tools/ci/playbook-quit-check.sh b/tools/ci/playbook-quit-check.sh new file mode 100644 index 000000000..6936dc995 --- /dev/null +++ b/tools/ci/playbook-quit-check.sh @@ -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 `, 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 )"; 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-' '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