Igneum Miner 0.3.13: the execution layer's restart at the pruning point (the devnet's one-time state reset), the finality route fix, the Power Helper

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 15:52:58 +00:00
commit 9519a95988
24 changed files with 751 additions and 71 deletions

View file

@ -71,6 +71,8 @@ jobs:
run: bash tools/ci/copied-sources-check.sh run: bash tools/ci/copied-sources-check.sh
- name: second-engine playbooks log to a file and end their tree (C35) - name: second-engine playbooks log to a file and end their tree (C35)
run: bash tools/ci/second-engine-check.sh run: bash tools/ci/second-engine-check.sh
- name: no playbook quits, pauses or resumes the installed app (self-test first, then the tree)
run: bash tools/ci/playbook-quit-check.sh --self-test && bash tools/ci/playbook-quit-check.sh
- name: the signer is never piped into head - name: the signer is never piped into head
run: bash tools/ci/signer-pipe-check.sh run: bash tools/ci/signer-pipe-check.sh
- name: bash bodies in PowerShell job scripts pass bash -n, the lost-quote class (self-test first, then the tree) - name: bash bodies in PowerShell job scripts pass bash -n, the lost-quote class (self-test first, then the tree)

View file

@ -219,7 +219,7 @@ dependencies = [
[[package]] [[package]]
name = "igneum-app" name = "igneum-app"
version = "0.3.12" version = "0.3.13"
dependencies = [ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"getrandom", "getrandom",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "igneum-app" name = "igneum-app"
version = "0.3.12" version = "0.3.13"
edition = "2021" edition = "2021"
description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1" description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1"
license = "MIT" license = "MIT"

View file

@ -6,8 +6,8 @@
1 ICON "igneum.ico" 1 ICON "igneum.ico"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,3,12,0 FILEVERSION 0,3,13,0
PRODUCTVERSION 0,3,12,0 PRODUCTVERSION 0,3,13,0
FILEFLAGSMASK 0x3fL FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
@ -20,12 +20,12 @@ BEGIN
BEGIN BEGIN
VALUE "CompanyName", "Igneum" VALUE "CompanyName", "Igneum"
VALUE "FileDescription", "Igneum Miner engine" VALUE "FileDescription", "Igneum Miner engine"
VALUE "FileVersion", "0.3.12" VALUE "FileVersion", "0.3.13"
VALUE "InternalName", "igneum-app" VALUE "InternalName", "igneum-app"
VALUE "LegalCopyright", "Igneum contributors" VALUE "LegalCopyright", "Igneum contributors"
VALUE "OriginalFilename", "igneum-app.exe" VALUE "OriginalFilename", "igneum-app.exe"
VALUE "ProductName", "Igneum Miner" VALUE "ProductName", "Igneum Miner"
VALUE "ProductVersion", "0.3.12" VALUE "ProductVersion", "0.3.13"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"

View file

@ -124,6 +124,28 @@ impl Default for Settings {
} }
impl Settings { impl Settings {
/// What a measurement engine (`--sweep`, started by a job beside the installed app) runs with, whatever the copied
/// file says: no remote jobs (run 4, 6 October 2026: the second engine fetched the jobs file and ran 96 old jobs
/// inside its scratch root), no updates, no proving, not paused, the tune on, Power control off (only an engine
/// that is itself elevated controls NVIDIA, through the probe's `direct`), every card due and unpinned. The file
/// on disk is never changed: the playbook copies the installed app's settings verbatim (a PowerShell JSON round
/// trip rewrote big integers as doubles and the engine read the whole file as defaults: no payout address, every
/// card off).
pub fn for_measurement(mut self) -> Settings {
self.remote_jobs = false;
self.auto_update = false;
self.prove = false;
self.paused = false;
self.sweep = true;
self.power_control = false;
self.setup_done = true;
for p in self.cards.values_mut() {
p.sweep_at = 0;
p.pinned = false;
}
self
}
pub fn load(path: &Path) -> Settings { pub fn load(path: &Path) -> Settings {
let mut s: Settings = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default(); let mut s: Settings = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
let mut dirty = false; let mut dirty = false;
@ -374,6 +396,18 @@ mod tests {
out out
} }
#[test]
fn a_measurement_engine_overrides_the_copied_settings_in_memory() {
let mut s = Settings { remote_jobs: true, auto_update: true, prove: true, paused: true, sweep: false, power_control: true, address: "0xabc".into(), ..Default::default() };
s.cards.insert("nvidia:0:x".into(), CardPref { enabled: true, identities: 8, sweep_at: 1_791_000_000, pinned: true, power_pct: 70, ..Default::default() });
let m = s.for_measurement();
assert!(!m.remote_jobs && !m.auto_update && !m.prove && !m.paused && m.sweep && !m.power_control && m.setup_done);
assert_eq!(m.address, "0xabc", "the payout address is the installed app's");
let c = &m.cards["nvidia:0:x"];
assert!(c.enabled && c.identities == 8 && c.power_pct == 70, "the card's choices stay");
assert!(c.sweep_at == 0 && !c.pinned, "every card is due and unpinned");
}
#[test] #[test]
fn manifest_url_round_trips_through_the_token() { fn manifest_url_round_trips_through_the_token() {
assert_eq!(manifest_url_for_token("abc123"), "https://dl.igneum.network/dl/abc123/igneum-app-latest.json"); assert_eq!(manifest_url_for_token("abc123"), "https://dl.igneum.network/dl/abc123/igneum-app-latest.json");
@ -466,3 +500,18 @@ mod tests {
assert!(p.node_override_params.is_none()); assert!(p.node_override_params.is_none());
} }
} }
#[cfg(test)]
mod fixture_tests {
/// `IGNEUM_TEST_SETTINGS=<path> cargo test settings_fixture`: parses a real settings.json with this crate's
/// struct and prints what it read (6 October 2026: PC 1's copied file read as defaults; this names the field).
#[test]
fn settings_fixture_parses_when_given() {
let Ok(p) = std::env::var("IGNEUM_TEST_SETTINGS") else { return };
let t = std::fs::read_to_string(&p).unwrap();
match serde_json::from_str::<super::Settings>(&t) {
Ok(s) => println!("parsed: address {} cards {} remote_jobs {} setup_done {}", s.address, s.cards.len(), s.remote_jobs, s.setup_done),
Err(e) => panic!("the crate refuses the file: {e}"),
}
}
}

View file

@ -596,6 +596,10 @@ pub struct Engine {
quit_source: &'static str, quit_source: &'static str,
/// the over-the-air updater never runs: IGNEUM_APP_NO_OTA=1 or --sweep (a second engine beside the installed app) /// the over-the-air updater never runs: IGNEUM_APP_NO_OTA=1 or --sweep (a second engine beside the installed app)
no_ota: bool, no_ota: bool,
/// the Igneum Power Helper task is registered (src/powertask.rs): once, ever; None = not asked yet
power_task: Option<bool>,
/// the running tune helper is the task (quit ends it; no SweepHelperDone comes from a thread)
sweep_helper_is_task: bool,
/// the request number the vendor tool last carried out (the run's acknowledgement) /// the request number the vendor tool last carried out (the run's acknowledgement)
tune_acked: Option<u64>, tune_acked: Option<u64>,
/// cards whose confirm check found a better neighbour: the full plan runs next /// cards whose confirm check found a better neighbour: the full plan runs next
@ -700,6 +704,8 @@ impl Engine {
sweep: None, sweep: None,
quit_source: "unknown", quit_source: "unknown",
no_ota, no_ota,
power_task: None,
sweep_helper_is_task: false,
tune_acked: None, tune_acked: None,
tune_full_due: std::collections::HashSet::new(), tune_full_due: std::collections::HashSet::new(),
sweep_pending: None, sweep_pending: None,
@ -989,6 +995,7 @@ impl Engine {
self.clock_next_https = Instant::now() + Duration::from_secs(6); self.clock_next_https = Instant::now() + Duration::from_secs(6);
} }
Cmd::PowerApplied(what, r, readback) => { Cmd::PowerApplied(what, r, readback) => {
self.power_task = None; // the one elevated step may have registered the task: ask again next time
self.power_busy = false; self.power_busy = false;
self.power_via_host = None; self.power_via_host = None;
// the truth is what nvidia-smi reads back, not whether the prompt said yes // the truth is what nvidia-smi reads back, not whether the prompt said yes
@ -1779,8 +1786,45 @@ impl Engine {
self.power_busy = true; self.power_busy = true;
self.power_restore_pending = true; self.power_restore_pending = true;
self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & "))); self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & ")));
let line = cmds.join(" & ");
let what = what.join(", "); let what = what.join(", ");
// once, ever (src/powertask.rs): a registered task sets the caps with no prompt; the readback judges it
if cfg!(windows) && self.power_task_registered() {
let pairs: Vec<(String, u64)> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0 && !c.power_applied).map(|c| (c.device.clone(), requested_watts(c).round() as u64)).collect();
let dir = self.sweep_dir();
let shared = self.shared.clone();
self.shared.log("power cap: through the Igneum Power Helper task (no prompt)");
std::thread::spawn(move || {
let r = crate::powertask::start().and_then(|_| {
let _ = std::fs::create_dir_all(&dir);
let mut seq = crate::platform::unix_now() % 1_000_000;
let mut text = String::new();
for (dev, w) in &pairs {
seq += 1;
text.push_str(&format!("{seq} dev {dev}\n"));
seq += 1;
text.push_str(&format!("{seq} pl {w}\n"));
}
std::fs::write(dir.join("cmd.txt"), text).map_err(|e| e.to_string())
});
std::thread::sleep(Duration::from_secs(6));
let back: std::collections::HashMap<String, f64> = crate::detect::nvidia_power_limits().into_iter().map(|(k, v)| (k, v.1)).collect();
shared.send(Cmd::PowerApplied(what, r, back));
});
return;
}
// the first approval registers the task in the same elevated step as the caps (Windows), so no later step
// needs a prompt: the registration script is written next to the command file
let line = if cfg!(windows) {
let dir = self.sweep_dir();
let _ = std::fs::create_dir_all(&dir);
let script = dir.join("register-power-task.ps1");
match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&exe).as_bytes()].concat())) {
Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()),
_ => cmds.join(" & "),
}
} else {
cmds.join(" & ")
};
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect(); let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
if self.wrapper && cfg!(windows) { if self.wrapper && cfg!(windows) {
// the window host has a UI context: it shows the administrator prompt and reports back on stdin // the window host has a UI context: it shows the administrator prompt and reports back on stdin
@ -1830,6 +1874,14 @@ impl Engine {
self.shared.log(&format!("GPU power limits left as set (they reset at the next reboot; no prompt on quit): {}", cmds.join(" & "))); self.shared.log(&format!("GPU power limits left as set (they reset at the next reboot; no prompt on quit): {}", cmds.join(" & ")));
} }
/// Is the Igneum Power Helper task registered (src/powertask.rs)? Asked once per run and after every elevated step.
fn power_task_registered(&mut self) -> bool {
if self.power_task.is_none() {
self.power_task = Some(crate::powertask::registered());
}
self.power_task.unwrap_or(false)
}
/// Power control (config.rs power_control): may the engine ask for administrator rights for the cap or the sweep? /// Power control (config.rs power_control): may the engine ask for administrator rights for the cap or the sweep?
/// The elevated PC sweep job (--sweep) sets caps directly and counts as allowed. /// The elevated PC sweep job (--sweep) sets caps directly and counts as allowed.
fn elevation_allowed(&self) -> bool { fn elevation_allowed(&self) -> bool {
@ -1854,6 +1906,16 @@ impl Engine {
if self.sweep.is_some() || self.sweep_pending.is_some() { if self.sweep.is_some() || self.sweep_pending.is_some() {
self.sweep_abort("power control is off"); self.sweep_abort("power control is off");
} }
if cfg!(windows) && self.power_task_registered() {
// the kill switch: the task unregisters itself (elevated) and exits; nothing is left behind
let dir = self.sweep_dir();
let _ = std::fs::write(dir.join("cmd.txt"), "remove\n");
match crate::powertask::start() {
Ok(()) => self.shared.log("power control off: the Igneum Power Helper task removes itself"),
Err(e) => self.shared.log(&format!("power control off: the task could not be started to remove itself ({e}); remove it in Task Scheduler")),
}
self.power_task = None;
}
self.shared.event(if note.starts_with("power control off:") { "error" } else { "info" }, note); self.shared.event(if note.starts_with("power control off:") { "error" } else { "info" }, note);
} }
@ -2343,8 +2405,18 @@ impl Engine {
std::fs::write(&script, crate::sweep::helper_script_unix()).map_err(|e| e.to_string())?; std::fs::write(&script, crate::sweep::helper_script_unix()).map_err(|e| e.to_string())?;
format!("sh \"{}\" \"{}\" \"{}\" {} {}", script.display(), dir.display(), smi, c.device, restore) format!("sh \"{}\" \"{}\" \"{}\" {} {}", script.display(), dir.display(), smi, c.device, restore)
}; };
if cfg!(windows) && self.power_task_registered() {
// once, ever: the registered task is the helper; it reads the same command file, no prompt
let _ = std::fs::write(dir.join("cmd.txt"), format!("{} dev {}\n", crate::platform::unix_now() % 1_000_000, c.device));
crate::powertask::start()?;
self.shared.log("tune helper: the Igneum Power Helper task (no prompt)");
self.sweep_helper = true;
self.sweep_helper_is_task = true;
return Ok(());
}
self.shared.log(&format!("tune helper (administrator prompt): {line}")); self.shared.log(&format!("tune helper (administrator prompt): {line}"));
self.sweep_helper = true; self.sweep_helper = true;
self.sweep_helper_is_task = false;
let shared = self.shared.clone(); let shared = self.shared.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let r = crate::platform::run_elevated(&line); let r = crate::platform::run_elevated(&line);
@ -2356,6 +2428,11 @@ impl Engine {
fn sweep_helper_quit(&mut self) { fn sweep_helper_quit(&mut self) {
if self.sweep_helper { if self.sweep_helper {
let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), "quit\n"); let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), "quit\n");
if self.sweep_helper_is_task {
// the task exits on quit and reports nothing back; the next tune starts it again
self.sweep_helper = false;
self.sweep_helper_is_task = false;
}
} }
} }
@ -2400,7 +2477,8 @@ impl Engine {
// measure only: nothing is set; the run notices the missing acknowledgement and measures // measure only: nothing is set; the run notices the missing acknowledgement and measures
return; return;
} }
let cmd = format!("{seq} pl {w}\n{seq} {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }); let dev = device.to_string();
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() });
let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), cmd); let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), cmd);
// the helper polls twice a second and nvidia-smi answers within a second or two // the helper polls twice a second and nvidia-smi answers within a second or two
std::thread::spawn(move || { std::thread::spawn(move || {

View file

@ -35,6 +35,7 @@ mod verifier;
mod wslhost; mod wslhost;
mod sweep; mod sweep;
mod ember; mod ember;
mod powertask;
mod watchdog; mod watchdog;
use std::io::{BufRead, Write}; use std::io::{BufRead, Write};
@ -54,6 +55,12 @@ fn main() {
println!("igneum-app {}", engine::VERSION); println!("igneum-app {}", engine::VERSION);
return; return;
} }
if args.iter().any(|a| a == "--power-helper") {
// the scheduled task's action (src/powertask.rs): elevated, runs only digit-argument nvidia-smi commands
// from <app data>/app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes
let dir = powertask::sweep_dir(&platform::data_root().join("app"));
std::process::exit(powertask::run_helper(&dir));
}
if args.iter().any(|a| a == "--launch") { if args.iter().any(|a| a == "--launch") {
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) { if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
let host = dir.join("Igneum Miner.exe"); let host = dir.join("Igneum Miner.exe");
@ -95,6 +102,8 @@ fn main() {
} }
let packaged = config::Packaged::load(&candidates).with_env_overrides(); let packaged = config::Packaged::load(&candidates).with_env_overrides();
let settings = config::Settings::load(&runtime.app_dir.join("settings.json")); let settings = config::Settings::load(&runtime.app_dir.join("settings.json"));
// a measurement engine runs with the installed app's choices and its own switches (config.rs for_measurement)
let settings = if sweep { settings.for_measurement() } else { settings };
// the per-launch token: 32 hex characters from the OS // the per-launch token: 32 hex characters from the OS
let mut raw = [0u8; 16]; let mut raw = [0u8; 16];

View file

@ -184,7 +184,11 @@ impl Updater {
if crate::platform::start_at_login_is_on() { if crate::platform::start_at_login_is_on() {
let _ = crate::platform::set_start_at_login(true); let _ = crate::platform::set_start_at_login(true);
} }
firewall_first_run(shared); // a measurement engine (--sweep) uses the installed app's node and asks for nothing: the rule is the
// installed app's (the dry run of 6 October 2026 raised a second UAC prompt from here)
if !shared.runtime.sweep_only {
firewall_first_run(shared);
}
} }
u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::<Vec<String>>(&t).ok()).unwrap_or_default(); u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::<Vec<String>>(&t).ok()).unwrap_or_default();
// the cached manifest: the rollback floor and the consensus override are known before the first check // the cached manifest: the rollback floor and the consensus override are known before the first check

View file

@ -166,17 +166,28 @@ pub fn lock_permissions(path: &Path, dir: bool) {
} }
#[cfg(windows)] #[cfg(windows)]
{ {
let _ = dir;
let user = std::env::var("USERNAME").unwrap_or_default(); let user = std::env::var("USERNAME").unwrap_or_default();
if !user.is_empty() { if !user.is_empty() {
let _ = quiet(&mut Command::new(tool("icacls"))) let _ = quiet(&mut Command::new(tool("icacls"))).arg(path).args(icacls_lock_args(dir, &user)).output();
.arg(path)
.args(["/inheritance:r", "/grant:r", &format!("{user}:F")])
.output();
} }
} }
} }
/// The icacls arguments that lock a path to the user. A folder gets an INHERITABLE grant (`user:(OI)(CI)F`) and
/// NO `/T`: Windows propagates the inheritable entry to every child, existing or future, as `(I)(F)`. Measured on
/// PC 1, 6 October 2026 (collect ember-acl-2): the old non-inheritable `user:F` cut the folder's inheritance and
/// left a file COPIED in before the engine started with no entry at all (the measurement engine's settings.json,
/// machine-id and wallet.json read as nothing, so it ran on defaults with no payout address; its own files, written
/// after the lock, inherited fine and hid it); the same grant WITH `/T` also left the file empty, because `/T`
/// re-applies `/inheritance:r` to the file after the propagation and an `(OI)(CI)` entry on a file is inherit-only.
pub fn icacls_lock_args(dir: bool, user: &str) -> Vec<String> {
if dir {
vec!["/inheritance:r".into(), "/grant:r".into(), format!("{user}:(OI)(CI)F")]
} else {
vec!["/inheritance:r".into(), "/grant:r".into(), format!("{user}:F")]
}
}
/// Opens a URL in the default browser (the fallback when no window host runs). /// Opens a URL in the default browser (the fallback when no window host runs).
pub fn open_url(url: &str) { pub fn open_url(url: &str) {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@ -500,6 +511,17 @@ pub fn quiet(cmd: &mut Command) -> &mut Command {
cmd cmd
} }
#[cfg(test)]
mod lock_tests {
#[test]
fn a_locked_folder_grants_the_user_inheritably_and_covers_what_is_inside() {
let d = super::icacls_lock_args(true, "Admin");
assert_eq!(d, vec!["/inheritance:r", "/grant:r", "Admin:(OI)(CI)F"], "inheritable, and never /T (it empties the children)");
let f = super::icacls_lock_args(false, "Admin");
assert_eq!(f, vec!["/inheritance:r", "/grant:r", "Admin:F"]);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]

View file

@ -0,0 +1,265 @@
//! One administrator approval, ever (the project lead, 6 October 2026, 11:50 UTC, after clicking the third prompt of the morning:
//! "can we make sure all these popups are not needed in future?").
//!
//! What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; but every later cap (an app
//! start, a reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. This module
//! makes the first approval the last: the one elevated step also registers a per-user Windows scheduled task,
//! `Igneum Power Helper`, principal = the signed-in user, RunLevel Highest, no trigger, whose action is this very
//! executable with `--power-helper`. A task the user owns can be STARTED by the user's unelevated processes without a
//! prompt (`Start-ScheduledTask`), and it runs elevated; so every later cap and tune starts the task and talks to it
//! through the command file `<app data>/app/sweep/cmd.txt` (the protocol the 0.3.9 helper scripts spoke: `<seq> pl
//! <watts>`, `<seq> lgc <MHz>`, `<seq> rgc`, `quit`; plus `remove`, the kill switch). The task survives app restarts,
//! updates (the per-user installer replaces the exe in place; the task's action path is the install folder) and
//! reboots (a task, not a process). Power control off starts the task once and sends `remove`: the helper unregisters
//! the task (elevated) and exits; nothing is left behind.
//!
//! Threat note (what the helper will and will not run):
//! - The action is fixed at registration: the app's own exe in the install folder with `--power-helper`. The task
//! has no trigger and no arguments from outside; only `Start-ScheduledTask` by the owning user starts it.
//! - The helper reads ONE file, `<app data>/app/sweep/cmd.txt`, in the user's own profile. Every command it accepts
//! is a fixed verb with digit-only arguments: `pl <watts>` runs `nvidia-smi -i <dev> -pl <watts>`, `lgc <MHz>` runs
//! `nvidia-smi -i <dev> -lgc 0,<MHz>`, `rgc` runs `nvidia-smi -i <dev> -rgc`, `quit` ends it, `remove` unregisters
//! the task and ends it. The device index is digits only too (`dev <n>` sets it). No shell, no path, no string from
//! the file reaches a process: `Command::new(nvidia-smi).args([...])`, never `cmd /c`.
//! - nvidia-smi is resolved to the driver's install path (platform::tool), never from PATH.
//! - What an attacker running as the user gains: the power limit and the clock cap of the user's own NVIDIA cards,
//! within the ranges the driver allows, which the same user could set with one approved prompt anyway. Nothing
//! else: no file, no process, no registry, no other binary.
//! - The helper exits after 20 idle minutes; a stale command file is cleared at start (sequence numbers must rise).
//! - Linux keeps pkexec per step (no scheduled task); macOS has no cap to set.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// The task name in the Windows Task Scheduler (per user).
pub const TASK_NAME: &str = "Igneum Power Helper";
/// The helper ends after this long without a new command.
pub const IDLE_S: u64 = 20 * 60;
/// One parsed command from cmd.txt.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HelperCmd {
Dev(String),
PowerLimit(u64),
ClockCap(u64),
ClockReset,
Quit,
Remove,
}
/// Parses one line: `<seq> <verb> [<digits>]` (the 0.3.9 form `<seq> <watts>` reads as a power limit; `quit` and
/// `remove` need no sequence). Anything that is not a fixed verb with digit-only arguments is None.
pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
let t = line.trim();
if t == "quit" {
return Some((0, HelperCmd::Quit));
}
if t == "remove" {
return Some((0, HelperCmd::Remove));
}
let p: Vec<&str> = t.split_whitespace().collect();
let digits = |s: &str| !s.is_empty() && s.len() <= 6 && s.chars().all(|c| c.is_ascii_digit());
let seq: u64 = p.first().filter(|s| digits(s)).and_then(|s| s.parse().ok())?;
match p.as_slice() {
[_, w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))),
[_, "pl", w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))),
[_, "lgc", m] if digits(m) => Some((seq, HelperCmd::ClockCap(m.parse().ok()?))),
[_, "rgc"] => Some((seq, HelperCmd::ClockReset)),
[_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))),
_ => None,
}
}
/// The nvidia-smi arguments a command becomes (None for the verbs that run nothing).
pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> {
match c {
HelperCmd::PowerLimit(w) => Some(vec!["-i".into(), dev.into(), "-pl".into(), w.to_string()]),
HelperCmd::ClockCap(m) => Some(vec!["-i".into(), dev.into(), "-lgc".into(), format!("0,{m}")]),
HelperCmd::ClockReset => Some(vec!["-i".into(), dev.into(), "-rgc".into()]),
_ => None,
}
}
/// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is this
/// executable's path in the install folder. Principal: the signed-in user, interactive logon, highest run level; no
/// trigger; may start on battery; one hour limit per run; multiple starts are ignored while one runs.
pub fn register_script(exe: &Path) -> String {
let exe = exe.display().to_string().replace('\'', "''");
format!(
"$a = New-ScheduledTaskAction -Execute '{exe}' -Argument '--power-helper' -WorkingDirectory '{dir}'\r\n\
$p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest\r\n\
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 1) -MultipleInstances IgnoreNew -Hidden\r\n\
Register-ScheduledTask -TaskName '{name}' -Action $a -Principal $p -Settings $s -Force | Out-Null\r\n\
exit 0\r\n",
dir = exe.rfind(['\\', '/']).map(|i| exe[..i].to_string()).unwrap_or_default(),
name = TASK_NAME
)
}
/// The PowerShell that starts the task from an unelevated process (no prompt: the user owns the task).
pub fn start_command() -> String {
format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0")
}
/// The PowerShell that says whether the task is registered (exit 0) or not (exit 1).
pub fn query_command() -> String {
format!("if (Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}")
}
/// The PowerShell the helper itself runs (elevated) on `remove`: the task goes, nothing is left.
pub fn remove_command() -> String {
format!("Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false; exit 0")
}
/// Is the task registered? Windows only; false elsewhere.
pub fn registered() -> bool {
if !cfg!(windows) {
return false;
}
let mut c = std::process::Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &query_command()]);
crate::platform::quiet(&mut c);
c.status().map(|s| s.success()).unwrap_or(false)
}
/// Starts the task (no prompt). Ok when Start-ScheduledTask returned 0.
pub fn start() -> Result<(), String> {
let mut c = std::process::Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &start_command()]);
crate::platform::quiet(&mut c);
let out = c.output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(format!("Start-ScheduledTask failed: {}", String::from_utf8_lossy(&out.stderr).trim()))
}
}
/// The helper process (`igneum-app --power-helper`): polls `<dir>/cmd.txt` twice a second, runs the parsed commands
/// through nvidia-smi, logs what it ran to `<dir>/helper.log`, ends on `quit`, on `remove` (after unregistering the
/// task) or after 20 idle minutes. `dir` is `<app data>/app/sweep`.
pub fn run_helper(dir: &Path) -> i32 {
let _ = std::fs::create_dir_all(dir);
let cmd_file = dir.join("cmd.txt");
let log_file = dir.join("helper.log");
let log = |line: &str| {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().append(true).create(true).open(&log_file) {
let _ = writeln!(f, "{} {line}", crate::platform::unix_now());
}
};
log("helper started (scheduled task, elevated)");
// a stale file from an earlier run is not a command: only lines after the start count
let mut last_seq: u64 = std::fs::read_to_string(&cmd_file).ok().and_then(|t| t.lines().filter_map(parse_line).map(|(s, _)| s).max()).unwrap_or(0);
let mut last_text = String::new();
let mut dev = "0".to_string();
let mut idle = Instant::now();
let smi = crate::platform::tool("nvidia-smi");
loop {
let text = std::fs::read_to_string(&cmd_file).unwrap_or_default();
if text != last_text {
last_text = text.clone();
for (seq, c) in text.lines().filter_map(parse_line) {
match c {
HelperCmd::Quit => {
log("quit");
return 0;
}
HelperCmd::Remove => {
let mut p = std::process::Command::new(crate::platform::tool("powershell"));
p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &remove_command()]);
crate::platform::quiet(&mut p);
let ok = p.status().map(|s| s.success()).unwrap_or(false);
log(&format!("remove: the task is {}", if ok { "unregistered" } else { "still registered (Unregister-ScheduledTask failed)" }));
return if ok { 0 } else { 1 };
}
_ if seq <= last_seq => continue,
HelperCmd::Dev(d) => {
last_seq = seq;
idle = Instant::now();
dev = d;
log(&format!("{seq} dev {dev}"));
}
other => {
last_seq = seq;
idle = Instant::now();
let args = smi_args(&dev, &other).unwrap_or_default();
let mut p = std::process::Command::new(&smi);
p.args(&args);
crate::platform::quiet(&mut p);
let out = p.output().map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))).unwrap_or_else(|e| e.to_string());
log(&format!("{seq} nvidia-smi {} : {}", args.join(" "), out.replace('\n', " ").trim()));
}
}
}
}
if idle.elapsed() >= Duration::from_secs(IDLE_S) {
log("idle 20 min: exit (the engine starts the task again when it needs it)");
return 0;
}
std::thread::sleep(Duration::from_millis(500));
}
}
/// Where the command file lives for a data root.
pub fn sweep_dir(app_dir: &Path) -> PathBuf {
app_dir.join("sweep")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_fixed_verbs_with_digit_arguments_parse() {
assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460))));
assert_eq!(parse_line("8 lgc 2472"), Some((8, HelperCmd::ClockCap(2472))));
assert_eq!(parse_line("9 rgc"), Some((9, HelperCmd::ClockReset)));
assert_eq!(parse_line("3 dev 1"), Some((3, HelperCmd::Dev("1".into()))));
assert_eq!(parse_line("5 403"), Some((5, HelperCmd::PowerLimit(403))), "the 0.3.9 form");
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
// nothing else: no shell, no path, no string argument, no oversized number
for bad in ["7 pl 460; calc", "7 pl -460", "7 pl 4.60", "7 lgc 0,2472", "7 rm C:\\x", "x pl 460", "7 pl", "7 lgc 12345678", "7 dev ../1", "", "7 pl 460 extra"] {
assert_eq!(parse_line(bad), None, "{bad:?}");
}
}
#[test]
fn the_arguments_reach_nvidia_smi_as_a_list_never_a_shell() {
assert_eq!(smi_args("0", &HelperCmd::PowerLimit(460)).unwrap(), vec!["-i", "0", "-pl", "460"]);
assert_eq!(smi_args("1", &HelperCmd::ClockCap(2472)).unwrap(), vec!["-i", "1", "-lgc", "0,2472"]);
assert_eq!(smi_args("1", &HelperCmd::ClockReset).unwrap(), vec!["-i", "1", "-rgc"]);
assert_eq!(smi_args("0", &HelperCmd::Quit), None);
assert_eq!(smi_args("0", &HelperCmd::Remove), None);
assert_eq!(smi_args("0", &HelperCmd::Dev("1".into())), None);
}
#[test]
fn the_registration_is_per_user_highest_no_trigger_fixed_action() {
let s = register_script(Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe"));
assert!(s.contains("-Execute 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner\\igneum-app.exe' -Argument '--power-helper'"), "{s}");
assert!(s.contains("-WorkingDirectory 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}");
assert!(s.contains("-RunLevel Highest") && s.contains("-LogonType Interactive"), "{s}");
assert!(s.contains("[System.Security.Principal.WindowsIdentity]::GetCurrent().Name"), "the signed-in user, never a literal");
assert!(!s.contains("-Trigger"), "no trigger: only the app starts it");
assert!(s.contains("-MultipleInstances IgnoreNew") && s.contains("-ExecutionTimeLimit"), "{s}");
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
// a quote in the path cannot break out of the literal
let q = register_script(Path::new(r"C:\it's\igneum-app.exe"));
assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}");
assert!(start_command().starts_with("Start-ScheduledTask -TaskName 'Igneum Power Helper'"));
assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Power Helper' -Confirm:$false"));
assert!(query_command().contains("Get-ScheduledTask -TaskName 'Igneum Power Helper'"));
}
#[test]
fn a_stale_command_file_does_not_run_at_start() {
// the helper's start reads the highest sequence already in the file and runs nothing below or at it
let text = "3 pl 460\n4 lgc 2472\n";
let last = text.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0);
assert_eq!(last, 4);
let newer: Vec<_> = "3 pl 460\n4 lgc 2472\n5 rgc\n".lines().filter_map(parse_line).filter(|(s, _)| *s > last).collect();
assert_eq!(newer, vec![(5, HelperCmd::ClockReset)]);
}
}

View file

@ -3,6 +3,6 @@
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too. // packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
#ifndef IGNEUM_HOST_VERSION_H #ifndef IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_H #define IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_STR "0.3.12" #define IGNEUM_HOST_VERSION_STR "0.3.13"
#define IGNEUM_HOST_VERSION_RC 0,3,12,0 #define IGNEUM_HOST_VERSION_RC 0,3,13,0
#endif #endif

View file

@ -1660,6 +1660,15 @@ Branch `ember-tune` (54ff1bc), docs/plans/ember-tune.md. Every card tuned for MH
**Run 2, 6 October 2026, 07:21 to 07:56Z (job ember-tune-pc1-2, elevated on the project lead's word, engine 25113f52..., PC 1 on 0.3.11):** the project lead answered the one prompt; the installed app stopped its miners at 07:21:16Z; the second engine ran for the whole 35-minute budget at "waiting, 0.00 MH/s" and no step ran. Cause: the playbook wrote the engine's copy of settings.json with PowerShell 5.1's `Set-Content -Encoding utf8`, which adds a UTF-8 BOM; the engine's JSON parser refuses it, `Settings::load` fell back to defaults (no payout address, no cards), the engine logged `[error] no payout address` and never started a miner. Run 1's scratch log carried the same line the night before. Readbacks, idle both times: the 5090 at 90.6 W before and 69.9 W after (2,505 then 2,407 MHz core, 14,001 MHz memory, limit 450 W of 575), the 9070 XT at factory (`gmax 0`, `plimit 0`). Nothing set on either card. The installed app's runner released the miners-stopped hold by itself on the failed exit (`job finished; the miners restart` at 07:56:50Z, both miners up by 07:57:04Z, `mining` at 07:57:29Z): mining paused 36 min 13 s. Fix 8273494: the copy is written without a BOM, the address is read back and the job fails within seconds if it is empty (`RESULT TUNE scratch settings: address ..., cards N, first bytes ...`), and the CI check fails any playbook writing JSON with `Set-Content -Encoding utf8`. The re-run needs one more click on the prompt. **Run 2, 6 October 2026, 07:21 to 07:56Z (job ember-tune-pc1-2, elevated on the project lead's word, engine 25113f52..., PC 1 on 0.3.11):** the project lead answered the one prompt; the installed app stopped its miners at 07:21:16Z; the second engine ran for the whole 35-minute budget at "waiting, 0.00 MH/s" and no step ran. Cause: the playbook wrote the engine's copy of settings.json with PowerShell 5.1's `Set-Content -Encoding utf8`, which adds a UTF-8 BOM; the engine's JSON parser refuses it, `Settings::load` fell back to defaults (no payout address, no cards), the engine logged `[error] no payout address` and never started a miner. Run 1's scratch log carried the same line the night before. Readbacks, idle both times: the 5090 at 90.6 W before and 69.9 W after (2,505 then 2,407 MHz core, 14,001 MHz memory, limit 450 W of 575), the 9070 XT at factory (`gmax 0`, `plimit 0`). Nothing set on either card. The installed app's runner released the miners-stopped hold by itself on the failed exit (`job finished; the miners restart` at 07:56:50Z, both miners up by 07:57:04Z, `mining` at 07:57:29Z): mining paused 36 min 13 s. Fix 8273494: the copy is written without a BOM, the address is read back and the job fails within seconds if it is empty (`RESULT TUNE scratch settings: address ..., cards N, first bytes ...`), and the CI check fails any playbook writing JSON with `Set-Content -Encoding utf8`. The re-run needs one more click on the prompt.
**Dry run 3, 6 October 2026, 14:56 to 15:02Z (job ember-dryrun-pc1-3, unelevated, no prompt, measure only; engine from ember-tune 07d5a72, kit sha256 36b522c9...):** the first measurement engine on PC 1 that mined. Both cards, one 60 s row each at the installed app's 80% cap, clocks unlocked, rate = the worker's STATUS wall rate, draw = nvidia-smi every 5 s:
| Card | MH/s | W | MH/W | core | memory | GPU C | limit |
|---|---|---|---|---|---|---|---|
| RTX 5090 | 127.31 | 316.5 | 0.402 | 2,850 MHz | 13,801 MHz | 68 | 460 W of 575 |
| RTX 4070 | 28.68 | 102.7 | 0.279 | 2,805 MHz | 10,251 MHz | 46 | 160 W of 200 |
Nothing set; the installed app's miners back after 350 s. Why every earlier run (5 and 6 October, runs 1 to 4 and dry runs 1 and 2) read its copied settings as defaults, measured on PC 1 (collect ember-acl-2): the engine's own start locks its app folder with `icacls /inheritance:r /grant:r <user>:F`; cutting the folder's inheritance propagates down, the non-inheritable grant gives the children nothing, so a file COPIED in before the start (settings.json, machine-id, wallet.json) is left with no access entry and its owner cannot read it (`ReadAllText`: access denied), while the engine's own files written after the lock inherit fine, which hid it for a day. A first fix with `(OI)(CI)F /T` left the file empty too: `/T` re-applies `/inheritance:r` to each file after the propagation and an `(OI)(CI)` entry on a file is inherit-only. The right form is the inheritable grant without `/T` (07d5a72). Consequence for every tier on Windows: nothing changes for the installed app (its files were always its own); any tool that drops files into the app folder before the app starts (an installer's seed, a migration, a support script) was unreadable to the app until now and is readable from 0.3.13 on.
Consequence for the tiers: an AMD card is tuned on its power limit alone until its stock core clock is read (a 9070 XT at -30% is the floor the driver allows, 4 steps, 5 minutes); every NVIDIA card's two-knob plan waits on the user's one click on Power control; the re-run on PC 1 is held until the quit's source is named (the event-log collect) and follows the 0.3.11 rollout (the update clears the jobs folder, so the engine and the helper are fetched again), with the scheduler's slot. Consequence for the tiers: an AMD card is tuned on its power limit alone until its stock core clock is read (a 9070 XT at -30% is the floor the driver allows, 4 steps, 5 minutes); every NVIDIA card's two-knob plan waits on the user's one click on Power control; the re-run on PC 1 is held until the quit's source is named (the event-log collect) and follows the 0.3.11 rollout (the update clears the jobs folder, so the engine and the helper are fetched again), with the scheduler's slot.
## 5 October 2026 (night), read width of the lottery hash: 4, 16 and 64-byte loads, a per-load mix, a written scratch; three cards (gate 1 experiment, cryptographer) ## 5 October 2026 (night), read width of the lottery hash: 4, 16 and 64-byte loads, a per-load mix, a written scratch; three cards (gate 1 experiment, cryptographer)
@ -2223,3 +2232,42 @@ Run b (`segments-pc2-pv1b`, 07:20Z to 07:51Z) claimed nothing in 88 passes: the
| The rule as shipped (no switch): fresh refused while the previous segment is pending (known-failed), accepted after it is unproven | 22 passed | 166.2 s | | The rule as shipped (no switch): fresh refused while the previous segment is pending (known-failed), accepted after it is unproven | 22 passed | 166.2 s |
| `--fresh-rule 0`: fresh accepted while the previous segment is pending, `freshAdmissible` true, still refused after a proven one, the second offer a duplicate ("segment already paid") | 23 passed | 139.9 s | | `--fresh-rule 0`: fresh accepted while the previous segment is pending, `freshAdmissible` true, still refused after a proven one, the second offer a duplicate ("segment already paid") | 23 passed | 139.9 s |
## 6 October 2026, 12:25 to 13:20Z, the finality route: why 26 fresh nodes lost the seed every checkpoint (fork `fin-route-0313` 5a339733 on 83089544; release engineer)
The fleet agent's finding (12:25Z): every rented node logged `P2P, route error: incoming route capacity for message type IgneumFinality has
been reached (peer: 188.245.5.161:26611)` every 20 to 60 s and reconnected at the checkpoint cadence (every 30 s); on a Vast box the seed
is the only peer, so each drop cost the node its only peer until the next dial.
**The cause is an echo, not the burst.** A certificate for an index below a node's window (`next_index` minus `KEEP_CHECKPOINTS` 2,000:
trimmed history) finds no record, goes through the off-chain path (`ingest_off_chain`), is LOCKED, pushed to gossip and sent to every peer,
trimmed again on the next pass, and comes back from every peer that held it. The seed's journal (`igneumd-v4`, 12:40 to 12:47Z):
| Line shape | Count in 7 min |
|---|---|
| `Finality: checkpoint N LOCKED by certificate: block <hash> ... is off this node's selected chain (not determined here yet)` | 13,354 (index 2954: 2,811; 2956: 2,799; 2957: 2,790; 2955: 2,778; 3897: 1,234; 1464: 942; the seed's next index was 6,127) |
| `route error: incoming route capacity for message type IgneumFinality` (the seed dropping ITS peers) | 13 |
| the real work (determined, received, LOCKED, folded, replaced by a heavier one) | 13 + 13 + 13 + 8 + 20 |
A fresh node on the Mac against the seed only (the 0.3.12 binary 83089544, 300 s, `kaspa_p2p_flows=debug`): 11,700 `Finality relay:
certificate` lines, every one `new=false`, 15 distinct indices, 240 per second at the peak (2,530 per 10 s), 203 votes; no route error on
the Mac (it drains 240/s with a 256-deep route) and one connection, where the fleet's slower boxes filled the route and lost the peer.
**The fix (four changes, 5a339733):** `ingest_certificate` ignores an index below `keep_from` (counted, debug: the echo stops at its source
once the seed runs it); the router's overflow policy for `IgneumFinality` is `Drop` with a counted warn once per 10 s per peer, never a
disconnect; the finality route is subscribed with 4,096 (a checkpoint's worst case is `MAX_VOTES_PER_BLOCK` 48 votes on each of 30 blocks
plus the certificates); the relay flow skips votes while IBD runs (counted, said once per 30 s; certificates still go in and land pending).
No consensus change, no digest change. Tests: the overflow-policy table (p2p 33 of 33), the flows crate (19 of 19), a certificate below
the window submitted twice (ignored, no gossip, counter 2; an index inside goes the normal way) with the finality tests (12 of 12).
**After, on the fixed binary against the still-unfixed seed** (203ae727, same run, 13:15:31 to 13:20:31Z): 63,628 certificates received
(the seed's echo had grown to 3,032 per 10 s at the peak as more fleet nodes joined), 0 route errors, 0 drops, 4 connections kept (the seed
and three peers learned from it), 168 votes skipped during IBD. The receiver side of the fix holds under a storm five times the morning's;
the source side (the guard) cannot show on the seed until 0.3.13 runs there, and the fresh node's own guard never fires during IBD (its
window starts at genesis), which is correct. Harness s7 on the fixed binary (`--quick --live-only`): PASS, 192 blocks accepted in 60 s under
a 50 blocks/s flood from one peer, honest template p50/p95/max 0.4/0.6/1.4 ms, rss 306 to 321 MB.
**Per tier:** a home miner joining today sees the warning and the peers=0 flicker every checkpoint until the seed runs 0.3.13; a rig the
same once; a pool user nothing; a fleet operator gets a node that keeps its only peer, and a seed that stops amplifying old certificates to
every peer (13,354 lines of work it did not need in seven minutes). Owed: the fleet agent's synced-node reading; a receiver-side limit on
certificates per index per minute as a second belt once the seed is fixed; the formatter's reflow of `finality.rs` (taken out of the commit).

View file

@ -161,13 +161,36 @@ maximum, 14,001 MHz memory; 9070 XT present on bus 98 with OFFSET ranges `gmax_r
10`). The offset finding changed the AMD mapping (054e041): an offset clock range closes the clock knob and the power 10`). The offset finding changed the AMD mapping (054e041): an offset clock range closes the clock knob and the power
ladder runs on a percent scale bounded by `plimit_range`. The re-run follows the 0.3.11 rollout. ladder runs on a percent scale bounded by `plimit_range`. The re-run follows the 0.3.11 rollout.
## 7a. One administrator approval, ever (0.3.13; the project lead, 6 October 2026, 11:50 UTC)
What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a
reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. Not "once, ever".
What `src/powertask.rs` does: the first approval's elevated step also registers a per-user Windows scheduled task,
`Igneum Power Helper` (principal = the signed-in user, interactive logon, RunLevel Highest, no trigger, hidden, one
hour limit, new starts ignored while one runs), whose action is the app's own exe in the install folder with
`--power-helper`. A task the user owns is started by the user's unelevated engine with `Start-ScheduledTask`, no
prompt, and runs elevated. Every later cap and every tune's helper starts the task and writes the command file
`<app data>/app/sweep/cmd.txt` (`<seq> dev <n>`, `<seq> pl <W>`, `<seq> lgc <MHz>`, `<seq> rgc`, `quit`). The task
survives app restarts, updates (the per-user installer replaces the exe in place; the task's action path is the
install folder) and reboots. Power control off starts the task once and sends `remove`: the helper unregisters the
task (elevated) and exits; nothing is left behind. Linux keeps pkexec per step; macOS has no cap.
Threat note: the helper runs only fixed verbs with digit-only arguments through `Command::new(nvidia-smi).args`
(the driver's own path, never PATH, never a shell); a line that is anything else is ignored; the sequence must rise
(a stale file runs nothing); an attacker running as the user gains the power limit and clock cap of the user's own
NVIDIA cards inside the driver's ranges, which the same user could set with one approved prompt anyway; no file,
process, registry key or other binary is reachable through it. Tests: `powertask::tests` (the parser refuses every
non-digit or extra argument, the arguments reach nvidia-smi as a list, the registration is per-user, highest,
trigger-less and quote-safe, a stale command file runs nothing).
## 8a. Next-cut notes (for the 0.3.12 shipper) ## 8a. Next-cut notes (for the 0.3.12 shipper)
| Commit | What | Where | | Commit | What | Where |
|---|---|---| |---|---|---|
| b671c8b | every `quit:` names its source; Power control alone decides; no cap at start under `--sweep` | main.rs, server.rs, engine.rs (separable) | | b671c8b | every `quit:` names its source; Power control alone decides; no cap at start under `--sweep` | main.rs, server.rs, engine.rs (separable) |
| e600e63 | a second engine never runs the updater (`IGNEUM_APP_NO_OTA`, implied by `--sweep`) | engine.rs (6 lines, separable) | | e600e63 | a second engine never runs the updater (`IGNEUM_APP_NO_OTA`, implied by `--sweep`) | engine.rs (6 lines, separable) |
| 1e9550e (this commit, amended) | the elevated job path's output file is followed while the script runs, so the 5-minute progress reports carry its lines (a 35-minute run that never mined showed only "script running" on 6 October 2026); the tune playbook's watchdog fails a run that mines nothing within 120 s of its first status line, with the engine's last log line in the RESULT | jobrun.rs `follow_file`, relay/playbooks/ember-tune-pc1.ps1 | | 1e9550e | the elevated job path's output file is followed while the script runs, so the 5-minute progress reports carry its lines (a 35-minute run that never mined showed only "script running" on 6 October 2026); the tune playbook's watchdog fails a run that mines nothing within 120 s of its first status line, with the engine's last log line in the RESULT | jobrun.rs `follow_file`, relay/playbooks/ember-tune-pc1.ps1 |
## 9. Open ## 9. Open

View file

@ -0,0 +1,150 @@
# Igneum Miner 0.3.13: node-only, the execution layer follows again and the finality route; prepared to the publish gate, 6 October 2026
Release engineer, from 13:05 UTC, on the coordinator's instruction: "prepare, so it ships the moment the fix lands: a release-0.3.13 branch,
NODE-ONLY". Worktree `/Users/joshm/Projects/igneum-wt-ship0313`, branch `release-0.3.13` from master 19edae0; the fork worktree
`vendor/igneum-node-0313`, branch `release-0.3.13-node`, at 83089544 (the 0.3.12 node) until the two node fixes land on it; `vendor/`
symlinked to the main checkout's. The 0.3.12 recipe throughout; igneum-labs commits; times UTC.
## 1. Why, and what it carries
Since the publish-2 restarts of 0.3.12 (about 11:37Z) the execution layer is dead on every node: `eth_blockNumber` answers `0x0` and
`igneum_getProvingStatus` reads `active: false, paidShards 0, paidWei 0x0` (the observer at 13:05Z). The devnet's pruning point left genesis
today, and the follower, which walks from genesis in memory, can no longer start; `--archival` does not help an existing datadir.
| Change | Where | State |
|---|---|---|
| The execution layer follows again: the exec state persisted to the data dir every 5 min and at stop, resumed at start (`--igneum-exec-snapshot=<path>[,<sha256>]`, `igneum_exportExecSnapshot`, the loud "exec not synced" status); the snapshot served and fetched over p2p (protocol 16, messages 76 and 77); the archival walk through the ghostdag store when the virtual-chain query refuses a tip below the retention root; `exec_restart_number`, `exec_restart_hash` and `exec_restart_trust_daa` in the override object (in the digest once set: without them a node on this build stays blocked, the bodies below 27,276 being gone on every hand) | the proving agent's `exec-sync-0313` 05e93f0e (7 commits on 83089544) | merged onto the route fix as release-0.3.13-node **a9dfe78e** (clean, 23 files). Measured by its agent on a copy of node 1's data dir: 27,276 header-only records, the EVM state restarted at chain block 27,276 and re-executed to the sink (130,272) in 93 s; paidShards 1,482, paidWei 1,825.70 IGN; the state persisted (114.8 MB) and resumed in 9 s |
| The finality route (the fleet's finding, 26 fresh nodes): a certificate for an index below this node's window is ignored (the seed re-locked index 2954 2,811 times in seven minutes and the echo filled every fresh peer's route); the IgneumFinality route takes a checkpoint burst (4,096); a full route drops the message and keeps the peer; votes are skipped during IBD | fork branch `fin-route-0313` 5a339733 (the bench-log entry `fin-route` 05d0944, merged e6c939e) | in: p2p 33, flows 19, finality 12 tests green; harness s7 PASS |
| The trust window off when `exec_restart_trust_daa` is never (05e93f0e read `carrier_daa < trust` with trust at u64::MAX, always true, so a node WITHOUT the field had the native-statement veto and the assignee check off and would have paid any carried shard record); `startedFrom` reads "genesis" when the follower executed genesis | the proving agent's 78c7f961 (fe6756da inside), found by the igneum-exec test at `proving.rs:1171` on the merged tree | merged as release-0.3.13-node **544fc30f**; igneum-exec 18 of 18 |
| The Power Helper (the project lead, 11:50Z: one administrator approval, ever: the first Power control action registers a per-user elevated scheduled task, no prompt after), the engine folder lock's ACL (`user:(OI)(CI)F` without /T), the verbatim settings copy for a measurement engine, the watchdog, no firewall prompt for a sweep engine, the jobrun `follow_file`, `tools/ci/playbook-quit-check.sh` (the 5 October rule as a gate; the two agg-cost scripts allow-listed under a dated note, db97665 drops them in the next cut with the aggregation-cost agent's `--stop-miners` change) | `ember-tune` 32b2688 (07d5a72 + master 494c9c7 merged in + the dry-run-3 bench entry), on the coordinator's condition: the Ember agent's unelevated dry run 3 on PC 1 PASSED (ember-dryrun-pc1-3, 14:56:18 to 15:02:08Z, exit 0: the 5090 127.31 MH/s at 316.5 W, the 4070 28.68 MH/s at 102.7 W, nothing set, no prompt) | merged 71f08d5 (07d5a72) then **4deea56** (32b2688); app tests 146 + 28 + 8, UI 26, relay 23, every CI check green including playbook-quit |
| A fresh node (the proving agent's rented 4090, 14:04Z): on the branch as first merged it executed genesis BEFORE IBD, and after IBD its bodies started at the pruning point, so the follower never proceeded and said nothing: every new 0.3.13 install would end with an empty EVM, silently. ecdccef3: nothing executes before consensus is synced (the sink within 10 minutes of the clock), the exec restart is retried every pass until consensus knows chain block 27,276, a walk that meets missing bodies below the retention root sets `blocked` and asks a peer for the snapshot; 8fe28de9: a flag's snapshot file whose tip consensus does not know yet is retried for 10 minutes | the proving agent's ecdccef3 (8fe28de9 inside), the coordinator's "take it" 15:1xZ; its harness PASSED on it 15:09:48Z (12 checks, both halves) | merged as release-0.3.13-node **bb43e9a8** (2 files, no params or proto change); igneum-exec 18 of 18; the digests unchanged |
| The six version files | 7c9b00f (`--check`: 0.3.13 in all 6) | |
A consensus change after all: the three exec-restart fields enter the digest once set, so this is the 0.3.12 two-publish shape (section 2),
not the one carried-over publish first planned. The thirteen-field object (the packaged line 71cb8a4, publish 2, the hands' and the seed's
files at step 2; `exec_restart_trust_daa` 200,000 pending the coordinator's word):
```
<the ten-field object of 0.3.12> + "exec_restart_number":27276,"exec_restart_hash":"bb45cf0dd2d7cc97ebfa5a2701527c09a8ede5d32de74efead9caa293b15688a","exec_restart_trust_daa":200000
```
PROTOCOL_VERSION 15 to 16: the handshake takes the lower version, a 0.3.12 and a 0.3.13 node peer during the window. The cut is no longer
node-only: the Ember tip rides in the app (above), so the Windows app build reran with the node fix.
### 1a. The devnet's one-time state reset (the coordinator's question, answered plainly)
No verified snapshot at chain block 27,276 exists. The executor starts at chain block 27,276 (DAA 45,537, the pruning point of 11:40Z) from an
EMPTY EVM state (`daemon.rs`: "Exec restart from the override file: the EVM state restarts empty at chain block {}"; 3dd9b2c9: "with
header-only records below it") and executes forward from the stored bodies; `exec_restart_hash` bb45cf0d... is that chain block's hash (where,
not a state root), and nothing is verified against a header's state root because there is no prior state to verify. Gone: every balance,
contract and nonce from before chain block 27,276 (the coinbase credits of the chain's first 45,537 DAA, the txgen harness wallets' transfers
from the relay tests, any contract state). Back, re-derived: coinbase credits from 27,276 on, every shard payout record (proving v0 began at
DAA 84,100, above the restart, so the proving ledger re-derives whole) and the fee flows after it.
| Reading | Before 11:37Z (node 1) | After the restart (the copy, 13:43Z) |
|---|---|---|
| `igneum_getProvingStatus` paidShards / paidWei | 663 / 814.64 IGN at 00:3xZ; 1,261 / 1,573 IGN at 08:30Z | 1,482 / 1,825.70 IGN (higher: it grows with the chain, nothing of it is lost) |
| PC 2's payout address 0xcafc6e74...516a | not read (no balance reading before 11:37Z exists anywhere: the hub's intake carries status lines, not balances) | 267,648 IGN (the rewards of chain blocks 27,276 to 130,272) |
| node 1's, PC 1's payout addresses | not read | re-derived from 27,276 on, as PC 2's |
| the first 45,537 DAA (chain blocks 1 to 27,275) | about 124,600 IGN of producer rewards (the subsidy 3.17 IGN at genesis rising to 3.67 at DAA 45,537 on the launch ramp, one blue block a second, the producer share 80%) and about 31,100 IGN of pool escrow (the proving agent's computation from `consensus/core/src/igneum.rs`, approximate) | gone; not attributable to addresses (the coinbase payloads with the IGNA payout addresses are in the pruned bodies, and the header's vote-key fallback names another address) |
| chain block 1 to 27,275 on the explorer | bodies and state | header-only; history below 27,276 is honest only as headers |
The chain, the finality locks and the hash are untouched; the reset is of the execution layer's state, once. the project lead approved the one-time reset
with the go (15:20Z).
## 2. The order at the go (the coordinator relays it; nothing below runs before)
Runbook: the session scratchpad's `r0313/rollout-0313.sh`. The 0.3.12 shape: publish 1 the binary with the TEN-field object (digest
7bd98cc4... unchanged, no window), publish 2 the THIRTEEN-field object (a new digest, one window per side).
| Step | What | Check |
|---|---|---|
| 0 the baseline | `step_check_observer`: `eth_blockNumber` 0x0 and `igneum_getProvingStatus` inactive on the observer today; node 1 read 814.64 IGN over 663 shards at 00:3xZ and 1,573 over 1,261 at 08:30Z (it grows with the chain) | the numbers to beat after the switch: paidShards >= 1,482, paidWei >= 1,825 IGN |
| 1a the hand nodes, the seed | `step_1a_hand_nodes`, `step_1a_seed`: the 0.3.13 binary with the SAME ten-field file; `step_mac_miners` only if the Mac mines (paused on the project lead's order since 07:10Z) | each prints 7bd98cc4...; `igneum_getExecStatus.blocked` says why the exec layer waits (the three fields are not set yet) |
| 1b publish 1 | `step_1b_ship` (`--from ci`): consensus CARRIED OVER (the ten-field object), the DMG, the installer and zip from the Windows run, HiveOS with `--public` | the live manifest 0.3.13, digest unchanged |
| 1c update-now | PC 1 FIRST (the project lead at its screen for the Ember click; the coordinator's 15:19Z order), then the Mac (its node is node 1: the engine alone), then PC 2 | each app's STATUS on 0.3.13, its node on 7bd98cc4; the coordinator told the second PC 1 shows 0.3.13 (the Ember elevated table run then takes PC 1 for about 45 minutes) |
| 2a the hand nodes, the seed | `step_2b_hand_nodes`, `step_2b_seed`: the thirteen-field file | each prints the new digest; within about 2 minutes `eth_blockNumber` climbs to the sink, `igneum_getExecStatus` reads `startedFrom "restart at chain block 27276"` (then "snapshot" on every restart after), `blocked null`, and `igneum_getProvingStatus` reads active with paidShards >= 1,482 and paidWei >= 1,825 IGN |
| 2b publish 2 | `step_2b_manifest`: the thirteen-field object, `--activation-height 198000`, the note names the exec restart | the live manifest carries the three fields |
| 2c the switch jobs | `step_2b_update_now` Mac (nothing to restart: node 1), then PC 2; PC 1's switch ONLY on the coordinator's "Ember closed" (a node restart under a step aborts the run) | each PC's node restarts once (seconds), its `[proving]` lines resume within minutes, its digest the new one |
| 3 the sweep | every node on the new digest, exec climbing, proving active; the fleet's rented nodes take the thirteen-field object through their operator (the fleet agent) | the per-machine times in section 4 |
| 3a a FRESH install | the proving agent's rented 4090 joins from scratch on the branch build the minute I send "publish 1 done" (the snapshot path: a 0.3.13 hand serves it over protocol 16) and again after publish 2 (the restart path): the start time, IBD done, the minute `eth_blockNumber` reached the sink, `startedFrom` and `blocked` then | the row in section 4: time to the executed tip |
## 3. Builds and artefacts (to fill when the fork tip is set)
| What | Command | Result |
|---|---|---|
| The fork's Mac node, a9dfe78e | `CARGO_TARGET_DIR=vendor/igneum-node/target-0313 cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow` from `vendor/igneum-node-0313`, under the lock (the target cloned from the route fix's) | 14:51:39 to 14:55:3xZ: igneumd **ef76ff5c1371317d783330e460ad4f6a1a8b3f2cdc55b228362ee64a227b30fa** (41,686,528), igneum-miner b7926642... (8,763,888); `igneumd/2.1.0-a9dfe78e` |
| The seed's Linux node (glibc 2.36 target, zig) | `NODE_SRC=<abs fork> TARGET_DIR=vendor/igneum-node/target-0313-linux OUT_DIR=<scratch>/r0313/cross infra/cross/build-linux.sh` under the lock | 14:51:47 to 14:55:12Z (203 s): igneumd **c7c696c5fa915350993b29378d3fceb252b88a1af880f6051472aef82f796777** (48,246,888), igneum-miner 77ab2e08... (9,860,400); handed to the fleet agent with the thirteen-field file at 14:56Z |
| The Windows node exes | `CARGO_TARGET_DIR=vendor/igneum-node/target-0313-win proto-cuda/windows-node/cross-build.sh <fork> 4` on the Mac (mingw) under the lock | 14:51:54 to 14:55:1xZ: igneumd.exe **f4e9ef8a83464535aa314e390682acb0ee0e23ceffea09815d546f5fd1ad90ae** (52,518,912), igneum-miner.exe 574adb79... (11,039,232) |
| The inputs, the pin | `IGNEUM_WIN_RELEASE=<target-0313-win>/x86_64-pc-windows-gnu/release IGNEUM_NODE_SRC=vendor/igneum-node-0313 packaging/windows/push-inputs.sh`, 14:56:51Z; the 0.3.11-verified workers 2b3b8c92.../edc4a75d... and the telemetry helper 8d679b52... unchanged | `payload-inputs.zip` 2327e1da165afbc2194fc7cd1f1be67c509b6845f6d7c032f6878a00a878991d (65,393,804), signed, live; `node-source.pin` a9dfe78e committed as **0c4f90e** (the CI commit) |
| The digests on the Mac node ef76ff5c... (ports 60995/60996, 22 s each, under `run`) | the ten-field file: **7bd98cc4...** (unchanged: publish 1 changes no handshake); the thirteen-field file: **b18ed271f75dd46406d230f4156c37472127415a4c32c558bac662f6f840e61c** with `Exec restart from the override file: the EVM state restarts empty at chain block 27276 bb45cf0d...` | |
| The DMG | `NODE=<fork igneumd> MINER=<fork igneum-miner> PROVE_HOST/PROVE_EXPORT=<the 0.3.12 build, unchanged> packaging/mac/build-dmg.sh` under the lock | 14:58:18 to 14:58:5xZ: `Igneum-Miner-0.3.13.dmg` **90864092fb69a90c0bd90fbfba91f24f9663499252c86e453abe3fa5dedbe716** (41,879,478), engine 0.3.13, node a9dfe78e (41,462,352 inside), the prover host ce03ceb5..., `igneum-bench` from `proto-metal/main.swift` (unchanged), `packaged-config` carries the thirteen-field object, hdiutil checksum valid |
| The HiveOS package | `NODE_OUT=<scratch>/r0313/cross WORKERS_OUT=<the 0.3.11 Linux workers> VERSION=0.3.13 packaging/hive/make-hive-package.sh`, then `publish-public.sh --hive` into `dl/public` (the 0.3.12 package removed; the ship's deploy carries it) | 14:58:59Z: `igneum-hive-0.3.13.tar.gz` **41e0633b2677005fabd360ad80669221ef8f7ea4da0a4aa48e1b659df8718eec** (24,632,169); the node inside is a9dfe78e |
| The node suites with the igneum-pow feature (the Mac, no fail-fast) | `CARGO_TARGET_DIR=vendor/igneum-node/target-0313 cargo test --release -j 4 --no-fail-fast -p kaspa-consensus -p kaspa-consensus-core -p igneum-exec -p kaspa-pow -p igneum-miner -p kaspa-p2p-flows -p kaspa-p2p-lib --features kaspa-consensus/igneum-pow,kaspa-pow/igneum-pow` from the fork, under the lock, 14:58:26 to 15:00:1xZ | igneum-miner 18 of 18, p2p-flows 33 of 33 (the IBD vote skip inside), p2p-lib 19 of 19 (the overflow-policy test), kaspa-pow 14 of 14, `db_compat` 7 of 7; kaspa-consensus 99 passed, 1 failed (the known M20 era test; the finality ban test green this run); consensus-core 107 passed, 1 failed (the known fast-time file duplicate-key test); igneum-exec 17 passed, **1 failed, NEW**: `proving::tests::assignment_follows_the_window_and_records_check_against_native_execution` (`proving.rs:1171`: the test expects a record to be refused, the code after 05e93f0e checks it as `assigned: true`; the crate was 17 of 17 on 83089544): the proving agent's to resolve before the gate (a fix commit, or the test's expectation is the stale half) |
| The Windows run, first round | `windows.yml` run 37483331039 on 0c4f90e (dispatched 14:57:37Z); `ci` 37483332527 | both green by 15:06Z; its installer eab82086... and zip cef39414... carry the a9dfe78e node (the over-paying trust rule): SUPERSEDED, not shipped |
| The second round (node 544fc30f, app 4deea56) | the Mac node rebuilt 15:02 to 15:03Z: igneumd **dd5eeda5b92463e929d07479b2fc77b4ba75c7e9354ab48a96ed22a9637e0b4d** (41,703,168); the Linux node 15:04Z: igneumd **2449d5fa3b16531b33068b75c3e5045de580119c9824053a8eaf58dfe6d8c484** (48,246,632), handed to the fleet agent in place of c7c696c5; the Windows node 15:04Z: igneumd.exe **b06f08dac020f639d2dbeec20b60f48d7025c333e59eaa3fbbed037ea33b96b9** (52,518,912); the digests re-read on dd5eeda5: 7bd98cc4... (ten) and b18ed271... (thirteen), unchanged; the inputs pushed 15:05:16Z: `payload-inputs.zip` 3a99a86f6d4dcbe4c4cdd3cc11c13e8973b1af9f3c87647cd1a420885ea94819 (65,393,980), `node-source.pin` 544fc30f as **55ef102** (the CI commit) | |
| The Windows run, second round | `windows.yml` run 37484511273 on 55ef102; the DMG 3697306e... and HiveOS 6933c0c7... on 544fc30f | SUPERSEDED by the third round (the fresh-joiner fix); the 544fc30f artefacts kept aside under the scratchpad |
| The third round (node bb43e9a8, app 4deea56) | the Mac node 15:09 to 15:10Z: igneumd **487312aa31c85bde583b7cea220ba2dd76cd4c64913c224c58664ca12f4f4f23** (41,719,760), igneum-miner b7926642...; the Linux node 15:10Z: igneumd **d6350586fe837b1f71696546628ec071b6accce2531aef776cf2b1e5487a8cdc** (48,263,528), handed to the fleet agent in place of 2449d5fa (and c7c696c5 before it); the Windows node 15:1xZ: igneumd.exe **c4441a3abed26465f2bddef6a60b237444219d4dcb6470694e19430d56d9f830** (52,527,616), igneum-miner.exe 574adb79...; the digests re-read on 487312aa: 7bd98cc4... (ten) and b18ed271... (thirteen); the inputs pushed 15:11:30Z: `payload-inputs.zip` 561878b8dcd91803ad3ff8055190e1201ba48975936bcbcdac17827f0fe7bb34 (65,398,104), `node-source.pin` bb43e9a8 as **be344ff** (the CI commit) | |
| The Windows run | `windows.yml` run 37485442000 on be344ff (dispatched 15:12:16Z); `ci` 37485442462 | both GREEN 15:18:48Z: `Igneum-Miner-Setup-0.3.13.exe` **499a8ede6268426342ffd3ae0da2354f3a14522dda7ff7c41aa21ae3e169deab** (45,396,595); `igneum-windows-app.zip` **dc3116242fc55c22259e5eb0339a5cb9dfbd8a4e7deb918f01307f291e3d1b76** (65,664,639), its igneumd.exe c4441a3a... (the bb43e9a8 cross-build); fetched 15:18:5xZ, not deployed. The 0.3.13 CI verdict: ci 37485442462 on be344ff; the Windows build 37485442000 on be344ff |
| The DMG (third round) | `NODE=<fork igneumd 487312aa> MINER=... PROVE_HOST/PROVE_EXPORT=<the 0.3.12 build, unchanged> packaging/mac/build-dmg.sh` under the lock | 15:12:51 to 15:13:1xZ: `Igneum-Miner-0.3.13.dmg` **2d35a0160925ef5fcd6d85dc653deab328c20261bfac1e37a7c3e8f4c18baf48** (41,859,802), engine 0.3.13 (the Ember helper inside), node bb43e9a8, `packaged-config` with the thirteen-field object, hdiutil checksum valid |
| The HiveOS package (third round) | `make-hive-package.sh` from the d6350586 Linux node and the 0.3.11 Linux workers, then `publish-public.sh --hive` into `dl/public` (the ship's deploy carries it) | `igneum-hive-0.3.13.tar.gz` **65ea42600236c7024844d85fc401ef2c4650e671d92061c8db6415038bac9530** (24,634,543) |
## 4. The rollout (the project lead's go 15:20Z through the coordinator; two publishes)
Baseline 15:19:45Z: tip DAA about 196,900; the observer and node 1 were DOWN since 14:56:28Z (both SIGTERMed by a hand that was not mine,
the same minute the Igneum Wallet app's own node started on this Mac, pid 81040 on 26620/26621/26800; the live stats stale 24 minutes);
the seed on 83089544 at 7bd98cc4; the Mac 0.3.12 (paused on the project lead's order), PC 2 0.3.12 at 115 MH/s, PC 1 0.3.12 with the project lead at its screen.
| Step | Time | Result |
|---|---|---|
| 1a the observer, node 1 | 15:20:38Z (pid 46848), 15:20:51Z (pid 48213) | `igneumd/2.1.0-bb43e9a8` on the ten-field file, 7bd98cc4...; the exec layer reads `blocked: exec not synced: the executor is at chain block 0 and the bodies below this node's retention root are gone`, `startedFrom genesis`, as designed before the three fields |
| 1a the seed | 15:21:18Z (MainPID 140366) | the same binary (d6350586...), the same digest |
| the fleet's first word | 15:24Z | "hands on 0.3.13" to the fleet agent (22 boxes on d6350586 with the ten-field file) |
| 1b publish 1 | the ship 15:22:01 to 15:23:45Z from 5ca4913 | ci "already" (37485442000), fetch "already", dmg "already", copy ok, manifest 0.3.13 published 15:22:05Z with `consensus` CARRIED OVER (the ten-field object), deployed 15:22:40Z, HiveOS 65ea4260... served |
| 1c update-now, PC 1 FIRST | 15:23:56Z | ran 15:24:42Z; engine restart 15:24:52Z (run `win-ae432dc7-20261006-152452`), "updated to Igneum Miner 0.3.13 from 0.3.12", per-user install, no prompt; cards "RTX 5090, RTX 4070, AMD integrated, RX 9070 XT"; mining 15:25:53Z; digest 7bd98cc4; the coordinator told 15:27Z, the Ember elevated table run then took PC 1 |
| 1c update-now, the Mac | 15:24:39Z | ran 15:25:11Z; engine restart 15:25:19Z (run `mac-d937c69d-20261006-152519`), 0.3.13, paused as ordered, node 1 six peers |
| 1c update-now, PC 2 | 15:25:08Z | ran 15:25:54Z; engine restart 15:26:06Z (run `win-1ccfe586-20261006-152606`), 0.3.13, worker ready 15:27:06Z, mining 15:27:07Z, digest 7bd98cc4 |
| 2a the observer, node 1 | 15:29:15Z (pid 63960), 15:29:29Z (pid 64106) | the thirteen-field file: **b18ed271...**, `Exec restart from the override file: the EVM state restarts empty at chain block 27276 bb45cf0d...` |
| 2a the seed | 15:29:53Z (MainPID 140546) | b18ed271... |
| 2b publish 2 | 15:30:03Z | the manifest with the thirteen-field object, activation 198000, "proving v1 fresh-record rule; exec restart at chain block 27276"; signed, verified, deployed |
| the exec checks, the observer (134,556 is the selected-chain height: chain blocks, one per selected-parent step, under the DAG's 151,606 blocks and the DAA 197,219; the two flat minutes were the node's own consensus re-sync after its restart, not the follower; the proving agent's reading) | 15:33:23Z | `eth_blockNumber` 134,556 (from 0 at 15:21Z), `igneum_getExecStatus` startedFrom "restart at chain block 27276", blocked null, `igneum_getProvingStatus` active, paidShards **1,482**, paidWei **1825.699240038 IGN**: the copy's numbers to the wei; the state persisted at 134,556 (118.7 MB, sha 0xe5194123...) at 15:35Z; 134,884 at 15:37:55Z, 134,909 at 15:39:47Z (the follower's rate after the restart: 1.2 then 0.2 chain blocks a second, under watch) |
| the fleet's second word | 15:35Z | "hands on b18ed271" to the fleet agent (STEP=file on 22 boxes; its per-box seconds-to-climbing follow) |
| 2c switch, the Mac | 15:35:42Z | ran 15:36:12Z: "consensus override changed; the node restarts with it at a safe moment"; its node is node 1, already switched |
| 2c switch, PC 2 | 15:36:08Z | ran 15:37:02Z, node restarted 15:37:03Z (pid 27552) on b18ed271, "Exec restart ... shard records carried below DAA score 200000 are paid as carried"; mining again 15:38:38Z, 13.7 MH/s ramping at 15:39:08Z. Its prover then logged `block 35012 shard 0: exporter: Error: segment 0: port state root 0x7e37a9fb... differs from the node's` and the same for block 61972: the assignment window hands it blocks far below the tip whose carried records were made over the old state; the re-derived state at those heights has another root, so those shards cannot be proven (they pay as carried below the trust DAA, so nothing is lost but prover passes): the proving agent's ruling: REAL and not designed. The exporter (igneum-prove-export) rebuilds a fixture's pre-state by replaying the exported chain from genesis, crediting block rewards from the headers, so above the restart it holds 27,276 blocks of rewards the node's restarted state never had and its root differs from the node's record: every shard assigned above R fails on every prover (the fleet's boxes too) until the fix, so every segment and shard payout stops from publish 2 until 0.3.14 (the carried records below the trust DAA pay regardless). The fix (about an hour, its commits to follow): `igneum_exportSegments` carries the restart (number, hash) and the exporter starts its replay at R from the registry-only state; one RPC field on the node, the exporter side in the proving package; 0.3.14's first item |
| 2c switch, PC 1 (last) | 15:42:50Z, on the coordinator's "Ember closed" (its run ended 15:39:03Z) | ran 15:43:36Z, node restarted 15:43:37Z (pid 3632) on b18ed271 with the exec restart line; the miner waiting at 15:43:54Z and mining again within the minute (the C43 class: a minute at 0 MH/s after a node restart); three cards |
| node 1's refusals | 15:39Z | 12 in the last 400 lines: the peers still on 7bd98cc4 (PC 1, the unswitched fleet boxes, the Igneum Wallet 0.1.4's bundled node on this Mac, which nobody updates: a wallet cut owes the thirteen-field object) |
| a FRESH install (the proving agent's rented 4090) | "publish 1 done" sent 15:25Z, "publish 2 done" 15:36Z | (its numbers pending) |
### 4a. The incident after publish 2 (15:42 to 15:52Z): the activation inside the window, the deep reorg, the moved pruning point
| Time | What |
|---|---|
| 15:42:57Z | `proving_v1_fresh_rule_daa` 198,000 armed while PC 1 (its switch held for the Ember run) and the fleet's unswitched boxes still mined on the ten-field object: two sides for a minute, the losing side 229 blocks deep (the coordinator's reading); PC 1's switch job went 15:42:50Z, its node on b18ed271 at 15:43:37Z, 40 s after the arming |
| 15:44:47Z | the hands' exec layer: `selected-chain reorg: 274 chain blocks removed, unwinding to height 134884 ... reorg deeper than the snapshot ring; replaying from genesis`; genesis executed, then nothing; `eth_blockNumber` 0, `startedFrom genesis`, `blocked null`; the same on the seed and on every fleet box that had come back (seven of them had reached the tip through the restart path in 87 to 188 s each: hub 88 s, 3080 87 s, 4070-1 113 s, 4090-3 138 s, rig-4090x8 138 s, A5000 163 s, 3090-4 188 s) |
| 15:48:00Z | the observer and node 1 restarted by me (the hands script, the same file): the restart path REFUSED: `sink ... is chain block 0; pruning point eb2a5d70... is chain block None (DAA 88763); retention root eb2a5d70... (DAA 88763)`, `exec restart at chain block 27276 ... not yet possible (the selected-parent walk from the sink never met the queried block)`: the devnet's pruning point moved from bb45cf0d (DAA 45,537) to eb2a5d70 (DAA 88,763) since 11:40Z, the anchor is below it and off the walk, and the genesis replay had overwritten the good 119 MB snapshot (tip 135,138) with a 2,629-byte tip-0 one (`exec-snapshot.prev.bin` keeps the good one). The exec layer and proving are at 0 on every node until the fix |
| the fix | the proving agent's, as 0.3.14 (the tooling refuses "0.3.13.1"): never replay from genesis on a pruned node (unwind through the persisted generations, else a peer's snapshot over protocol 16); keep executing from the persisted state when the pruning point passes the anchor (the anchor only for a node with nothing); never overwrite a good snapshot with a tip-0 one |
| node 1's follower | found in the same hour: node 1's `chain follower stopped` at every start since 12:37Z because both hands bound the eth_ JSON-RPC on 26790 and node 1 lost ("Address already in use"), so the Mac app's node never executed; `restart-hand-nodes.sh` now gives node 1 26791 (this commit), live at the next hand restart |
Rules from it (the coordinator's four and two more): the switch jobs go out before the height, no miner stays on the old side across an
activation; an activation height is never set inside a rollout window (the floor of 10,800 exists for that); a deep reorg must not reset the
exec layer (unwind through the persisted generations, else a peer's snapshot; never a genesis replay on a pruned node); the pruning point must
not strand an anchor (a node keeps executing from its persisted state, the anchor is for a node with nothing); a hand node restart is
announced before it runs (the 14:56:28Z SIGTERM of both hands by an unnamed hand cost 24 minutes of stale stats); the two hands never share a port.
The exec checks of section 2 are therefore RED at the close of this cut (every node at `eth_blockNumber` 0 since 15:44:47Z, `startedFrom`
genesis or snapshot-at-0, `paidShards` 0; the chain, the finality locks and the hash untouched; the proving ledger below the trust DAA pays
as carried once the exec layer is back); the cause is the pruning point past the anchor and the genesis replay over the snapshot; the fix is
0.3.14 (node-only, the proving agent's branch; one publish with the object carried over and a hand-node swap first if it is code-only, the
exec check per node the gate; two publishes only if a field changes; no re-pin of the anchor). The coordinator's decision 15:53Z.
## 5. The one line for the project lead
When he says go: the execution layer comes back on every node at its restart (the hands and the seed by hand, the Mac, PC 2 and PC 1
through two update-nows each, the node restarting once for the switch), with the state restarted empty at chain block 27,276 (6 October
11:40Z, DAA 45,537) and re-derived forward, so every IGN earned since then by mining and proving is back on the ledger (PC 2's address
267,648 IGN; 1,482 shards, 1,825.70 IGN of prover payouts) and the chain's first 45,537 DAA (about 124,600 IGN of mining rewards and 31,100
IGN of escrow, approximate) are gone from it and cannot be given back to addresses, once; the chain, the finality locks and the hash are untouched; a fresh node joining the devnet no longer loses the seed every
checkpoint once the seed runs this.
## 6. Owed

View file

@ -26,7 +26,7 @@ nohup "$BIN" --devnet --nodnsseed --disable-upnp --appdir=/tmp/igneum-devnet/obs
lines /tmp/igneum-devnet/observer-v4.out lines /tmp/igneum-devnet/observer-v4.out
# node 1, under caffeinate as it runs today # node 1, under caffeinate as it runs today
stop "igneumd --devnet.*appdir=/tmp/igneum-devnet/node1 " stop "igneumd --devnet.*appdir=/tmp/igneum-devnet/node1 "
nohup caffeinate -dims "$BIN" --devnet --nodnsseed --disable-upnp --enable-unsynced-mining --appdir=/tmp/igneum-devnet/node1 --rpclisten=0.0.0.0:26610 --listen=0.0.0.0:26611 \ nohup caffeinate -dims "$BIN" --devnet --nodnsseed --disable-upnp --enable-unsynced-mining --appdir=/tmp/igneum-devnet/node1 --rpclisten=0.0.0.0:26610 --listen=0.0.0.0:26611 --evm-rpclisten=127.0.0.1:26791 \
--addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" --nologfiles --yes >> /tmp/igneum-devnet/node1.out 2>&1 & --addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" --nologfiles --yes >> /tmp/igneum-devnet/node1.out 2>&1 &
lines /tmp/igneum-devnet/node1.out lines /tmp/igneum-devnet/node1.out
echo "running now:"; ps -o pid=,lstart=,command= -p "$(pgrep -f 'igneumd --devnet' | tr '\n' ',' | sed 's/,$//')" | cut -c1-160 echo "running now:"; ps -o pid=,lstart=,command= -p "$(pgrep -f 'igneumd --devnet' | tr '\n' ',' | sed 's/,$//')" | cut -c1-160

View file

@ -11,7 +11,7 @@
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>VERSION_STAMP</string> <string>VERSION_STAMP</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.12</string> <string>0.3.13</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>

View file

@ -28,7 +28,7 @@ DL_HOST="https://dl.igneum.network"
# carry the devnet's activation height here, the same N as every other devnet node, before it is cut (Mac and CI alike: # carry the devnet's activation height here, the same N as every other devnet node, before it is cut (Mac and CI alike:
# make-payload.sh sources this file). Rule and order: docs/plans/difficulty-v2-rollout-devnet.md. # make-payload.sh sources this file). Rule and order: docs/plans/difficulty-v2-rollout-devnet.md.
# Example: NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa": 123456}' # Example: NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa": 123456}'
NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200,"program_class_v3_activation_daa":154800,"proving_v1_activation_daa":154800,"proving_v1_segment_blocks":8,"proving_v1_unproven_daa":600,"proving_v1_aggregator_share_bps":1000,"proving_v1_fresh_rule_daa":198000}' NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200,"program_class_v3_activation_daa":154800,"proving_v1_activation_daa":154800,"proving_v1_segment_blocks":8,"proving_v1_unproven_daa":600,"proving_v1_aggregator_share_bps":1000,"proving_v1_fresh_rule_daa":198000,"exec_restart_number":27276,"exec_restart_hash":"bb45cf0dd2d7cc97ebfa5a2701527c09a8ede5d32de74efead9caa293b15688a","exec_restart_trust_daa":200000}'
# igneum_secret_file <env var name> <base name> -> the file to read: the variable when set, else <base>.next when it # igneum_secret_file <env var name> <base name> -> the file to read: the variable when set, else <base>.next when it
# exists, else <base>; IGNEUM_CONFIG_DIR (tests) replaces ~/.config/igneum # exists, else <base>; IGNEUM_CONFIG_DIR (tests) replaces ~/.config/igneum

View file

@ -9,7 +9,7 @@
#define ArtDir "..\..\brand\icons" #define ArtDir "..\..\brand\icons"
#endif #endif
#ifndef AppVersion #ifndef AppVersion
#define AppVersion "0.3.12" #define AppVersion "0.3.13"
#endif #endif
#define AppName "Igneum Miner" #define AppName "Igneum Miner"
#define Publisher "Igneum" #define Publisher "Igneum"

View file

@ -1 +1 @@
830895447497bdea5e4779c9aa9d4118eac3cbbc bb43e9a85f2683786fccc98d5533e85828b24138

View file

@ -15,7 +15,7 @@
# therefore measure only tonight unless the engine finds itself elevated. # therefore measure only tonight unless the engine finds itself elevated.
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Continue'
$resultTag = 'TUNE' $resultTag = 'TUNE'
$budgetMinutes = 35 $budgetMinutes = 45
if (-not ($budgetMinutes -is [int]) -or $budgetMinutes -lt 5) { $budgetMinutes = 35 } # a budget under 5 minutes is a bug, not a budget (C35) if (-not ($budgetMinutes -is [int]) -or $budgetMinutes -lt 5) { $budgetMinutes = 35 } # a budget under 5 minutes is a bug, not a budget (C35)
$started = Get-Date $started = Get-Date
$deadline = $started.AddMinutes($budgetMinutes) $deadline = $started.AddMinutes($budgetMinutes)
@ -33,20 +33,30 @@ $appDir = $env:IGNEUM_APP_DIR
if (-not $appDir) { $appDir = Join-Path $appData 'app' } if (-not $appDir) { $appDir = Join-Path $appData 'app' }
# the scratch install: the whole folder (workers, node, helper, DLLs) with the Ember engine swapped in # the scratch install: the whole folder (workers, node, helper, DLLs) with the Ember engine swapped in
$root = Join-Path $env:LOCALAPPDATA 'igneum-tune' # a FRESH scratch root per run (run 3, 6 October 2026, 11:45Z: the folder an earlier elevated engine had locked to itself
# refused the settings copy, the engine started on stale files and exited in 6 s); old roots are small and left alone
$root = Join-Path $env:LOCALAPPDATA ('igneum-tune-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
$bin = Join-Path $root 'bin' $bin = Join-Path $root 'bin'
$sApp = Join-Path $root 'app' $sApp = Join-Path $root 'app'
$sLogs = Join-Path $root 'logs' $sLogs = Join-Path $root 'logs'
New-Item -ItemType Directory -Force -Path $root, $sApp, $sLogs | Out-Null New-Item -ItemType Directory -Force -Path $root, $sApp, $sLogs | Out-Null
if (Test-Path $bin) { Remove-Item -LiteralPath $bin -Recurse -Force -ErrorAction SilentlyContinue } if (Test-Path $bin) { Remove-Item -LiteralPath $bin -Recurse -Force -ErrorAction SilentlyContinue }
Copy-Item -LiteralPath $installDir -Destination $bin -Recurse -Force Copy-Item -LiteralPath $installDir -Destination $bin -Recurse -Force
# the installed engine carries Ember Tune from 0.3.12 on: prefer it; the kit is for a PC still on an older app
$installedVer = (& (Join-Path $installDir 'igneum-app.exe') --version 2>&1 | Out-String).Trim()
$installedHasEmber = $false
if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber = ([int]$Matches[1] -gt 0) -or ([int]$Matches[2] -gt 3) -or (([int]$Matches[2] -eq 3) -and ([int]$Matches[3] -ge 12)) }
$ember = $null $ember = $null
foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } # ember-kit-3 (the engine with Settings::for_measurement) is preferred when present, whatever the installed version;
# older kits only when the installed app predates Ember Tune
$k3 = Join-Path $appDir 'jobs\ember-kit-5\igneum-app-ember.exe'
if (Test-Path $k3) { $ember = $k3 }
elseif (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } }
if ($ember) { if ($ember) {
Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force
Say ("engine: the Ember build from " + $ember) Say ("engine: the Ember build from " + $ember)
} else { } else {
Say 'engine: the installed one (no jobs\ember-kit-1\igneum-app-ember.exe); an older engine ignores the tune and reports no_rows' Say ('engine: the installed one (' + $installedVer + $(if ($installedHasEmber) { ', carries Ember Tune' } else { '; no kit found: an older engine ignores the tune and reports no_rows' }) + ')')
} }
$helper = $null $helper = $null
$found = Get-ChildItem -Path (Join-Path $appDir 'jobs') -Recurse -Filter 'igneum-gpu-telemetry.exe' -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match 'amd-kit' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $found = Get-ChildItem -Path (Join-Path $appDir 'jobs') -Recurse -Filter 'igneum-gpu-telemetry.exe' -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match 'amd-kit' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1
@ -61,37 +71,22 @@ Say ("engine: " + $exe + " (" + $ver + ")")
Write-Output ("RESULT TUNE engine " + $ver + " sha256=" + (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash.ToLower()) Write-Output ("RESULT TUNE engine " + $ver + " sha256=" + (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash.ToLower())
if ($ver -notmatch 'igneum-app (\d+)\.(\d+)\.(\d+)') { Write-Output 'RESULT TUNE error=version_unknown'; exit 2 } if ($ver -notmatch 'igneum-app (\d+)\.(\d+)\.(\d+)') { Write-Output 'RESULT TUNE error=version_unknown'; exit 2 }
foreach ($f in @('settings.json', 'machine-id', 'wallet.json', 'tuning.json')) { foreach ($f in @('settings.json', 'machine-id', 'wallet.json', 'tuning.json', 'firewall-rule.json')) { # the firewall flag too: an older kit then asks nothing
$src = Join-Path $appDir $f $src = Join-Path $appDir $f
if (Test-Path $src) { Copy-Item -LiteralPath $src -Destination (Join-Path $sApp $f) -Force } if (Test-Path $src) { Copy-Item -LiteralPath $src -Destination (Join-Path $sApp $f) -Force }
} }
# the second engine must not poll jobs (it would see this one), update itself, or prove; the tune is on # the copies are VERBATIM (run 4, 6 October 2026: a PowerShell ConvertFrom-Json | ConvertTo-Json round trip rewrote big
# integers as doubles, the engine read the file as defaults, no payout address, every card off, 96 old jobs run in
# the scratch root); the engine itself switches remote jobs, updates, proving and Power control off under --sweep
# (Settings::for_measurement) and makes every card due. Only a report line is read here.
$sj = Join-Path $sApp 'settings.json' $sj = Join-Path $sApp 'settings.json'
if (Test-Path $sj) { if (-not (Test-Path -LiteralPath $sj)) { Write-Output 'RESULT TUNE error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 }
try { $installedPowerControl = 'unknown'; $addr = ''; $ncards = 0
$j = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json try { $back = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json; $addr = [string]$back.address; if ($back.cards) { $ncards = @($back.cards.PSObject.Properties).Count }; if ($back.PSObject.Properties.Name -contains 'power_control') { $installedPowerControl = ([bool]$back.power_control).ToString().ToLower() } } catch { }
$j.remote_jobs = $false; $j.auto_update = $false; $j.prove = $false; $j.paused = $false; $j.setup_done = $true; $j.sweep = $true $bom = (Get-Content -LiteralPath $sj -Encoding Byte -TotalCount 3 -ErrorAction SilentlyContinue) -join ','
# the project lead, 6 October 2026, 07:20Z: never raise an administrator prompt. The installed app's Power control is READ and Write-Output ('RESULT TUNE installed_power_control=' + $installedPowerControl + ' (the tune engine runs with it off: no prompt unless the job itself is elevated)')
# reported, but the copy runs with it OFF so the second engine can never start the elevated helper; the 5090 is Write-Output ('RESULT TUNE scratch settings (verbatim copy): address ' + $(if ($addr) { $addr.Substring(0, [Math]::Min(10, $addr.Length)) + '...' } else { 'EMPTY' }) + ', cards ' + $ncards + ', first bytes ' + $bom + ', ' + (Get-Item -LiteralPath $sj).Length + ' bytes')
# measured as it runs either way (the two-knob tune is the installed engine's job once it carries Ember Tune) if (-not $addr -and -not (Test-Path (Join-Path $sApp 'wallet.json'))) { Write-Output 'RESULT TUNE error=no_address reason=the_copied_settings_carry_no_payout_address_and_no_wallet.json'; exit 2 }
$installedPowerControl = $false
try { $installedPowerControl = [bool]$j.power_control } catch { }
Write-Output ('RESULT TUNE installed_power_control=' + $installedPowerControl.ToString().ToLower() + ' (the copy runs with it off: no prompt)')
if ($j.PSObject.Properties.Name -contains 'power_control') { $j.power_control = $false } else { $j | Add-Member -NotePropertyName power_control -NotePropertyValue $false }
# every card is due: the stored results are cleared in the COPY only
if ($j.cards) { foreach ($p in $j.cards.PSObject.Properties) { $p.Value.sweep_at = 0; $p.Value.pinned = $false } }
# PowerShell 5.1's Set-Content -Encoding utf8 writes a BOM, which the engine's JSON parser refuses: the copy then
# read as defaults (no payout address, no cards) and the miners never started (runs 1 and 2, 5 and 6 October 2026)
[IO.File]::WriteAllText($sj, ($j | ConvertTo-Json -Depth 8), (New-Object System.Text.UTF8Encoding $false))
} catch { Say ("settings.json: " + $_.Exception.Message) }
$back = $null
try { $back = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json } catch { }
$addr = ''; if ($back) { $addr = [string]$back.address }
$bom = (Get-Content -LiteralPath $sj -Encoding Byte -TotalCount 3 -ErrorAction SilentlyContinue) -join ','
Write-Output ('RESULT TUNE scratch settings: address ' + $(if ($addr) { $addr.Substring(0, [Math]::Min(10, $addr.Length)) + '...' } else { 'EMPTY' }) + ', cards ' + $(if ($back -and $back.cards) { @($back.cards.PSObject.Properties).Count } else { 0 }) + ', first bytes ' + $bom)
if (-not $addr -and -not (Test-Path (Join-Path $sApp 'wallet.json'))) { Write-Output 'RESULT TUNE error=no_address reason=the_copied_settings_carry_no_payout_address_and_no_wallet.json'; exit 2 }
if ($bom -eq '239,187,191') { Write-Output 'RESULT TUNE error=bom reason=settings.json_starts_with_a_BOM'; exit 2 }
} else { Write-Output 'RESULT TUNE error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 }
Remove-Item -LiteralPath (Join-Path $sApp 'app.url') -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path $sApp 'app.url') -Force -ErrorAction SilentlyContinue
# the state before, for the report # the state before, for the report
@ -139,6 +134,12 @@ $rows = 0
$firstStatusAt = $null $firstStatusAt = $null
$lastMining = $null $lastMining = $null
$lastEngineLine = '' $lastEngineLine = ''
function EngineLogDump([string] $why) {
Write-Output ('===== engine log tail (' + $why + ')')
$t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($t) { Get-Content -LiteralPath $t.FullName -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch 'status: accepted 0 blocks' } | Select-Object -Last 80 | ForEach-Object { Write-Output (' ' + $_) } } else { Write-Output ' (no app-*.log in the scratch logs folder)' }
if (Test-Path -LiteralPath $errFile) { Write-Output '===== engine stderr'; Get-Content -LiteralPath $errFile -ErrorAction SilentlyContinue | Select-Object -Last 20 | ForEach-Object { Write-Output (' ' + $_) } }
}
function EngineTail() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $l = Get-Content -LiteralPath $t.FullName -Tail 1 -ErrorAction SilentlyContinue; if ($l) { return [string]$l } }; return '' } function EngineTail() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $l = Get-Content -LiteralPath $t.FullName -Tail 1 -ErrorAction SilentlyContinue; if ($l) { return [string]$l } }; return '' }
while (-not $p.HasExited) { while (-not $p.HasExited) {
Start-Sleep -Seconds 5 Start-Sleep -Seconds 5
@ -150,12 +151,14 @@ while (-not $p.HasExited) {
} }
if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) { if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) {
Write-Output ('RESULT TUNE error=not_mining reason=no_card_mined_within_120_s_of_the_first_status_line last_log_line=' + ($lastEngineLine -replace '\s+', '_')) Write-Output ('RESULT TUNE error=not_mining reason=no_card_mined_within_120_s_of_the_first_status_line last_log_line=' + ($lastEngineLine -replace '\s+', '_'))
EngineLogDump 'watchdog: not mining'
EndTree $p.Id 'watchdog: not mining' EndTree $p.Id 'watchdog: not mining'
Write-Output 'RESULT TUNE error=no_rows' Write-Output 'RESULT TUNE error=no_rows'
exit 3 exit 3
} }
if ($lastMining -and ((Get-Date) - $lastMining).TotalSeconds -gt 300) { if ($lastMining -and ((Get-Date) - $lastMining).TotalSeconds -gt 300) {
Write-Output ('RESULT TUNE error=stopped_mining reason=every_card_idle_for_300_s last_log_line=' + ($lastEngineLine -replace '\s+', '_')) Write-Output ('RESULT TUNE error=stopped_mining reason=every_card_idle_for_300_s last_log_line=' + ($lastEngineLine -replace '\s+', '_'))
EngineLogDump 'watchdog: stopped mining'
EndTree $p.Id 'watchdog: stopped mining' EndTree $p.Id 'watchdog: stopped mining'
Write-Output 'RESULT TUNE error=no_rows' Write-Output 'RESULT TUNE error=no_rows'
exit 3 exit 3
@ -173,8 +176,8 @@ while (-not $p.HasExited) {
# C35 (5 October 2026): the only quit this script may send goes to the TUNE engine's own URL file in the scratch # C35 (5 October 2026): the only quit this script may send goes to the TUNE engine's own URL file in the scratch
# root, never to a file under the installed app's folder; the RESULT line names the file it used # root, never to a file under the installed app's folder; the RESULT line names the file it used
$u = Join-Path $sApp 'app.url' $u = Join-Path $sApp 'app.url'
$installedUrl = Join-Path $appDir 'app.url' # the only URL file this script may quit is its own scratch root's; the installed app's folder is refused by name
if ((Resolve-Path -LiteralPath $u -ErrorAction SilentlyContinue).Path -eq (Resolve-Path -LiteralPath $installedUrl -ErrorAction SilentlyContinue).Path -or $u -like '*\igneum\app\*') { if ((Resolve-Path -LiteralPath $u -ErrorAction SilentlyContinue).Path -like (Join-Path $appDir '*') -or $u -like '*\igneum\app\*') {
Write-Output ('RESULT TUNE quit refused: ' + $u + ' is the installed app''s URL file') Write-Output ('RESULT TUNE quit refused: ' + $u + ' is the installed app''s URL file')
} elseif (Test-Path -LiteralPath $u) { } elseif (Test-Path -LiteralPath $u) {
Write-Output ('RESULT TUNE quit asked of the tune engine through ' + $u + ' (pid ' + $p.Id + ')') Write-Output ('RESULT TUNE quit asked of the tune engine through ' + $u + ' (pid ' + $p.Id + ')')

View file

@ -52,13 +52,8 @@ function Stop-App {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $stop 2>&1 | ForEach-Object { Say (" " + (Strip "$_")) } & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $stop 2>&1 | ForEach-Object { Say (" " + (Strip "$_")) }
return return
} }
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url' # (5 October 2026 rule: a job never quits the installed app it did not start; the api/quit that stood here is gone.
if (Test-Path $urlFile) { # Stopping the app is the signed `restart` job kind's work; without the stop script this waits for the app to stop.)
$url = (Get-Content $urlFile -Raw).Trim()
if ($url) {
try { Invoke-WebRequest -Uri ($url + 'api/quit') -Method POST -Body '{}' -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 | Out-Null; Say 'asked the engine to quit over its local API' } catch { Say ('local API did not answer: ' + $_.Exception.Message) }
}
}
$until = (Get-Date).AddSeconds(50) $until = (Get-Date).AddSeconds(50)
while ((Get-Date) -lt $until) { while ((Get-Date) -lt $until) {
if (@(Get-Process -Name 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue).Count -eq 0) { break } if (@(Get-Process -Name 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue).Count -eq 0) { break }

View file

@ -37,15 +37,8 @@ foreach ($f in @('settings.json', 'machine-id', 'wallet.json')) {
$src = Join-Path $appDir $f $src = Join-Path $appDir $f
if (Test-Path $src) { Copy-Item -LiteralPath $src -Destination (Join-Path $sApp $f) -Force } if (Test-Path $src) { Copy-Item -LiteralPath $src -Destination (Join-Path $sApp $f) -Force }
} }
# the second engine must not poll jobs (it would see this one), update itself, or prove # the copies are verbatim: the engine switches jobs, updates and proving off itself under --sweep (Settings::for_measurement, 0.3.13)
$sj = Join-Path $sApp 'settings.json' if (-not (Test-Path (Join-Path $sApp 'settings.json'))) { Write-Output 'RESULT SWEEP error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 }
if (Test-Path $sj) {
try {
$j = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json
$j.remote_jobs = $false; $j.auto_update = $false; $j.prove = $false; $j.paused = $false; $j.setup_done = $true
[IO.File]::WriteAllText($sj, ($j | ConvertTo-Json -Depth 8), (New-Object System.Text.UTF8Encoding $false)) # no BOM: the engine's JSON parser refuses one (C35, runs 1 and 2)
} catch { Say ("settings.json: " + $_.Exception.Message) }
} else { Write-Output 'RESULT SWEEP error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 }
Remove-Item -LiteralPath (Join-Path $sApp 'app.url') -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path $sApp 'app.url') -Force -ErrorAction SilentlyContinue
# the cap state before, for the report # the cap state before, for the report

36
tools/ci/playbook-quit-check.sh Executable file
View file

@ -0,0 +1,36 @@
#!/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.
# bash tools/ci/playbook-quit-check.sh [--self-test]
set -euo pipefail
cd "$(dirname "$0")/../.."
check_file() {
local f="$1" bad=0
grep -qE "api/(quit|pause|resume)" "$f" || return 0
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"
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
rm -rf "$t"; echo "self-test passed: the installed app's URL file with a quit fails, a scratch URL file 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)
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; check_file "$f" || fail=1; done < <(git ls-files 'relay/playbooks/**' 'tools/windows/**' 'tools/proving-v1/**' 'packaging/**' | grep -E '\.(ps1|sh)$')
[ "$fail" = 0 ] && echo "playbook-quit: no playbook quits, pauses or resumes the installed app"
exit $fail

View file

@ -23,6 +23,9 @@ while IFS= read -r f; do
if grep -qE 'settings\.json|\.json' "$f" && grep -vE '^\s*#' "$f" | grep -qE 'Set-Content[^\n]*-Encoding +utf8'; then if grep -qE 'settings\.json|\.json' "$f" && grep -vE '^\s*#' "$f" | grep -qE 'Set-Content[^\n]*-Encoding +utf8'; then
echo "second-engine: $f writes JSON with Set-Content -Encoding utf8 (a BOM the engine refuses: the copy read as defaults, no payout address, nothing mined); use [IO.File]::WriteAllText with UTF8Encoding(\$false)"; fail=1 echo "second-engine: $f writes JSON with Set-Content -Encoding utf8 (a BOM the engine refuses: the copy read as defaults, no payout address, nothing mined); use [IO.File]::WriteAllText with UTF8Encoding(\$false)"; fail=1
fi fi
if grep -vE '^\s*#' "$f" | grep -qE 'ConvertTo-Json' && grep -qE 'settings\.json' "$f"; then
echo "second-engine: $f rewrites settings.json through ConvertTo-Json (a lossy round trip: big integers become doubles and the engine reads the whole file as defaults; run 4, 6 October 2026); copy the file verbatim, the engine applies Settings::for_measurement under --sweep"; fail=1
fi
if ! grep -qE "IGNEUM_APP_NO_OTA *= *'1'" "$f"; then if ! grep -qE "IGNEUM_APP_NO_OTA *= *'1'" "$f"; then
echo "second-engine: $f starts an engine without IGNEUM_APP_NO_OTA = '1' (its updater would run the installer, which quits the installed app: PC 1, 5 October 2026, 22:31 UTC)"; fail=1 echo "second-engine: $f starts an engine without IGNEUM_APP_NO_OTA = '1' (its updater would run the installer, which quits the installed app: PC 1, 5 October 2026, 22:31 UTC)"; fail=1
fi fi