Merge commit '4c3d68b6' into release-0.3.17

# Conflicts:
#	docs/bench-log.md
This commit is contained in:
igneum-labs 2026-10-06 23:20:19 +00:00
commit 49daffbf9d
15 changed files with 703 additions and 77 deletions

View file

@ -39,6 +39,9 @@ pub struct CardPref {
pub sweep_class: String,
#[serde(default)]
pub sweep_source: String,
/// the chosen clock sat on the ladder's floor (the lowest step measured, not the optimum)
#[serde(default)]
pub sweep_floor: bool,
/// Miner UI 4 (6 October 2026): this card's goal (efficiency | balanced | rate); empty = the global tune_goal
#[serde(default)]
pub tune_goal: String,

View file

@ -906,6 +906,88 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String {
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
}
/// HH:MM UTC of a unix time (the day is not shown: "since 18:39 UTC").
pub fn hhmm_utc(unix: f64) -> String {
let s = unix.max(0.0) as u64 % 86_400;
format!("{:02}:{:02}", s / 3600, (s % 3600) / 60)
}
/// Horizon polish Q83: the one sentence every surface shows while finality is paused. The cause is the node's own
/// (`reason`, with who holds it) when it carries one, else the plain two-thirds line; never the word "final".
pub fn finality_paused_line(since_unix: f64, reason: &str, held_by: &str) -> String {
let cause = if reason.trim().is_empty() {
"under two thirds of the weight is signing".to_string()
} else if held_by.trim().is_empty() {
reason.trim().to_string()
} else {
format!("{} (held by {})", reason.trim(), held_by.trim())
};
format!("Finality paused since {} UTC: {cause}", hhmm_utc(since_unix))
}
/// The node's structured finality fields on igneum_getProvingStatus (the node lane, 0.3.16): "finalityReason"
/// (empty when live), "finalityProvisional", "heldBy" {tableIndex, stayersShareBps, expiresDaa} or null,
/// "pausedSinceMs" or null. Returns (reason, held-by words, provisional, paused_since unix seconds).
pub struct FinalityStatus {
/// the node's own word on whether finality is live (b2e21447: finalityActive); None on an older node
pub active: Option<bool>,
pub reason: String,
pub held_by: String,
pub provisional: bool,
pub paused_since: Option<f64>,
}
pub fn parse_finality_status(v: &serde_json::Value) -> Option<FinalityStatus> {
// the node's words begin "paused: ..." (b2e21447); the app's sentence already says "Finality paused since", so
// that prefix goes; a reason that already names who holds it ("held by weight table 7, ...") is not repeated
let reason = v.get("finalityReason")?.as_str().unwrap_or("").trim().trim_start_matches("paused:").trim().to_string();
let active = v.get("finalityActive").and_then(|b| b.as_bool());
let provisional = v.get("finalityProvisional").and_then(|b| b.as_bool()).unwrap_or(false);
let held_by = match v.get("heldBy") {
Some(h) if h.is_object() => {
let idx = h.get("tableIndex").and_then(|x| x.as_u64()).unwrap_or(0);
let bps = h.get("stayersShareBps").and_then(|x| x.as_u64()).unwrap_or(0);
let exp = h.get("expiresDaa").and_then(|x| x.as_u64()).unwrap_or(0);
format!("weight table {idx}, {}.{:02}% still signing, expires at DAA {exp}", bps / 100, bps % 100)
}
_ => String::new(),
};
let held_by = if reason.contains("held by") { String::new() } else { held_by };
let paused_since = v.get("pausedSinceMs").and_then(|x| x.as_u64()).filter(|ms| *ms > 0).map(|ms| ms as f64 / 1000.0);
Some(FinalityStatus { active, reason, held_by, provisional, paused_since })
}
/// The node's pause line, when it carries one: `... finality_reason=<words or "quoted words"> held_by=<id>`.
/// Returns (reason, held_by); None when the line is not one.
pub fn parse_finality_line(text: &str) -> Option<(String, String)> {
let i = text.find("finality_reason=")?;
let rest = &text[i + "finality_reason=".len()..];
let (reason, after) = if let Some(q) = rest.strip_prefix('"') {
let end = q.find('"')?;
(q[..end].to_string(), &q[end + 1..])
} else {
let end = rest.find(' ').unwrap_or(rest.len());
(rest[..end].replace('_', " "), &rest[end..])
};
let held_by = after.find("held_by=").map(|j| after[j + 8..].split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();
Some((reason, held_by))
}
/// Miner UI 4 (6 October 2026): the fleet's draw now: the sum over cards that mine and have a draw reading under
/// `max_age_s` old (a stale reading is not a draw).
pub fn fleet_watts<'a>(cards: impl Iterator<Item = (&'a str, f64, f64)>, now: f64, max_age_s: f64) -> f64 {
cards.filter(|(state, w, at)| *state == "mining" && *w > 0.0 && now - *at < max_age_s).map(|(_, w, _)| w).sum()
}
/// A hex quantity (0x-prefixed, as eth_getBalance answers) as a decimal string; None when it is not one.
pub fn wei_from_hex(hex: &str) -> Option<String> {
let h = hex.trim().strip_prefix("0x").unwrap_or(hex.trim());
if h.is_empty() {
return Some("0".into());
}
u128::from_str_radix(h, 16).ok().map(|v| v.to_string())
}
/// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one.
pub fn goal_for(card_goal: &str, global: &str) -> Goal {
Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal })
@ -1034,6 +1116,54 @@ mod tests {
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
}
#[test]
fn the_fleet_draw_sums_mining_cards_with_a_fresh_reading_and_the_balance_reads_in_wei() {
let now = 1_791_300_000.0;
let cards = vec![("mining", 226.8, now - 5.0), ("mining", 75.6, now - 10.0), ("mining", 201.0, now - 120.0), ("off", 30.0, now - 1.0), ("mining", 0.0, now)];
let w = fleet_watts(cards.iter().map(|(s, w, at)| (*s, *w, *at)), now, 60.0);
assert!((w - 302.4).abs() < 1e-9, "the stale 9070 XT reading and the card that is off do not count: {w}");
assert!((pounds_per_day(302.4, 28.5) - 2.068416).abs() < 1e-6);
assert_eq!(wei_from_hex("0x1a"), Some("26".into()));
assert_eq!(wei_from_hex("0x0"), Some("0".into()));
assert_eq!(wei_from_hex("0x"), Some("0".into()));
assert_eq!(wei_from_hex("0xde0b6b3a7640000"), Some("1000000000000000000".into()), "one IGN");
assert_eq!(wei_from_hex("soon"), None);
}
#[test]
fn the_finality_pause_line_names_the_time_and_the_cause_and_never_says_final() {
// 6 October 2026 18:39:00Z
let since = 1_791_311_940.0;
assert_eq!(hhmm_utc(since), "18:39");
let plain = finality_paused_line(since, "", "");
assert_eq!(plain, "Finality paused since 18:39 UTC: under two thirds of the weight is signing");
assert!(!plain.to_ascii_lowercase().contains("final "), "no 'final' word while paused: {plain}");
assert_eq!(finality_paused_line(since, "a checkpoint vote is split", "ae432dc7"), "Finality paused since 18:39 UTC: a checkpoint vote is split (held by ae432dc7)");
assert_eq!(parse_finality_line("1791311940 finality paused finality_reason=\"vote split at index 412\" held_by=ae432dc7"), Some(("vote split at index 412".into(), "ae432dc7".into())));
assert_eq!(parse_finality_line("x finality_reason=under_two_thirds"), Some(("under two thirds".into(), String::new())));
assert_eq!(parse_finality_line("status: accepted 3 blocks"), None);
// the structured carrier on igneum_getProvingStatus (the node lane's field names)
// the node's own words (b2e21447): "paused: under two thirds ... ({p}%; the table frozen at lock {j} has {q}% signing)"
let v: serde_json::Value = serde_json::from_str(r#"{"v1":{"active":true},"finalityActive":false,"finalityReason":"paused: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)","finalityProvisional":false,"heldBy":null,"pausedSinceMs":1791311940000}"#).unwrap();
let f = parse_finality_status(&v).unwrap();
assert_eq!((f.active, f.provisional, f.paused_since), (Some(false), false, Some(1_791_311_940.0)));
assert_eq!(finality_paused_line(f.paused_since.unwrap(), &f.reason, &f.held_by), "Finality paused since 18:39 UTC: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)");
// the held-by form: the reason names the table itself, so heldBy is not repeated
let v2: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: held by weight table 7, 61% of it signing, expires at DAA 205000","finalityProvisional":true,"heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000},"pausedSinceMs":1791311940000}"#).unwrap();
let g = parse_finality_status(&v2).unwrap();
assert!(g.provisional && g.held_by.is_empty());
assert_eq!(finality_paused_line(g.paused_since.unwrap(), &g.reason, &g.held_by), "Finality paused since 18:39 UTC: held by weight table 7, 61% of it signing, expires at DAA 205000");
// a reason without the table words keeps the heldBy suffix
let v3: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: no checkpoint determined above the latest lock","heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000}}"#).unwrap();
assert_eq!(parse_finality_status(&v3).unwrap().held_by, "weight table 7, 61.50% still signing, expires at DAA 205000");
// live finality: an empty reason, null heldBy, null pausedSinceMs
let live: serde_json::Value = serde_json::from_str(r#"{"finalityReason":"","finalityProvisional":false,"heldBy":null,"pausedSinceMs":null}"#).unwrap();
let l = parse_finality_status(&live).unwrap();
assert!(l.reason.is_empty() && l.held_by.is_empty() && !l.provisional && l.paused_since.is_none() && l.active.is_none());
// a node before 0.3.16 carries none of it
assert!(parse_finality_status(&serde_json::json!({"v1":{"active":true}})).is_none());
}
#[test]
fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() {
assert_eq!(goal_for("", "balanced"), Goal::Balanced);

View file

@ -28,6 +28,10 @@ pub struct CardChoice {
pub enum Cmd {
Detect,
/// the payout address's balance (eth_getBalance, hex) or why it could not be read
BalanceRead(Result<String, String>),
/// the node's finality fields from igneum_getProvingStatus (None: the node carries none, or did not answer)
FinalityStatus(Option<crate::ember::FinalityStatus>),
ApplyCards(Vec<CardChoice>),
/// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs)
Detected(crate::detect::Detection),
@ -404,6 +408,10 @@ fn address_state(s: &Settings, wallet_path: &std::path::Path) -> crate::state::A
source: s.address_source.clone(),
key_saved: s.key_saved,
wallet_file: if s.address_source == "generated" { wallet_path.display().to_string() } else { String::new() },
balance_wei: None,
balance_age_s: -1.0,
balance_note: String::new(),
price_gbp_per_ign: None,
}
}
@ -610,6 +618,10 @@ pub struct Engine {
tune_acked: Option<u64>,
/// cards whose confirm check found a better neighbour: the full plan runs next
tune_full_due: std::collections::HashSet<usize>,
/// Miner UI 4: the balance read (every 30 s while the node runs; 60 s after a failure)
balance_next: Option<Instant>,
balance_busy: bool,
balance_at: f64,
/// a sweep waiting for the cap-mode probe: (card index, forced)
sweep_pending: Option<(usize, bool)>,
/// how caps are set: Some(true) directly, Some(false) through the elevated helper, None = not probed yet
@ -714,6 +726,9 @@ impl Engine {
sweep_helper_is_task: false,
tune_acked: None,
tune_full_due: std::collections::HashSet::new(),
balance_next: None,
balance_busy: false,
balance_at: 0.0,
sweep_pending: None,
sweep_direct: None,
sweep_helper: false,
@ -1149,6 +1164,51 @@ impl Engine {
}
}
Cmd::TuneProgress(v) => self.tune_progress(&v),
Cmd::FinalityStatus(f) => {
let mut st = self.st();
match f {
Some(f) => {
st.finality.node_active = f.active;
st.finality.reason = f.reason;
st.finality.held_by = f.held_by;
st.finality.provisional = f.provisional;
st.finality.cause_source = "node".into();
if let Some(since) = f.paused_since {
st.finality.paused_since = since;
}
}
None => {
// a node without the fields: the log line (parse_finality_line) may still have set a cause
st.finality.node_active = None;
if st.finality.cause_source != "node-line" {
st.finality.cause_source.clear();
}
}
}
}
Cmd::BalanceRead(r) => {
self.balance_busy = false;
match r {
Ok(hex) => match crate::ember::wei_from_hex(&hex) {
Some(wei) => {
let mut st = self.st();
st.address.balance_wei = Some(wei);
st.address.balance_note.clear();
drop(st);
self.balance_at = crate::platform::unix_now_f();
self.balance_next = Some(Instant::now() + Duration::from_secs(30));
}
None => {
self.st().address.balance_note = format!("the node answered {hex} for the balance, not a quantity");
self.balance_next = Some(Instant::now() + Duration::from_secs(60));
}
},
Err(e) => {
self.st().address.balance_note = e;
self.balance_next = Some(Instant::now() + Duration::from_secs(60));
}
}
}
Cmd::TuneCardGoal(key, goal) => {
{
let mut s = self.shared.settings.lock().unwrap();
@ -1845,7 +1905,7 @@ impl Engine {
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 seq = crate::powertask::wire_seq();
let mut text = String::new();
for (dev, w) in &pairs {
seq += 1;
@ -2523,7 +2583,7 @@ impl Engine {
// under a scratch IGNEUM_APP_DATA), no prompt
let hdir = crate::powertask::helper_dir();
let _ = std::fs::create_dir_all(&hdir);
let _ = std::fs::write(hdir.join("cmd.txt"), format!("{} dev {}\n", crate::platform::unix_now() % 1_000_000, c.device));
let _ = std::fs::write(hdir.join("cmd.txt"), format!("{} dev {}\n", crate::powertask::wire_seq(), c.device));
crate::powertask::start()?;
self.shared.log("tune helper: the Igneum Power Helper task (no prompt)");
self.sweep_helper = true;
@ -2604,7 +2664,10 @@ impl Engine {
return;
}
let dev = device.to_string();
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n{seq}3 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
// (F) the wire numbers come from the one monotonic space every writer uses (powertask::wire_seq);
// `seq` (the run's request index) stays the engine's own id for the acknowledgement
let wire = crate::powertask::wire_seq();
let cmd = format!("{} dev {dev}\n{} pl {w}\n{} {}\n{} {}\n", wire, wire + 1, wire + 2, if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, wire + 3, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
let dir = self.helper_cmd_dir();
let is_task = self.sweep_helper_is_task;
// 6 October 2026, PC 1: (B) the task's helper had exited while the engine's flag still said it ran,
@ -2614,7 +2677,7 @@ impl Engine {
// is started and a fresh heartbeat awaited BEFORE the command is written; and the acknowledgement
// is the helper's own log line for this sequence (`<seq>3 nvidia-smi ...`), read for up to 15 s;
// a missing line is a failure with that reason
let want = format!(" {seq}3 nvidia-smi");
let want = format!(" {} nvidia-smi", wire + 3);
let log_file = dir.join("helper.log");
std::thread::spawn(move || {
if is_task {
@ -2858,6 +2921,10 @@ impl Engine {
e.sweep_mhs = row.mhs;
e.sweep_clock_mhz = row.point.clock_mhz;
e.sweep_mem_mhz = row.point.mem_mhz;
// Miner UI 4: the row's sub-line reads "(the ladder's floor)" when the chosen clock is the lowest step
let floor_point = row.point.clock_mhz > 0 && row.point.clock_mhz <= run.plan.limits.clock_floor();
e.sweep_floor = floor_point;
c.tune_floor = floor_point;
e.sweep_driver = c.driver.clone();
e.sweep_class = c.program_class.clone();
e.sweep_source = kind.name().into();
@ -3004,6 +3071,7 @@ impl Engine {
self.tick_sweep(now);
}
self.derive(now);
self.tick_balance(now);
if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) {
self.last_status = now;
if self.running {
@ -3052,6 +3120,9 @@ impl Engine {
let st = self.st();
crate::ota::Ctx {
node_synced: st.node.synced && st.clock.severity != "block",
// Horizon frontier lane: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S
// (the last LOCK line's age; or, when none was ever seen this run, the engine's own uptime)
finality_paused: st.finality.paused,
boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None },
// a remote job in progress counts as busy: no update applies under it (src/jobrun.rs)
miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting"),
@ -3540,6 +3611,32 @@ impl Engine {
}
}
/// Miner UI 4: the payout address's balance through the node's own RPC, every 30 s while the node answers.
fn tick_balance(&mut self, now: Instant) {
if self.balance_busy || self.balance_next.map(|t| now < t).unwrap_or(false) {
return;
}
let (addr, node_up) = {
let st = self.st();
(st.address.value.clone(), matches!(st.node.state.as_str(), "syncing" | "synced"))
};
if addr.len() != 42 || !node_up {
self.balance_next = Some(now + Duration::from_secs(10));
return;
}
self.balance_busy = true;
self.balance_next = Some(now + Duration::from_secs(30));
let shared = self.shared.clone();
std::thread::spawn(move || {
let r = crate::prover::evm_rpc(&shared, "eth_getBalance", serde_json::json!([addr, "latest"]), Duration::from_secs(8)).and_then(|v| v.as_str().map(|s| s.to_string()).ok_or_else(|| "eth_getBalance: not a string".to_string()));
shared.send(Cmd::BalanceRead(r));
// the same 30-s cadence carries the node's finality fields (the node lane, 0.3.16); a node without them
// answers without the keys and the engine's own rule stands
let f = crate::prover::evm_rpc(&shared, "igneum_getProvingStatus", serde_json::json!([]), Duration::from_secs(8)).ok().and_then(|v| crate::ember::parse_finality_status(&v));
shared.send(Cmd::FinalityStatus(f));
});
}
/// Derived fields: ages, the hash total, the program countdown, the finality age, the mining state word.
fn derive(&mut self, now: Instant) {
let unix = crate::platform::unix_now_f();
@ -3554,6 +3651,10 @@ impl Engine {
c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 };
}
st.mining.hash_total = total;
// Miner UI 4: the fleet's draw and its £ a day (settings.power_price_pence; 0 when no price is set)
st.mining.watts_total = crate::ember::fleet_watts(st.mining.cards.iter().map(|c| (c.state.as_str(), c.power_w, c.telemetry_at)), unix, 60.0);
st.mining.pounds_per_day = if st.settings.power_price_pence > 0.0 { crate::ember::pounds_per_day(st.mining.watts_total, st.settings.power_price_pence) } else { 0.0 };
st.address.balance_age_s = if self.balance_at > 0.0 { unix - self.balance_at } else { -1.0 };
crate::hotplug::age(&mut st.mining.cards, unix);
let cut = unix - 3600.0;
st.mining.found.retain(|t| *t > cut);
@ -3575,6 +3676,22 @@ impl Engine {
st.finality.age_s = unix - st.finality.last_lock_at;
st.finality.message = String::new();
}
// Horizon polish Q83/Q84: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S (the
// last lock's age, else the engine's uptime); since the last lock (else the start); one sentence everywhere
let gap = if st.finality.last_lock > 0 { st.finality.age_s } else { st.uptime_s as f64 };
// the node's own word wins when it carries one (0.3.16, finalityActive); else the engine's 15-minute rule
let paused = match st.finality.node_active {
Some(active) => !active,
None => st.node.synced && gap > crate::manifest::FINALITY_PAUSE_S,
};
if paused && !st.finality.paused && st.finality.cause_source != "node" {
st.finality.paused_since = if st.finality.last_lock > 0 { st.finality.last_lock_at } else { unix - st.uptime_s as f64 };
}
st.finality.paused = paused;
st.finality.line = if paused { crate::ember::finality_paused_line(st.finality.paused_since, &st.finality.reason, &st.finality.held_by) } else { String::new() };
if paused {
st.finality.message = st.finality.line.clone();
}
if self.running {
let mining_now = st.mining.cards.iter().any(|c| c.state == "mining");
let any_slot = !self.miners.is_empty();
@ -3980,6 +4097,18 @@ impl Engine {
st.finality.age_s = 0.0;
}
}
} else if text.contains("finality_reason=") {
// the node lane's pause line (0.3.16): the cause and who holds it, shown in the one sentence
if let Some((reason, held_by)) = crate::ember::parse_finality_line(text) {
let mut st = self.st();
st.finality.reason = reason;
st.finality.held_by = held_by;
st.finality.cause_source = "node-line".into();
}
} else if text.contains("finality resumed") {
let mut st = self.st();
st.finality.reason.clear();
st.finality.held_by.clear();
} else if text.contains(" VOTE index=") {
self.st().finality.votes += 1;
} else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") {

View file

@ -170,6 +170,7 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) {
c.tune_goal = p.tune_goal.clone();
c.tune_before_watts = p.sweep_before_watts;
c.tune_before_mhs = p.sweep_before_mhs;
c.tune_floor = p.sweep_floor;
if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 {
c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff);
}

View file

@ -1,20 +1,36 @@
//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module). The scene wants the network's
//! blocks over the last minutes with lanes, parents, colours, the selected chain, checkpoints and proof state. The
//! local node speaks gRPC and wRPC only and the engine carries no client for either (no TLS, no websocket stack), so
//! this reads the observer's /api/live (the same URL ota.rs already polls for the identity count) through curl, caches
//! it for 2 s, and marks this machine's blocks: the observer names a block's miner by the first 8 hex of its vote key
//! hash, and every card's `ids` carry the same 8 characters for this machine's keys, so a match becomes the lane "you".
//! Read-only; one upstream call per 2 s whatever the window asks. A local-node source is owed (a JSON RPC on igneumd or
//! a wRPC client here); the shape stays the same when it comes.
//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module) and the Cards tab's network
//! numbers. Two sources, one contract:
//!
//! * the observer's /api/live (the same URL ota.rs polls for the identity count), read through curl, cached 2 s per
//! window, with this machine's blocks marked as the lane "you" (`shape`): the scene's full feed (lanes, parents,
//! colours, the selected chain, checkpoints, proof state). `state.source` = "observer".
//! * the local node's `igneum_getRecentBlocks(seconds)` (igneumd 0.3.17, the node lane's eec34ac3): the last 600 s
//! of chain and merged blocks with vote key hashes, blue scores and colours. The engine computes the observer's
//! state fields from it (`shape_from_blocks`: miners_10m, blocks_10m, blocks_per_minute) and adds them to every
//! observer reply as `state.node`, so the Cards tab reads network numbers that need no site; and when the observer
//! is unreachable the node's rows stand in for the scene (`state.source` = "node", `partial: true`: no parents, no
//! proof state), instead of `{ok:false}`. A node before 0.3.17 answers -32601 and the node source rests 10 minutes.
//!
//! Read-only; one observer call per 2 s whatever the window asks; one node call per 5 s at most.
use crate::engine::Shared;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::process::Command;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None);
const TTL: Duration = Duration::from_secs(2);
struct NodeCache {
at: Option<Instant>,
blocks: Vec<RecentBlock>,
/// the node said "method not found" (-32601): rest until then
retry: Option<Instant>,
}
static NODE: Mutex<NodeCache> = Mutex::new(NodeCache { at: None, blocks: Vec::new(), retry: None });
const NODE_TTL: Duration = Duration::from_secs(5);
/// The observer's reply with this machine's lane marked. `ids` are the 8-character vote key ids of this machine's
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` names
/// where the data came from. Anything else passes through untouched, so the module's contract holds.
@ -25,7 +41,9 @@ pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
let mine = b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false);
if mine {
yours += 1;
if let Some(o) = b.as_object_mut() { o.insert("miner".into(), json!("you")); }
if let Some(o) = b.as_object_mut() {
o.insert("miner".into(), json!("you"));
}
}
}
}
@ -41,30 +59,197 @@ pub fn url_for(live_api: &str, window_s: u32) -> String {
format!("{live_api}?window={}", window_s.clamp(30, 300))
}
/// The reply for the dashboard: cached 2 s per window; `{ok:false, error}` when the observer is unreachable or the
/// build has no observer address (the UI then draws its own blocks strip).
pub fn fetch(live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
if live_api.is_empty() {
return json!({ "ok": false, "error": "no observer address in this build" });
/// One block from `igneum_getRecentBlocks`.
#[derive(Clone, Debug)]
pub struct RecentBlock {
pub hash: String,
pub blue_score: u64,
pub daa_score: u64,
pub timestamp_ms: u64,
pub vote_key_hash: String,
pub is_chain_block: bool,
pub color: String,
/// "header" or "chain_block" (eec34ac3: a merged block whose own header was not at hand carries its merging
/// chain block's time and blue score, marked so blocks_per_minute stays honest); "" on an older row = header
pub timestamp_source: String,
}
pub fn parse_recent(v: &Value) -> Vec<RecentBlock> {
let s = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let n = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
v.as_array()
.map(|a| {
a.iter()
.map(|b| RecentBlock {
hash: s(b, "hash"),
blue_score: n(b, "blue_score"),
daa_score: n(b, "daa_score"),
timestamp_ms: n(b, "timestamp_ms"),
vote_key_hash: s(b, "vote_key_hash"),
is_chain_block: b.get("is_chain_block").and_then(|x| x.as_bool()).unwrap_or(false),
color: s(b, "color"),
timestamp_source: s(b, "timestamp_source"),
})
.collect()
})
.unwrap_or_default()
}
/// The lane id the UI matches against a card's `ids`: the first 8 hex of the vote key hash (engine.rs gives a card
/// its ids the same way, `h.chars().take(8)`); "" when the block carries no key.
pub fn lane(vote_key_hash: &str) -> String {
vote_key_hash.trim_start_matches("0x").chars().take(8).collect()
}
/// The site's `short`: the first 16 hex (site/api/live.mjs), so a hash reads the same from either source.
fn short(h: &str) -> String {
h.trim_start_matches("0x").chars().take(16).collect()
}
/// The observer's `state` fields that come from the blocks: distinct vote keys in 10 minutes, blocks in 10
/// minutes, blocks per minute over the last 10 minutes (oldest first), and the `blocks` (last `window_s`, in the
/// scene's row shape as far as the node knows it: no parents) and `miners` arrays. `now_ms` is the reference time.
pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) -> Value {
let ten = now_ms.saturating_sub(600_000);
let recent: Vec<&RecentBlock> = blocks.iter().filter(|b| b.timestamp_ms >= ten).collect();
let mut miners: std::collections::BTreeMap<&str, u64> = Default::default();
for b in &recent {
if !b.vote_key_hash.is_empty() {
*miners.entry(b.vote_key_hash.as_str()).or_insert(0) += 1;
}
}
let mut per_min = vec![0u64; 10];
for b in &recent {
let idx = ((b.timestamp_ms - ten) / 60_000).min(9) as usize;
per_min[idx] += 1;
}
let win = now_ms.saturating_sub(window_s * 1000);
let rows: Vec<Value> = blocks
.iter()
.filter(|b| b.timestamp_ms >= win)
.map(|b| {
json!({
"hash": short(&b.hash), "ts": b.timestamp_ms, "timestamp_ms": b.timestamp_ms, "blue_score": b.blue_score, "daa": b.daa_score,
"parents": [], "chain": b.is_chain_block, "is_chain_block": b.is_chain_block,
"color": if b.color.is_empty() { "pending" } else { b.color.as_str() },
"miner": lane(&b.vote_key_hash), "vote_key_hash": short(&b.vote_key_hash),
"timestamp_source": if b.timestamp_source.is_empty() { "header" } else { b.timestamp_source.as_str() },
"locked": false, "final": false, "shards": [], "proven": false
})
})
.collect();
json!({
"miners_10m": miners.len(),
"blocks_10m": recent.len(),
"blocks_per_minute": per_min,
"blocks": rows,
"miners": miners.iter().map(|(k, n)| json!({ "id": lane(k), "vote_key_hash": short(k), "blocks_10m": n })).collect::<Vec<_>>(),
})
}
/// The node's recent blocks, cached 5 s; empty when the node is down, carries no such method (-32601, rests 10
/// minutes) or answered nothing.
fn node_blocks(shared: &Arc<Shared>) -> Vec<RecentBlock> {
{
let c = NODE.lock().unwrap();
if c.at.map(|t| t.elapsed() < NODE_TTL).unwrap_or(false) {
return c.blocks.clone();
}
if c.retry.map(|t| Instant::now() < t).unwrap_or(false) {
return Vec::new();
}
}
let node_up = matches!(shared.state.lock().unwrap().node.state.as_str(), "syncing" | "synced");
if !node_up {
return Vec::new();
}
match crate::prover::evm_rpc(shared, "igneum_getRecentBlocks", json!([600]), Duration::from_secs(6)) {
Ok(v) if v.is_array() => {
let blocks = parse_recent(&v);
let mut c = NODE.lock().unwrap();
c.at = Some(Instant::now());
c.blocks = blocks.clone();
blocks
}
Ok(_) => Vec::new(),
Err(e) => {
// the node's default arm: {"code": -32601, "message": "method igneum_getRecentBlocks not found"}
if e.contains("not found") || e.contains("-32601") {
NODE.lock().unwrap().retry = Some(Instant::now() + Duration::from_secs(600));
}
Vec::new()
}
}
}
/// The node's `state` fields for the Cards tab (`state.node` on an observer reply, `state` on a node-only one).
fn node_state(shared: &Arc<Shared>, blocks: &[RecentBlock], now_ms: u64) -> Value {
let mut v = shape_from_blocks(blocks, now_ms, 90);
let st = shared.state.lock().unwrap();
let o = v.as_object_mut().unwrap();
o.remove("blocks");
o.remove("miners");
o.insert("block_count".into(), json!(st.node.blocks));
o.insert("header_count".into(), json!(st.node.headers));
o.insert("daa".into(), json!(st.node.daa));
o.insert("blue_score".into(), json!(st.node.blue));
o.insert("difficulty".into(), json!(st.node.difficulty));
o.insert("peers".into(), json!(st.node.peers));
o.insert("synced".into(), json!(st.node.synced));
o.insert("hashes_per_second_estimate".into(), Value::Null);
o.insert("source".into(), json!("node"));
v
}
/// The reply for the dashboard: the observer's, cached 2 s per window, with the node's state fields added as
/// `state.node` when the node answers; the node's rows alone (`partial: true`) when the observer is unreachable;
/// `{ok:false, error}` when neither answers (the UI then draws its own blocks strip).
pub fn fetch(shared: &Arc<Shared>, live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
let window_s = window_s.clamp(30, 300);
if let Some((at, w, v)) = CACHE.lock().unwrap().as_ref() {
if *w == window_s && at.elapsed() < TTL { return v.clone(); }
}
let url = url_for(live_api, window_s);
let out = crate::detect::run_timeout(
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]),
None,
Duration::from_secs(6),
);
let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids),
_ => json!({ "ok": false, "error": "the observer did not answer" }),
let now_ms = (crate::platform::unix_now_f() * 1000.0) as u64;
let blocks = node_blocks(shared);
let node = if blocks.is_empty() { None } else { Some(node_state(shared, &blocks, now_ms)) };
let cached = CACHE.lock().unwrap().as_ref().and_then(|(at, w, v)| if *w == window_s && at.elapsed() < TTL { Some(v.clone()) } else { None });
let mut reply = match cached {
Some(v) => v,
None if live_api.is_empty() => json!({ "ok": false, "error": "no observer address in this build" }),
None => {
let url = url_for(live_api, window_s);
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]), None, Duration::from_secs(6));
let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids),
_ => json!({ "ok": false, "error": "the observer did not answer" }),
};
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone()));
}
reply
}
};
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone()));
let observer_ok = reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false);
match (observer_ok, node) {
(true, Some(n)) => {
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("node".into(), n);
}
reply
}
(true, None) => reply,
(false, Some(n)) => {
// the node's rows stand in: the scene gets blocks without parents or proof state, and says so
let mut v = shape_from_blocks(&blocks, now_ms, window_s as u64);
let o = v.as_object_mut().unwrap();
let rows = o.remove("blocks").unwrap_or(Value::Null);
let miners = o.remove("miners").unwrap_or(Value::Null);
let mut out = json!({ "ok": true, "partial": true, "now": crate::platform::unix_now_f(), "state": n, "blocks": rows, "miners": miners, "events": [], "finality": { "checkpoints": [] } });
out = shape(out, ids);
if let Some(st) = out.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("source".into(), json!("node"));
}
out
}
(false, None) => reply,
}
reply
}
#[cfg(test)]
@ -131,10 +316,51 @@ mod tests {
assert_eq!(url_for("https://igneum.network/api/live", 9000), "https://igneum.network/api/live?window=300");
}
fn b(ts: u64, key: &str, chain: bool) -> RecentBlock {
RecentBlock { hash: format!("0x{:064x}", ts), blue_score: ts / 1000, daa_score: ts / 1000, timestamp_ms: ts, vote_key_hash: key.into(), is_chain_block: chain, color: if chain { "blue".into() } else { String::new() }, timestamp_source: String::new() }
}
#[test]
fn a_build_without_an_observer_address_says_so() {
let out = fetch("", 120, &HashSet::new());
assert_eq!(out["ok"], false);
assert_eq!(out["error"], "no observer address in this build");
fn the_state_fields_come_from_the_nodes_blocks_of_the_last_ten_minutes() {
let now = 1_791_300_000_000u64;
let blocks = vec![b(now - 5_000, "aa", true), b(now - 30_000, "bb", true), b(now - 95_000, "aa", false), b(now - 500_000, "cc", true), b(now - 700_000, "dd", true)];
let v = shape_from_blocks(&blocks, now, 120);
assert_eq!(v["miners_10m"], 3, "dd is older than 10 minutes");
assert_eq!(v["blocks_10m"], 4);
let pm = v["blocks_per_minute"].as_array().unwrap();
assert_eq!(pm.len(), 10);
assert_eq!(pm[9], 2, "the newest minute holds the 5 s and 30 s blocks");
assert_eq!(pm[8], 1, "the 95 s block");
assert_eq!(pm[1], 1, "the 500 s block");
assert_eq!(v["blocks"].as_array().unwrap().len(), 3, "the 120 s window");
assert_eq!(v["blocks"][2]["color"], "pending", "an unmerged block without a colour");
assert_eq!(v["blocks"][0]["color"], "blue");
assert_eq!(v["blocks"][0]["timestamp_source"], "header");
assert_eq!(v["blocks"][0]["miner"], "aa", "the lane id is the first 8 hex of the vote key hash, as a card's ids");
assert_eq!(v["blocks"][0]["chain"], true, "the scene's row shape");
assert_eq!(v["blocks"][0]["parents"], json!([]), "the node method carries no parents");
assert_eq!(v["miners"].as_array().unwrap().len(), 3);
assert_eq!(v["miners"][0]["id"], "aa", "the miners rows carry id, as the site's");
// the lane "you" marks node rows too
let ids: HashSet<String> = ["aa".to_string()].into_iter().collect();
let marked = shape(json!({ "ok": true, "state": {}, "blocks": v["blocks"] }), &ids);
assert_eq!(marked["blocks"][0]["miner"], "you");
assert_eq!(marked["state"]["you_blocks"], 2);
assert_eq!(lane("0x0123456789abcdef"), "01234567");
assert_eq!(lane(""), "");
}
#[test]
fn recent_blocks_parse_from_the_node_reply_and_an_empty_reply_is_empty() {
let v: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"cd","is_chain_block":true,"color":"blue"},{"hash":"0xcd","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"ef","is_chain_block":false,"color":"red","timestamp_source":"chain_block"}]"#).unwrap();
let p = parse_recent(&v);
assert_eq!(p.len(), 2);
assert_eq!((p[0].blue_score, p[0].is_chain_block, p[0].color.as_str()), (5, true, "blue"));
assert_eq!((p[1].color.as_str(), p[1].timestamp_source.as_str()), ("red", "chain_block"));
// a null vote_key_hash (a chain block with no mergeset entry, which the executor never produces) reads as ""
let n: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":null,"is_chain_block":true,"color":"blue","timestamp_source":"header"}]"#).unwrap();
assert_eq!(parse_recent(&n)[0].vote_key_hash, "");
assert!(parse_recent(&json!(null)).is_empty());
assert_eq!(short("0x1234567890abcdef00"), "1234567890abcdef", "the site's 16-hex short");
}
}

View file

@ -52,6 +52,9 @@ pub struct Manifest {
pub min_supported_version: String,
pub notes: String,
pub activation_height: Option<u64>,
/// Horizon frontier lane (6 October 2026): the publisher's word that this version installs even while the
/// network's finality is paused (nothing else does); false unless the signed manifest says so
pub urgent: bool,
pub deadline_note: String,
/// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's
/// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest.
@ -157,6 +160,8 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
min_supported_version: s(&v, "min_supported_version"),
notes: s(&v, "notes"),
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()),
// the manifest's TOP-LEVEL "urgent": true (the publisher sets it; igneum-ota-sign passes the document through)
urgent: v.get("urgent").and_then(|u| u.as_bool()).unwrap_or(false),
deadline_note: s(&consensus, "deadline_note"),
override_params: match consensus.get("override") {
Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()),
@ -312,8 +317,16 @@ pub struct Moment {
pub slot_ok: bool,
/// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent.
pub network_drop_pct: f64,
/// Horizon frontier lane (6 October 2026): the network's finality is paused (the node is synced and no checkpoint
/// has locked for FINALITY_PAUSE_S); a rollout never lands on a chain that cannot lock
pub finality_paused: bool,
/// the signed manifest's own urgent flag: the one thing that installs while finality is paused
pub manifest_urgent: bool,
}
/// No checkpoint lock for this long on a synced node = finality paused (the devnet locks every few minutes).
pub const FINALITY_PAUSE_S: f64 = 15.0 * 60.0;
/// The percentage of identities the network may lose in 10 minutes before updates hold (we are the devnet).
pub const NETWORK_DROP_HOLD_PCT: f64 = 30.0;
@ -324,6 +337,10 @@ pub fn slot_minute(id8: &str) -> u64 {
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
// the finality rule comes first: a fork-close or unsupported urgency does not pass it, only the manifest's flag
if m.finality_paused && !m.manifest_urgent {
return Err("waiting for finality: the network has not locked a checkpoint for 15 min; nothing installs on a chain that cannot lock".into());
}
if m.job_active {
return Err("a remote job is running; installing when it closes".into());
}
@ -356,7 +373,10 @@ pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
/// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score).
pub fn fork_is_close(activation_height: Option<u64>, daa: u64) -> bool {
match activation_height {
Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
// Horizon polish Q4 (6 October 2026): an activation at or below the DAA has PASSED, nothing is pending; the
// old rule read it as close, so every update since the 0.3.14 manifest said "0 blocks away, installing now",
// stripped Later and skipped every safe-moment guard (PC 1's 17:52:54Z install under a job came through it)
Some(h) if daa > 0 && h > daa => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
_ => false,
}
}
@ -499,8 +519,15 @@ mod tests {
#[test]
fn safe_moments() {
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false };
assert!(safe_to_apply(&base).is_ok());
// the finality rule (Horizon frontier lane, 6 October 2026), the known-failed case: paused, no install; active, install;
// paused with the manifest's urgent flag, install; paused with only a fork-close urgency, no install; patience never passes it
assert!(safe_to_apply(&Moment { finality_paused: true, ..base.clone() }).unwrap_err().starts_with("waiting for finality"));
assert!(safe_to_apply(&Moment { finality_paused: false, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { finality_paused: true, manifest_urgent: true, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { finality_paused: true, urgent: true, ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { finality_paused: true, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err());
@ -543,8 +570,11 @@ mod tests {
assert!(!fork_is_close(Some(120_000), 0));
assert!(!fork_is_close(Some(120_000), 118_199));
assert!(fork_is_close(Some(120_000), 118_200));
assert!(fork_is_close(Some(120_000), 120_000));
assert!(fork_is_close(Some(120_000), 130_000));
assert!(fork_is_close(Some(120_000), 119_999), "one block before the activation");
// a passed activation is not close (Horizon polish Q4): at the height and after it, nothing is pending
assert!(!fork_is_close(Some(120_000), 120_000));
assert!(!fork_is_close(Some(120_000), 130_000));
assert!(!fork_is_close(Some(33_000), 201_776), "the 0.3.14 manifest's case: difficulty v2 at 33,000 against PC 1's DAA");
let m = parse(SAMPLE).unwrap();
assert!(!unsupported(&m, "0.3.0"));
assert!(unsupported(&m, "0.2.9"));

View file

@ -68,6 +68,8 @@ pub enum Launch {
pub struct Ctx {
pub node_synced: bool,
/// the network's finality is paused (engine: synced and no lock for manifest::FINALITY_PAUSE_S)
pub finality_paused: bool,
pub boundary_eta_s: Option<i64>,
pub miner_busy: bool,
/// A remote job is running (src/jobrun.rs): the install holds, urgent or not.
@ -541,7 +543,8 @@ impl Updater {
}
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
let manifest_urgent = self.manifest.as_ref().map(|m| m.urgent).unwrap_or(false);
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct , finality_paused: ctx.finality_paused, manifest_urgent };
if !self.auto && !urgent && !self.install_asked {
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
return None;

View file

@ -119,6 +119,17 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
}
}
/// The sequence number a command line carries on the wire. The helper runs a line only when its number is above
/// every number it has seen (its `last_seq`, seeded from the file at its start), so every writer must draw from
/// ONE monotonic space: the unix time modulo a million (six digits, what `parse_line` accepts), plus a small
/// offset per line. (F) 6 October 2026, 22:19Z on PC 1: the tune path wrote its request index ("00 dev 1", "01 pl
/// 160", ...) while the cap path had written 305327 and up, so the helper skipped every tune command as stale and
/// both climbs stopped on "the helper did not run sequence 1 within 15 s". Wraps every 11.6 days; the helper's
/// idle exit (20 minutes) re-seeds it from the file, so a wrap costs at most one tune step.
pub fn wire_seq() -> u64 {
crate::platform::unix_now() % 999_990
}
/// The commands a helper acts on: the lines added after its start (`skip` = the line count at the start, 0 again
/// when the file shrank). A stale `quit` or `remove` from an earlier engine is never a command.
pub fn commands_after(text: &str, skip: usize) -> Vec<(u64, HelperCmd)> {
@ -360,6 +371,9 @@ mod tests {
assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n7 dev 1\n", 3), vec![(7, HelperCmd::Dev("1".into()))]);
assert_eq!(commands_after("quit\n", 3), vec![(0, HelperCmd::Quit)]);
// the heartbeat: fresh within ALIVE_MAX_S, dead after, dead when unreadable
// (F) every wire number parses (six digits at most) and leaves room for the four lines of a tune step
let w = wire_seq();
assert!(w + 3 <= 999_999 && parse_line(&format!("{} rmc", w + 3)).is_some());
assert!(alive_at("1791309325", 1791309325 + ALIVE_MAX_S));
assert!(!alive_at("1791309325", 1791309325 + ALIVE_MAX_S + 1));
assert!(!alive_at("", 1791309325));

View file

@ -167,7 +167,7 @@ fn exec_boundary(shared: &Shared) -> u64 {
text.strip_prefix("restart at chain block ").and_then(|t| t.split_whitespace().next()).and_then(|t| t.parse().ok()).unwrap_or(0)
}
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;

View file

@ -185,7 +185,7 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
let window = query_param(&req.query, "window").and_then(|s| s.parse().ok()).unwrap_or(120u32);
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
let ids: std::collections::HashSet<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
json_resp(&mut stream, 200, crate::live::fetch(&live_api, window, &ids));
json_resp(&mut stream, 200, crate::live::fetch(&shared, &live_api, window, &ids));
}
("GET", "/api/log") => {
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);

View file

@ -129,6 +129,7 @@ pub struct CardState {
pub tune_goal: String, // this card's goal override (efficiency | balanced | rate); "" = the global goal
pub tune_before_watts: f64, // the untuned point of the last full plan (step 0); 0 = never measured
pub tune_before_mhs: f64,
pub tune_floor: bool, // the chosen clock is the ladder's floor (the row's sub-line says so)
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
pub tune_step: u32,
@ -166,6 +167,10 @@ pub struct MiningState {
/// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime
pub fee_session: u64,
pub fee_total: u64,
/// Miner UI 4 (6 October 2026): the sum of the mining cards' draw (a reading under 60 s old), and that draw as
/// £ a day at settings.power_price_pence (0 when no price is set)
pub watts_total: f64,
pub pounds_per_day: f64,
}
/// The miner software's dev fee as the miner reports it at start (`dev fee 1% (1 block in 100) to 0x...`).
@ -252,6 +257,23 @@ pub struct FinalityState {
pub age_s: f64,
pub votes: u64,
pub message: String,
/// Horizon polish Q83/Q84 (6 October 2026): the network's finality is paused (a synced node, no checkpoint lock
/// for manifest::FINALITY_PAUSE_S); since when (the last lock's time, else the engine's start); the cause when
/// the node carries it (its `finality_reason=` / `held_by=` line), else the plain two-thirds line; and the one
/// sentence every surface shows: "Finality paused since 18:39 UTC: under two thirds of the weight is signing"
pub paused: bool,
pub paused_since: f64,
pub reason: String,
pub held_by: String,
pub line: String,
/// the node's word (igneum_getProvingStatus finalityProvisional, 0.3.16): locks are provisional right now
pub provisional: bool,
/// where the cause came from: "node" (the RPC's structured fields), "node-line" (its log line) or "" (the
/// engine's own rule)
pub cause_source: String,
/// the node's own finalityActive (b2e21447), when it carries it: then the node decides `paused`, not the
/// engine's 15-minute rule
pub node_active: Option<bool>,
}
/// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind).
@ -274,6 +296,15 @@ pub struct AddressState {
pub source: String,
pub key_saved: bool,
pub wallet_file: String,
/// Miner UI 4: the payout address's balance in wei as a decimal string (eth_getBalance through the node's own
/// RPC, read every 30 s while the node runs); null until the first read; balance_age_s = -1 until then
pub balance_wei: Option<String>,
pub balance_age_s: f64,
pub balance_note: String, // the last read's error, in words; "" when the last read was good
/// £ per IGN. null until a market exists. Its one source will be a SIGNED field of the OTA manifest (`price`:
/// gbp_per_ign, as_of, source), checked like the manifest's tuning object; the app never computes or fetches a
/// price on its own
pub price_gbp_per_ign: Option<f64>,
}
#[derive(Clone, Serialize, Default)]

View file

@ -2661,3 +2661,11 @@ every segment record from the shipped host was refused while 8 of 8 shards passe
and proving_aggregator_id are the statement's ids when set, so the env is not needed. Consequence per tier: a node
without a host (a pool hand, a seed, a home node that does not prove) must carry the ids in its object or its env, or
it refuses every segment record and pays no aggregator; the 0.3.17 object carries them.
## 6 October 2026, 22:16Z: the shipped 0.3.16 app asked to tune itself on PC 1 (ember-installed-pc1-2): A to E held, no climb (cause F)
Gate ok (0.3.16, Power control on, task on the installed exe, Running at the end), prompts 0, the helper up once per
NVIDIA card. RX 9070 XT measured as it runs: 19.2 MH/s at 202 W (0.095 MH/W), no set sent (measure only: the probe
gave no tune line; open). RTX 5090 and 4070 climbs stopped at request 1: "the helper did not run sequence 1 within
15 s"; cmd.txt held "00 dev 1 / 01 pl 160 / 02 rgc / 03 rmc", two-digit wire numbers below the cap path's unix-based
ones, so the helper skipped them as stale (cause F, fixed cbd4d51 for 0.3.17). Draws unchanged: 5090 208 W at
1845 MHz, 4070 75.5 W at 1860 MHz, 9070 XT 203 W.

View file

@ -312,15 +312,53 @@ The shipped 0.3.14 app, Power control on, the task registered, asked over its ow
What a user on 0.3.14 sees: Power control on, no prompt, and no card tunes (the rows keep the baseline or
"tuning stopped"); the lever-2 TUNING records still flow. 0.3.15 closes it; the window repeats there.
### The 0.3.16 window (6 October 2026, 22:16 to 22:25Z): A to E held; a sixth cause (F) stopped the climbs
`ember-installed-pc1-2`, unelevated, the installed 0.3.16 app (the branch at 5429e82) tuning itself through the task.
Gate ok; prompts 0; the helper started once per NVIDIA card and stayed up (task state Running at the end).
| Card | Plan | Result |
|---|---|---|
| RX 9070 XT | baseline, measure (the probe gave no tune line again) | "Measured: 19.2 MH/s at 202 W (0.095 MH/W)", gclk 3298, mclk 2505, 62 C, ok; no set sent (D held). Open: why the installed app's probe gets no tune line when run 6's kit engine did |
| RTX 5090 | climb, 5 probes, floor 1390 MHz, mode helper | request 1 "the helper did not run sequence 1 within 15 s (no line in helper.log)"; 211.5 W before, 208 W after, unchanged |
| RTX 4070 | climb | the same; 74.8 W before, 75.5 W after |
Cause F (cmd.txt read back: `00 dev 1 / 01 pl 160 / 02 rgc / 03 rmc`): the tune path wrote its request index as the
wire sequence while the cap path had written unix-based numbers earlier in the same helper session; the helper runs
only numbers above every one it has seen, so every tune command read as stale. The honest acknowledgement (B) said
so within 15 s instead of a blind yes. Fix (cbd4d51, 0.3.17): one monotonic wire space for every writer
(`powertask::wire_seq`, the unix time modulo 999,990), the acknowledgement keyed to the wire number; test. The
elevated runs never crossed F (they set limits directly), which is why run 6 tuned and the self-tune did not.
## 8a. Next-cut notes (for the 0.3.12 shipper)
For 0.3.16 (Miner UI 4's Cards tab, Ember as its second layer), on ember-tune past the 0.3.15 tip 7fadd4c:
Correction (6 October 2026, 19:0xZ): release-0.3.15 took ember-tune at 5429e82 (merge 886c075), because Miner UI 4's
Cards tab ships in 0.3.15 and reads the per-card goal and the before fields; so the row below is IN 0.3.15 except
`card.tune_floor` (995530b), which rides 0.3.17.
Numbering (the shipper, 6 October 2026, 21:xx UK): 0.3.16 is a corrective cut of 0.3.15 under a new number (the Mac and PC 1 had taken earlier 0.3.15 builds), so everything below written as "0.3.16" ships as 0.3.17; the 0.3.15/0.3.16 content is the branch at 5429e82.
For 0.3.17 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, on ember-tune past 5429e82:
| Field | What | Where |
|---|---|---|
| `state.mining.watts_total` | the sum of the mining cards' draw (a reading under 60 s old) | `ember::fleet_watts`, engine `derive` + test |
| `state.mining.pounds_per_day` | that draw as £ a day at `settings.power_price_pence` (0 when no price) | `ember::pounds_per_day` |
| `state.address.balance_wei` | the payout address's balance in wei as a decimal string, `eth_getBalance` through the node's own RPC every 30 s while the node runs (60 s after a failure); null until read; `balance_age_s` (-1 until then), `balance_note` (the last error in words) | engine `tick_balance`, `Cmd::BalanceRead`, `ember::wei_from_hex` + test |
| `state.address.price_gbp_per_ign` | null. Its one source will be a SIGNED field of the OTA manifest (`price`: gbp_per_ign, as_of, source), checked like the tuning object; the app never computes or fetches a price itself | state.rs (documented), no code until a market exists |
| (F) the helper's wire sequence | one monotonic space for every writer of cmd.txt (`powertask::wire_seq`); the tune path's two-digit request index read as stale beside the cap path's unix-based numbers, so the 0.3.16 self-tune's climbs stopped at their first request | powertask.rs, engine.rs + test |
| Horizon polish Q4 (updater) | `fork_is_close`: an activation height at or below the DAA has passed, nothing is pending (the old rule read it as close: every update since the 0.3.14 manifest said "0 blocks away, installing now", stripped Later and skipped every guard; PC 1's 17:52:54Z install under a job came through it); `publish-manifest.sh` refuses an activation height at or below the live DAA (/api/live state.daa) unless `--allow-passed-activation` | manifest.rs + test, packaging/ota/publish-manifest.sh |
| Horizon polish Q83/Q84/Q2 (the pause shown) | `state.finality.paused`, `paused_since` (the last lock's time, else the engine's start), `reason`, `held_by`, `line` = "Finality paused since 18:39 UTC: under two thirds of the weight is signing" (the node's cause when it carries one: the engine parses a node log line carrying `finality_reason=<words or "quoted words"> held_by=<id>`, the node lane's to emit); the node line, the Overview's state and the Finality card show the one sentence while paused, the Finality card's age reads "paused", and no surface calls a lock final; `finality.message` carries the sentence too so older UIs show it | ember.rs `finality_paused_line`, `parse_finality_line`, `hhmm_utc` + tests; engine.rs derive and the node-line parse; ui/app.js `View.finalityWords` + test |
| the finality rule (updater) | Horizon frontier lane: the updater installs nothing while the network's finality is paused (a synced node with no checkpoint lock for `manifest::FINALITY_PAUSE_S` = 15 min; the last LOCK line's age, else the engine's uptime); the update card reads "waiting for finality: ..."; slot, catch-up and patience rules unchanged otherwise; only the signed manifest's own `urgent` flag installs through a pause (a fork-close or unsupported urgency does not); the known-failed case is the test | manifest.rs `Moment.finality_paused`, `manifest_urgent`, `Manifest.urgent`, `safe_to_apply` + test; ota.rs `Ctx`; engine.rs |
| `GET /api/live` | the observer's reply shape from a local source: `"source": "node"` when igneumd carries `igneum_getRecentBlocks(seconds)` (the node lane, a283f5f0d364ceef0; the engine computes miners_10m, blocks_10m, blocks_per_minute, the 90 s blocks and the miners list from it and takes the DAG numbers from its node state), else `"source": "site"` (the public reply, fetched by curl, cached 60 s) with `age_s`; `pending: true` before the first fetch | src/live.rs (`shape_from_blocks`, `parse_recent` + tests), server.rs |
For Miner UI 4's Cards tab (Ember as its second layer), 0.3.17, on ember-tune:
| Commit | What | Where |
|---|---|---|
| (past 7fadd4c) | a per-card goal: `POST /api/tune/goal {key, goal}` (efficiency, balanced, rate; "" or "global" clears), persisted as `CardPref.tune_goal`, echoed as `card.tune_goal` ("" = the global goal), resolved at the tune's start (`ember::goal_for`); the untuned point of the last full plan on the row: `card.tune_before_watts`, `card.tune_before_mhs` (its step 0, kept across confirm plans; `CardPref.sweep_before_*`), so the row can read "saves 84 W, 0.15% of rate" against `sweep_watts` / `sweep_mhs`; Retune = `POST /api/sweep/start {key}` (forced: clears a back-off since 0.3.15) | ember.rs, engine.rs, server.rs, config.rs, state.rs, hotplug.rs + test |
| 5429e82 (in 0.3.15), 995530b (`tune_floor`, next cut) | a per-card goal: `POST /api/tune/goal {key, goal}` (efficiency, balanced, rate; "" or "global" clears), persisted as `CardPref.tune_goal`, echoed as `card.tune_goal` ("" = the global goal), resolved at the tune's start (`ember::goal_for`); `card.tune_floor` (the chosen clock is the ladder's floor; `CardPref.sweep_floor`); the untuned point of the last full plan on the row: `card.tune_before_watts`, `card.tune_before_mhs` (its step 0, kept across confirm plans; `CardPref.sweep_before_*`), so the row can read "saves 84 W, 0.15% of rate" against `sweep_watts` / `sweep_mhs`; Retune = `POST /api/sweep/start {key}` (forced: clears a back-off since 0.3.15) | ember.rs, engine.rs, server.rs, config.rs, state.rs, hotplug.rs + test |
For the 0.3.15 cut (6 October 2026 evening), on ember-tune:
For the 0.3.15 cut (6 October 2026 evening), on ember-tune (taken at 5429e82, see the correction above):
| Commit | What | Where |
|---|---|---|

View file

@ -7,6 +7,8 @@
# packaging/ota/publish-manifest.sh --version 0.3.1 --mac packaging/mac/dist/Igneum-Miner-0.3.1.dmg \
# [--win packaging/windows/dist/Igneum-Miner-Setup-0.3.1.exe] --notes "one line of what changed" \
# [--activation-height 120000 --deadline-note "difficulty v2"] [--min-supported 0.3.0] [--channel devnet] [--deploy]
# [--allow-passed-activation] (Horizon polish Q4: an activation height at or below the live DAA is refused otherwise)
# --self-test-height proves the height check on known values (33000 against 201776 refused; 300000 passes) and exits
# [--override '{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":120000}']
# consensus.override: the exact object every app writes to its override.json (the node's
# --override-params-file), so it carries EVERY height switch, not just the new one;
@ -39,26 +41,6 @@ TOKEN_FILE="$HOME/.config/igneum/dl-token"
SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign"
VERSION="" MAC="" WIN="" NOTES="" ACTIVATION="" DEADLINE="" MIN_SUPPORTED="" CHANNEL="devnet" BASE="" DEST="" DEPLOY=0 VERIFY_ONLY=0 TRIES=12
# An activation height at or below the live DAA makes every app treat the update as urgent (manifest::fork_is_close counts
# any height already passed as "close"), which skips the slot, the patience and the busy rule; PC 1 took an install under a
# running job on 6 October 2026 on that path. The live DAA comes from the observer's exec status (igneum_getExecStatus,
# executedTipDaa) at 127.0.0.1:26790; a height at or below it is refused. IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 overrides for a
# deliberate replay. `--self-test-height` exercises the rule on a known-bad and a known-good value against DAA 1000.
height_rule() { # <height> <live daa> -> 0 ok, 1 refused
local h="$1" daa="$2"
[ -z "$h" ] && return 0
[[ "$h" =~ ^[0-9]+$ ]] || { echo "activation height $h is not a number" >&2; return 1; }
if [ "$h" -le "$daa" ]; then echo "activation height $h is at or below the live DAA $daa: every app would treat the update as urgent (fork_is_close); refused (IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 overrides a deliberate replay)" >&2; return 1; fi
return 0
}
if [ "${1:-}" = "--self-test-height" ]; then
height_rule 900 1000 2>/dev/null && { echo "self-test failed: a passed height was accepted"; exit 1; }
height_rule 1000 1000 2>/dev/null && { echo "self-test failed: the live height was accepted"; exit 1; }
height_rule 1001 1000 || { echo "self-test failed: a future height was refused"; exit 1; }
height_rule "" 1000 || { echo "self-test failed: no height was refused"; exit 1; }
echo "self-test passed: a height at or below the live DAA is refused, a future one and none pass"; exit 0
fi
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0
while [ $# -gt 0 ]; do
case "$1" in
@ -67,6 +49,8 @@ while [ $# -gt 0 ]; do
--win) WIN="$2"; shift 2 ;;
--notes) NOTES="$2"; shift 2 ;;
--activation-height) ACTIVATION="$2"; shift 2 ;;
--allow-passed-activation) ALLOW_PASSED=1; shift ;;
--self-test-height) SELF_TEST_HEIGHT=1; shift ;;
--deadline-note) DEADLINE="$2"; shift 2 ;;
--override) OVERRIDE="$2"; shift 2 ;; # consensus.override: the exact JSON object every app writes to its override.json (all height switches, not just the new one)
--min-supported) MIN_SUPPORTED="$2"; shift 2 ;;
@ -83,12 +67,23 @@ while [ $# -gt 0 ]; do
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [ -n "$ACTIVATION" ] && [ "${IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT:-0}" != "1" ]; then
LIVE_DAA="$(curl -s -m 5 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"igneum_getExecStatus","params":[]}' http://127.0.0.1:26790 2>/dev/null | python3 -c 'import json,sys; print(int(json.load(sys.stdin)["result"]["executedTipDaa"],16))' 2>/dev/null || true)"
[ -n "$LIVE_DAA" ] || { echo "cannot read the live DAA from the observer (127.0.0.1:26790) to check --activation-height $ACTIVATION; start it or set IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 knowingly" >&2; exit 1; }
height_rule "$ACTIVATION" "$LIVE_DAA" || exit 1
echo "activation height $ACTIVATION is above the live DAA $LIVE_DAA ($((ACTIVATION - LIVE_DAA)) ahead)"
# Horizon polish Q4 (6 October 2026): has an activation height passed? 0 = pending (above the DAA), 1 = passed (at or
# below it), 2 = unknown (no DAA). A passed activation made every app since the 0.3.14 manifest read the fork as
# close ("0 blocks away, installing now", every safe-moment guard skipped).
activation_passed() { # <height> <live daa>
local h="$1" daa="$2"
[ "${daa:-0}" -gt 0 ] || return 2
[ "$h" -le "$daa" ] && return 1 || return 0
}
if [ "${SELF_TEST_HEIGHT:-0}" = 1 ]; then
# (set -e: a non-zero return is captured with `|| v=$?`, never left bare)
fail=0; v=0
v=0; activation_passed 33000 201776 || v=$?; [ "$v" -eq 1 ] || { echo "self-test: 33000 against 201776 should read as passed (got $v)" >&2; fail=1; }
v=0; activation_passed 201776 201776 || v=$?; [ "$v" -eq 1 ] || { echo "self-test: a height at the DAA should read as passed (got $v)" >&2; fail=1; }
v=0; activation_passed 300000 201776 || v=$?; [ "$v" -eq 0 ] || { echo "self-test: 300000 against 201776 should read as pending (got $v)" >&2; fail=1; }
v=0; activation_passed 300000 0 || v=$?; [ "$v" -eq 2 ] || { echo "self-test: no DAA should read as unknown (got $v)" >&2; fail=1; }
[ $fail -eq 0 ] && echo "self-test: the activation height check holds (passed, at the DAA, pending, unknown)"
exit $fail
fi
[ -n "$VERSION" ] || [ "$VERIFY_ONLY" = 1 ] || { echo "--version is required" >&2; exit 2; }
[ -n "$VERSION" ] || VERSION="(the folder's)"
@ -200,6 +195,24 @@ fi
# canonical JSON: sorted keys, no whitespace; the signature is over these exact bytes
NEW="$DEST/igneum-app-latest.json.new"
# Horizon polish Q4 (6 October 2026): an activation height that has already passed makes every app read the fork as
# close (0.3.14's manifest carried difficulty v2 at 33,000 against a DAA over 200,000: "0 blocks away, installing
# now" on every update, every safe-moment guard skipped). The live DAA comes from the public /api/live; a height at or
# below it is refused unless --allow-passed-activation says so.
if [ -n "$ACTIVATION" ]; then
LIVE_DAA="$(curl -fsS --max-time 10 "${IGNEUM_LIVE_API:-https://igneum.network/api/live}" 2>/dev/null | python3 -c 'import json,sys; print(int((json.load(sys.stdin).get("state") or {}).get("daa") or 0))' 2>/dev/null || echo 0)"
verdict=0; activation_passed "$ACTIVATION" "${LIVE_DAA:-0}" || verdict=$?
if [ "$verdict" -eq 1 ]; then
if [ "${ALLOW_PASSED:-0}" = 1 ]; then
echo "activation height $ACTIVATION is at or below the live DAA $LIVE_DAA (passed); published anyway on --allow-passed-activation" >&2
else
echo "refused: activation height $ACTIVATION is at or below the live DAA $LIVE_DAA, so it has passed; a passed activation makes every app read the fork as close (0.3.14 manifest). Drop --activation-height or pass --allow-passed-activation" >&2
exit 2
fi
elif [ "$verdict" -eq 2 ]; then
echo "note: the live DAA could not be read from /api/live; the activation height $ACTIVATION is not checked against it" >&2
fi
fi
python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" <<'PY'
import json, sys, datetime
out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning = sys.argv[1:12]

View file

@ -17,9 +17,9 @@ $s = State
if (-not $s) { Write-Output 'RESULT TUNE error=state_unreadable'; exit 1 }
$ver = [string]$s.version
# the first line: the version gate (main, 6 October 2026): 0.3.14 or later carries bd7fcf4 (AMD apply on ack) and
# 5165de7 (api/tune/goal with the climb); 0.3.15 the helper fixes A to E (6 October 2026); an older app aborts here
$vok = $false; try { $vok = ([version]($ver -replace '[^0-9.].*$', '')) -ge [version]'0.3.15' } catch { }
Write-Output ('RESULT TUNE version=' + $ver + ' gate=' + $(if ($vok) { 'ok (0.3.15 or later)' } else { 'FAILED (needs 0.3.15: the helper fixes A to E)' }) + ' climb_field=' + $(if ($null -ne $s.settings.tune_climb) { 'present' } else { 'absent' }))
# 5165de7 (api/tune/goal with the climb); 0.3.17 the helper fixes A to F (6 October 2026); an older app aborts here
$vok = $false; try { $vok = ([version]($ver -replace '[^0-9.].*$', '')) -ge [version]'0.3.17' } catch { }
Write-Output ('RESULT TUNE version=' + $ver + ' gate=' + $(if ($vok) { 'ok (0.3.17 or later)' } else { 'FAILED (needs 0.3.17: the helper fixes A to F; 0.3.16 carries F and repeats the 22:16Z result)' }) + ' climb_field=' + $(if ($null -ne $s.settings.tune_climb) { 'present' } else { 'absent' }))
if (-not $vok -or $null -eq $s.settings.tune_climb) { Write-Output 'RESULT TUNE error=version_gate'; exit 1 }
Write-Output ('RESULT TUNE installed app ' + $ver + ' power_control=' + $s.settings.power_control + ' tuning_off=' + $s.settings.tuning_off + ' climb_before=' + $s.settings.tune_climb + ' goal_before=' + $s.settings.tune_goal)
$t = Get-ScheduledTask -TaskName 'Igneum Power Helper' -ErrorAction SilentlyContinue