Remote jobs: cards_leave_off, a run job's --cards-off cards restored as OFF on any exit (persisted by the app's own card path)

The way to hold a card out of mining past a job without a script on api/cards (the 6 October rule): the runner's hold
is built with enabled=false (identities and cap kept), the report line says LEFT OFF, publish-jobs.sh carries
--cards-leave-off. For the Arc B580 on PC 1 while its worker fix rides to the shipped app. Test:
cards_leave_off_restores_the_card_as_off_with_its_settings_kept (igneum-app 157 of 157 on the box).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 9e794503d2e9689ae3a0996e703cb97ea6d95cef)
This commit is contained in:
igneum-labs 2026-10-07 11:31:38 +00:00
parent 4ee4bc69d3
commit 00e8c2d1b4
3 changed files with 41 additions and 5 deletions

View file

@ -137,6 +137,12 @@ pub fn key_without_index(k: &str) -> String {
}
}
/// The restore list of a `cards_leave_off` job: the same entries (identities and cap kept) with enabled=false, so the
/// runner's restore on any exit leaves the card off, persisted by the app's own card path.
pub fn leave_off(restore: Vec<crate::engine::CardChoice>) -> Vec<crate::engine::CardChoice> {
restore.into_iter().map(|mut c| { c.enabled = false; c }).collect()
}
/// One live card as `cards_off_choices` sees it: key, enabled, identities, power_pct, present.
pub type LiveCard = (String, bool, u32, u32, bool);
@ -605,6 +611,10 @@ impl Jobs {
return None;
}
a.waiting_for_cards = false;
// cards_leave_off (7 October 2026, intel-arc): the hold still ends through the app's own card path on any
// exit, but with enabled=false, so the card stays off and persisted (settings.json) after the job: the way
// to hold a card out of mining past a job without a script touching api/cards (the 6 October rule).
let restore = if a.job.bool_param("cards_leave_off") { leave_off(restore) } else { restore };
a.cards = CardHold::hold(restore);
if a.waiting_for_miners {
return Some(Action::StopMiners(format!("job {}: {}", a.job.id, a.job.label())));
@ -630,7 +640,11 @@ impl Jobs {
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()));
if job.bool_param("cards_leave_off") {
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards LEFT OFF: {} by the runner on any exit (done, failed, timeout, aborted, app quit), enabled=false with their own identities and cap, persisted by the app (cards_leave_off)", held.keys(), held.keys()));
} 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();
@ -1434,6 +1448,23 @@ fn collect_done(uploaded: u32, failed: u32, names: Vec<String>, ran: Option<Ran>
mod tests {
use super::*;
/// cards_leave_off (7 October 2026): the restore entries keep their identities and cap and carry enabled=false,
/// so the job's exit leaves the card off through the same path; without the param they keep their own flag.
#[test]
fn cards_leave_off_restores_the_card_as_off_with_its_settings_kept() {
let live: Vec<LiveCard> = vec![("other:Intel(R) Arc(TM) B580 Graphics".into(), true, 8, 0, true), ("nvidia:NVIDIA GeForce RTX 5090".into(), true, 2, 80, true)];
let (off, restore) = cards_off_choices(&["other:Intel(R) Arc(TM) B580 Graphics".to_string()], &live);
assert_eq!(off.len(), 1);
assert!(restore[0].enabled, "the plain restore puts the card back on");
let left = leave_off(restore.clone());
assert_eq!(left.len(), 1);
assert_eq!((left[0].key.as_str(), left[0].enabled, left[0].identities, left[0].power_pct), ("other:Intel(R) Arc(TM) B580 Graphics", false, 8, restore[0].power_pct));
// the job's param decides, through the same hold
let j = jobs::parse(r#"{"jobs":[{"id":"x","kind":"run","expires_at":"2099-01-01T00:00:00Z","target":{"machine_ids":["ae432dc7"]},"params":{"script":"ls","cards_off":["other:Intel(R) Arc(TM) B580 Graphics"],"cards_leave_off":true}}]}"#).unwrap().jobs.remove(0);
assert!(j.bool_param("cards_leave_off"));
assert_eq!(j.list_param("cards_off"), vec!["other:Intel(R) Arc(TM) B580 Graphics".to_string()]);
}
#[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

View file

@ -22,7 +22,9 @@
//!
//! Kinds and their params:
//! run script (the body), shell powershell|bash (default per platform), elevated, stop_miners_first,
//! timeout_minutes (default 60, at most 600)
//! timeout_minutes (default 60, at most 600), cards_off [keys] (the runner switches them off before
//! the script and restores them on any exit), cards_leave_off (with cards_off: restored as OFF, so
//! the card stays off and persisted after the job; 7 October 2026, the Arc on PC 1)
//! fetch url (https), sha256, size, to (file name), dir jobs|prove|packs|updates (default jobs, which is
//! <app data>/app/jobs/<id>/), extract (tar -xf into the dir), fresh (empty extract_dir first), extract_dir
//! collect globs ["logs/app-*.log", ...] relative to the app data root (* and ? per path component), command

View file

@ -10,7 +10,9 @@
# 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] \
# [--cards-off key,key] [--timeout-minutes 60] [--shell powershell|bash] --title "..." [--expires-hours 48] [--deploy]
# [--cards-off key,key] [--cards-leave-off] [--timeout-minutes 60] [--shell powershell|bash] --title "..." [--expires-hours 48] [--deploy]
# (--cards-leave-off, with --cards-off: the runner restores the cards as OFF on any exit, so they stay off and
# persisted by the app after the job; the way to hold a card out of mining without a script on api/cards, 7 October 2026)
# (--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)
@ -52,7 +54,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="" CARDS_OFF=""
SCRIPT="" SHELL_KIND="" ELEVATED=0 STOP_MINERS=0 TIMEOUT_MIN="" CARDS_OFF="" CARDS_LEAVE_OFF=0
FILE="" URL="" SHA="" SIZE="" DIR="" TO="" EXTRACT=0 EXTRACT_DIR="" FRESH=0
GLOBS=() COMMAND="" WHAT=""
ZIP="" FIXTURES="" CAP_MIN="" DISTRO="" WSL_USER="" REMOVE_ID=""
@ -74,6 +76,7 @@ while [ $# -gt 0 ]; do
--elevated) ELEVATED=1; shift ;;
--stop-miners) STOP_MINERS=1; shift ;;
--cards-off) CARDS_OFF="$2"; shift 2 ;;
--cards-leave-off) CARDS_LEAVE_OFF=1; shift ;;
--timeout-minutes) TIMEOUT_MIN="$2"; shift 2 ;;
--file) FILE="$2"; shift 2 ;;
--url) URL="$2"; shift 2 ;;
@ -277,7 +280,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 {}), **({"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")"
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 {}), **({"cards_leave_off": True} if sys.argv[7]=="1" else {})}))' "$SCRIPT" "$SHELL_KIND" "$ELEVATED" "$STOP_MINERS" "$TIMEOUT_MIN" "$CARDS_OFF" "$CARDS_LEAVE_OFF")"
[ -n "$TITLE" ] || TITLE="run $(basename "$SCRIPT")"
;;
fetch)