From 741424dfcec0adf9fad407ff3a3b95ecf3fc62ee Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:48:11 +0000 Subject: [PATCH 01/12] Ember for Miner UI 4 (0.3.16): card.tune_floor (the chosen clock is the ladder's floor), persisted as CardPref.sweep_floor Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 1f0f7849dbee00a18a11bed81d7e1be0234b7a0f) --- app/igneum-app/src/config.rs | 3 +++ app/igneum-app/src/engine.rs | 4 ++++ app/igneum-app/src/hotplug.rs | 1 + app/igneum-app/src/state.rs | 1 + docs/plans/ember-tune.md | 2 +- 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 51beeaf75..de4c8ec43 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -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, diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 70545e3ff..86ff77edf 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -2858,6 +2858,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(); diff --git a/app/igneum-app/src/hotplug.rs b/app/igneum-app/src/hotplug.rs index c5259f82b..f54a51627 100644 --- a/app/igneum-app/src/hotplug.rs +++ b/app/igneum-app/src/hotplug.rs @@ -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); } diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 196652162..ee090726f 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -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, diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 3ac2fd4da..844ad8eb4 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -318,7 +318,7 @@ For 0.3.16 (Miner UI 4's Cards tab, Ember as its second layer), on ember-tune pa | 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 | +| (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`); `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: From 568395b78beaab5d4fe6d24af55aff75505671d7 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:53:49 +0000 Subject: [PATCH 02/12] Ember plan: 0.3.15 took ember-tune at 441d1ea (merge 02627f3); card.tune_floor (1f0f784) rides the next cut Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 6e8a64d1a39f9401aa4b26578cbdf28f2e44a9de) --- docs/plans/ember-tune.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 844ad8eb4..83737346b 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -314,13 +314,17 @@ What a user on 0.3.14 sees: Power control on, no prompt, and no card tunes (the ## 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 the next cut. + +For Miner UI 4's Cards tab (Ember as its second layer), 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`); `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 | +| 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 | |---|---|---| From 4e4b1fab15ca9fc84647d6bfd2aec99989225001 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:24:38 +0000 Subject: [PATCH 03/12] Ember for Miner UI 4 (0.3.16): state.mining.watts_total and pounds_per_day (the fleet's fresh draw, the price from settings), state.address.balance_wei with balance_age_s and balance_note (eth_getBalance through the node's RPC every 30 s), state.address.price_gbp_per_ign null with its one documented source (a signed manifest field); GET /api/live from a local source (src/live.rs: igneum_getRecentBlocks when the node carries it, else the public site's reply cached 60 s, source and age_s on every reply); tests Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 027616330180992afc17baaf426e41a76e516503) --- app/igneum-app/src/ember.rs | 29 ++++ app/igneum-app/src/engine.rs | 63 ++++++++ app/igneum-app/src/live.rs | 294 +++++++++++++++++++++++++++++++---- app/igneum-app/src/prover.rs | 2 +- app/igneum-app/src/server.rs | 2 +- app/igneum-app/src/state.rs | 13 ++ docs/plans/ember-tune.md | 10 ++ 7 files changed, 377 insertions(+), 36 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index 113751eff..bca7fe6e3 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -906,6 +906,21 @@ 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)") } +/// 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, 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 { + 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 +1049,20 @@ 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 a_card_goal_overrides_the_global_goal_and_empty_follows_it() { assert_eq!(goal_for("", "balanced"), Goal::Balanced); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 86ff77edf..1e5b863f5 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -28,6 +28,8 @@ pub struct CardChoice { pub enum Cmd { Detect, + /// the payout address's balance (eth_getBalance, hex) or why it could not be read + BalanceRead(Result), ApplyCards(Vec), /// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs) Detected(crate::detect::Detection), @@ -404,6 +406,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 +616,10 @@ pub struct Engine { tune_acked: Option, /// cards whose confirm check found a better neighbour: the full plan runs next tune_full_due: std::collections::HashSet, + /// Miner UI 4: the balance read (every 30 s while the node runs; 60 s after a failure) + balance_next: Option, + 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 +724,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 +1162,29 @@ impl Engine { } } Cmd::TuneProgress(v) => self.tune_progress(&v), + 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(); @@ -3008,6 +3044,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 { @@ -3544,6 +3581,28 @@ 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)); + }); + } + /// 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(); @@ -3558,6 +3617,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); diff --git a/app/igneum-app/src/live.rs b/app/igneum-app/src/live.rs index c1eab9367..b6615cef7 100644 --- a/app/igneum-app/src/live.rs +++ b/app/igneum-app/src/live.rs @@ -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> = Mutex::new(None); const TTL: Duration = Duration::from_secs(2); +struct NodeCache { + at: Option, + blocks: Vec, + /// the node said "method not found" (-32601): rest until then + retry: Option, +} +static NODE: Mutex = 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) -> 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) -> 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 { + 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 = 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::>(), + }) +} + +/// 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) -> Vec { + { + 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, 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, live_api: &str, window_s: u32, ids: &HashSet) -> 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::(&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::(&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 = ["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"); } } diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index 1d2140bbc..36c55542e 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -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 { +pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result { 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())?; diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 9db8176c7..81afdd798 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -185,7 +185,7 @@ fn handle(mut stream: TcpStream, shared: Arc) { 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 = 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); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index ee090726f..bc5ed11e1 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -167,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...`). @@ -275,6 +279,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, + 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, } #[derive(Clone, Serialize, Default)] diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 83737346b..0650c1cf5 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -318,6 +318,16 @@ Correction (6 October 2026, 19:0xZ): release-0.3.15 took ember-tune at 5429e82 ( 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 the next cut. +For 0.3.16 (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 | +| `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), on ember-tune: | Commit | What | Where | From 447cd019b9f0d672682e5248ae211f9aafafe1da Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:47:37 +0000 Subject: [PATCH 04/12] Updater (0.3.16, Horizon frontier lane): nothing installs while the network's finality is paused (a synced node with no checkpoint lock for 15 min); the update card reads "waiting for finality"; only the signed manifest's own urgent flag installs through a pause, a fork-close or unsupported urgency does not; slot, catch-up and patience rules unchanged; the known-failed case tested Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 700312688ab39dd4dd2731e4fe35d03113cb09dc) --- app/igneum-app/src/engine.rs | 3 +++ app/igneum-app/src/manifest.rs | 26 +++++++++++++++++++++++++- app/igneum-app/src/ota.rs | 5 ++++- docs/plans/ember-tune.md | 1 + 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 1e5b863f5..2d691d18d 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -3093,6 +3093,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.node.synced && (if st.finality.last_lock > 0 { st.finality.age_s } else { st.uptime_s as f64 }) > crate::manifest::FINALITY_PAUSE_S, 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"), diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index 15b883324..baaff96a3 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -52,6 +52,9 @@ pub struct Manifest { pub min_supported_version: String, pub notes: String, pub activation_height: Option, + /// 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 /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 { 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()); } @@ -499,8 +516,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()); diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index d1e669f89..e84a9ae97 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -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, 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; diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 0650c1cf5..cd0b93776 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -326,6 +326,7 @@ For 0.3.16 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, o | `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 | +| 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), on ember-tune: From 39b8df93cd3315455e1ec4eae8776e106206b7d1 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:28:23 +0000 Subject: [PATCH 05/12] Horizon polish for 0.3.16: (Q4) fork_is_close treats a passed activation as not close (every update since the 0.3.14 manifest read it as 0 blocks away and skipped every guard), publish-manifest.sh refuses an activation height at or below the live DAA unless --allow-passed-activation; (Q83, Q84, Q2) the finality pause shown: state.finality.paused, paused_since, 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 finality_reason/held_by), on the node line, the Overview's state and the Finality card, no lock called final while paused; tests Co-Authored-By: Claude Fable 5.1 (cherry picked from commit e5e1e92e37d520248570aefac8ea67708e308134) --- app/igneum-app/src/ember.rs | 49 +++++++++++++++++++++++++++++++ app/igneum-app/src/engine.rs | 21 ++++++++++++- app/igneum-app/src/manifest.rs | 12 ++++++-- app/igneum-app/src/state.rs | 9 ++++++ docs/plans/ember-tune.md | 2 ++ packaging/ota/publish-manifest.sh | 19 ++++++++++++ 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index bca7fe6e3..8e261c87c 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -906,6 +906,41 @@ 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 pause line, when it carries one: `... finality_reason= held_by=`. +/// 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, now: f64, max_age_s: f64) -> f64 { @@ -1063,6 +1098,20 @@ mod tests { 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); + } + #[test] fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() { assert_eq!(goal_for("", "balanced"), Goal::Balanced); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 2d691d18d..7f079a19c 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -3095,7 +3095,7 @@ impl Engine { 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.node.synced && (if st.finality.last_lock > 0 { st.finality.age_s } else { st.uptime_s as f64 }) > crate::manifest::FINALITY_PAUSE_S, + 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"), @@ -3645,6 +3645,18 @@ 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 }; + let paused = st.node.synced && gap > crate::manifest::FINALITY_PAUSE_S; + if paused && !st.finality.paused { + 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(); @@ -4050,6 +4062,13 @@ 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; + } } else if text.contains(" VOTE index=") { self.st().finality.votes += 1; } else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") { diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index baaff96a3..893934c05 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -373,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, 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, } } @@ -567,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")); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index bc5ed11e1..6797a2ee3 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -257,6 +257,15 @@ 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, } /// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind). diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index cd0b93776..2ba3b6400 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -326,6 +326,8 @@ For 0.3.16 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, o | `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 | +| 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= held_by=`, 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 | diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index 41a593d19..6e71e8413 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -7,6 +7,7 @@ # 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) # [--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; @@ -67,6 +68,7 @@ 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 ;; --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 ;; @@ -200,6 +202,23 @@ 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)" + if [ "${LIVE_DAA:-0}" -gt 0 ] && [ "$ACTIVATION" -le "$LIVE_DAA" ]; 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 [ "${LIVE_DAA:-0}" -eq 0 ]; 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] From c1e3204481555adef9596fcdd4478b7a98cd4f6c Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:29:45 +0000 Subject: [PATCH 06/12] publish-manifest.sh: the activation height check as a function with --self-test-height (the known-bad case 33000 against 201776, at the DAA, pending, unknown), the shape the shipper's 0.3.15 guard had; the public /api/live stays the source Co-Authored-By: Claude Fable 5.1 (cherry picked from commit e54a87bc44514e907c71728ad96775b962e5e5dc) --- packaging/ota/publish-manifest.sh | 50 ++++++++++++++----------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index 6e71e8413..1a857e789 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -8,6 +8,7 @@ # [--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; @@ -40,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() { # -> 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 @@ -69,6 +50,7 @@ while [ $# -gt 0 ]; do --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 ;; @@ -85,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() { # + 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)" @@ -208,14 +201,15 @@ NEW="$DEST/igneum-app-latest.json.new" # 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)" - if [ "${LIVE_DAA:-0}" -gt 0 ] && [ "$ACTIVATION" -le "$LIVE_DAA" ]; then + 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 [ "${LIVE_DAA:-0}" -eq 0 ]; then + 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 From 740c35783e939e5fbbafc744196194fac11d36d0 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:30:55 +0000 Subject: [PATCH 07/12] Finality pause (0.3.16): the node lane's structured carrier on igneum_getProvingStatus (finalityReason, finalityProvisional, heldBy {tableIndex, stayersShareBps, expiresDaa}, pausedSinceMs) read on the 30-s RPC cadence; the node's pausedSince wins over the engine's own; state.finality.provisional and cause_source; the log line and "finality resumed" kept; tests with the node lane's field names Co-Authored-By: Claude Fable 5.1 (cherry picked from commit a7b5ecccf095d11f6b2ffd60ece770e8f7fb6b87) --- app/igneum-app/src/ember.rs | 38 ++++++++++++++++++++++++++++++++++++ app/igneum-app/src/engine.rs | 33 ++++++++++++++++++++++++++++++- app/igneum-app/src/state.rs | 4 ++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index 8e261c87c..c3353ccd6 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -925,6 +925,32 @@ pub fn finality_paused_line(since_unix: f64, reason: &str, held_by: &str) -> Str 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 { + pub reason: String, + pub held_by: String, + pub provisional: bool, + pub paused_since: Option, +} + +pub fn parse_finality_status(v: &serde_json::Value) -> Option { + let reason = v.get("finalityReason")?.as_str().unwrap_or("").trim().to_string(); + 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 paused_since = v.get("pausedSinceMs").and_then(|x| x.as_u64()).filter(|ms| *ms > 0).map(|ms| ms as f64 / 1000.0); + Some(FinalityStatus { reason, held_by, provisional, paused_since }) +} + /// The node's pause line, when it carries one: `... finality_reason= held_by=`. /// Returns (reason, held_by); None when the line is not one. pub fn parse_finality_line(text: &str) -> Option<(String, String)> { @@ -1110,6 +1136,18 @@ mod tests { 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) + let v: serde_json::Value = serde_json::from_str(r#"{"v1":{"active":true},"finalityReason":"under two thirds of the weight is signing","finalityProvisional":true,"heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000},"pausedSinceMs":1791311940000}"#).unwrap(); + let f = parse_finality_status(&v).unwrap(); + assert_eq!((f.reason.as_str(), f.provisional, f.paused_since), ("under two thirds of the weight is signing", true, Some(1_791_311_940.0))); + assert_eq!(f.held_by, "weight table 7, 61.50% still signing, expires at DAA 205000"); + 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 (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()); + // a node before 0.3.16 carries none of it + assert!(parse_finality_status(&serde_json::json!({"v1":{"active":true}})).is_none()); } #[test] diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 7f079a19c..22540a7da 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -30,6 +30,8 @@ pub enum Cmd { Detect, /// the payout address's balance (eth_getBalance, hex) or why it could not be read BalanceRead(Result), + /// the node's finality fields from igneum_getProvingStatus (None: the node carries none, or did not answer) + FinalityStatus(Option), ApplyCards(Vec), /// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs) Detected(crate::detect::Detection), @@ -1162,6 +1164,26 @@ impl Engine { } } Cmd::TuneProgress(v) => self.tune_progress(&v), + Cmd::FinalityStatus(f) => { + let mut st = self.st(); + match f { + Some(f) => { + 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 + if st.finality.cause_source != "node-line" { + st.finality.cause_source.clear(); + } + } + } + } Cmd::BalanceRead(r) => { self.balance_busy = false; match r { @@ -3603,6 +3625,10 @@ impl Engine { 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)); }); } @@ -3649,7 +3675,7 @@ impl Engine { // 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 }; let paused = st.node.synced && gap > crate::manifest::FINALITY_PAUSE_S; - if paused && !st.finality.paused { + 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; @@ -4068,7 +4094,12 @@ impl Engine { 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 ") { diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 6797a2ee3..3ff5d3e58 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -266,6 +266,10 @@ pub struct FinalityState { 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 or its log line) or "" (the engine's own rule) + pub cause_source: String, } /// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind). From d5d8e76a7a0acd2527013273e49b3bf171e86be3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:23:56 +0000 Subject: [PATCH 08/12] Finality pause and /api/live pinned to the node's landed shape (ca3-v4-0316 b2e21447, eec34ac3): finalityActive decides paused when present, the node's "paused: " prefix dropped from the sentence, heldBy not repeated when the reason names the table, timestamp_source header | chain_block, a null vote_key_hash reads as empty; tests on the node's exact words Co-Authored-By: Claude Fable 5.1 (cherry picked from commit b43d329d105868bc604241012932fce047089ad6) --- app/igneum-app/src/ember.rs | 28 +++++++++++++++++++++------- app/igneum-app/src/engine.rs | 8 +++++++- app/igneum-app/src/state.rs | 6 +++++- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index c3353ccd6..0bb4a93c6 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -929,6 +929,8 @@ pub fn finality_paused_line(since_unix: f64, reason: &str, held_by: &str) -> Str /// (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, pub reason: String, pub held_by: String, pub provisional: bool, @@ -936,7 +938,10 @@ pub struct FinalityStatus { } pub fn parse_finality_status(v: &serde_json::Value) -> Option { - let reason = v.get("finalityReason")?.as_str().unwrap_or("").trim().to_string(); + // 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() => { @@ -947,8 +952,9 @@ pub fn parse_finality_status(v: &serde_json::Value) -> Option { } _ => 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 { reason, held_by, provisional, paused_since }) + Some(FinalityStatus { active, reason, held_by, provisional, paused_since }) } /// The node's pause line, when it carries one: `... finality_reason= held_by=`. @@ -1137,15 +1143,23 @@ mod tests { 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) - let v: serde_json::Value = serde_json::from_str(r#"{"v1":{"active":true},"finalityReason":"under two thirds of the weight is signing","finalityProvisional":true,"heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000},"pausedSinceMs":1791311940000}"#).unwrap(); + // 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.reason.as_str(), f.provisional, f.paused_since), ("under two thirds of the weight is signing", true, Some(1_791_311_940.0))); - assert_eq!(f.held_by, "weight table 7, 61.50% still signing, expires at DAA 205000"); - 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 (held by weight table 7, 61.50% still signing, expires at DAA 205000)"); + 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()); + 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()); } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 22540a7da..b94715b41 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -1168,6 +1168,7 @@ impl Engine { 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; @@ -1178,6 +1179,7 @@ impl Engine { } 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(); } @@ -3674,7 +3676,11 @@ impl Engine { // 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 }; - let paused = st.node.synced && gap > crate::manifest::FINALITY_PAUSE_S; + // 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 }; } diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 3ff5d3e58..966eddd1b 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -268,8 +268,12 @@ pub struct FinalityState { 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 or its log line) or "" (the engine's own rule) + /// 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, } /// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind). From c88504b47b37a99ecd5feec109222b85cb5335cc Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:59:53 +0000 Subject: [PATCH 09/12] Ember plan: 0.3.16 is the corrective cut of 0.3.15; the Miner UI 4 and Horizon rows ship as 0.3.17 Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 454ec0a7620481fb6d495d0cce0877e2336e7b6e) --- docs/plans/ember-tune.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 2ba3b6400..fb2e38ce8 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -316,9 +316,11 @@ What a user on 0.3.14 sees: Power control on, no prompt, and no card tunes (the 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 the next cut. +`card.tune_floor` (995530b), which rides 0.3.17. -For 0.3.16 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, on ember-tune past 5429e82: +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 | |---|---|---| @@ -331,7 +333,7 @@ For 0.3.16 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, o | 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), on ember-tune: +For Miner UI 4's Cards tab (Ember as its second layer), 0.3.17, on ember-tune: | Commit | What | Where | |---|---|---| From 16ec189a0bd52b8d04aa9f75c0e7160dc3040511 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:28:49 +0000 Subject: [PATCH 10/12] Ember helper (F), from the 0.3.16 window on PC 1: the tune path wrote its request index as the wire sequence ("00 dev 1", "01 pl 160") below the cap path's unix-based numbers, so the helper skipped every tune command as stale and both climbs stopped on "the helper did not run sequence 1 within 15 s"; every writer now draws from one monotonic space (powertask::wire_seq), the acknowledgement matches the wire number; test Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 90077927429109aaa5168aca5107a1a86c9414b0) --- app/igneum-app/src/engine.rs | 11 +++++++---- app/igneum-app/src/powertask.rs | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index b94715b41..c76cbd129 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -1905,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; @@ -2583,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; @@ -2664,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, @@ -2674,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 (`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 { diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs index 3f22d2378..a4f9043fd 100644 --- a/app/igneum-app/src/powertask.rs +++ b/app/igneum-app/src/powertask.rs @@ -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)); From fa99216dd3f349e170a7ea68788e863976d5b174 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:29:40 +0000 Subject: [PATCH 11/12] Ember: the 0.3.16 window in the plan and the bench log (A to E held, the 9070 XT measured, cause F and its fix) Co-Authored-By: Claude Fable 5.1 (cherry picked from commit d5f236afc03d13c99ef7bec6e059d60b93f2019b) --- docs/bench-log.md | 8 ++++++++ docs/plans/ember-tune.md | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/bench-log.md b/docs/bench-log.md index d3d093737..86339a054 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -2626,3 +2626,11 @@ USD 20 an hour on community pods, against a devnet of 1.16 GH/s. Consequence: the devnet's hash is rentable for the price of a dinner, so nothing on it is a security result; the counter-ASIC and finality work is tested there for correctness, not for cost. The cost argument only starts at the TH/s scale, where the rental market's supply (not its price) is the limit, and that number belongs in the litepaper with this caveat. +## 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. diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index fb2e38ce8..737136f58 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -312,6 +312,24 @@ 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) Correction (6 October 2026, 19:0xZ): release-0.3.15 took ember-tune at 5429e82 (merge 886c075), because Miner UI 4's @@ -328,6 +346,7 @@ For 0.3.17 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, o | `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= held_by=`, 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 | From 4c3d68b63e37d0a298b942cb4bb90725a790583e Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:30:25 +0000 Subject: [PATCH 12/12] Ember shipped-app window playbook: the gate at 0.3.17 (0.3.16 carries cause F and would repeat the 22:16Z result) Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 44e2a355c4a7fbfb220c695cd87f69d01f92a85c) --- relay/playbooks/ember-installed-tune-pc1.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/relay/playbooks/ember-installed-tune-pc1.ps1 b/relay/playbooks/ember-installed-tune-pc1.ps1 index 1848b53be..86e06de25 100644 --- a/relay/playbooks/ember-installed-tune-pc1.ps1 +++ b/relay/playbooks/ember-installed-tune-pc1.ps1 @@ -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