Merge commit '4c3d68b6' into release-0.3.17
# Conflicts: # docs/bench-log.md
This commit is contained in:
commit
49daffbf9d
15 changed files with 703 additions and 77 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -906,6 +906,88 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String {
|
|||
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
|
||||
}
|
||||
|
||||
/// HH:MM UTC of a unix time (the day is not shown: "since 18:39 UTC").
|
||||
pub fn hhmm_utc(unix: f64) -> String {
|
||||
let s = unix.max(0.0) as u64 % 86_400;
|
||||
format!("{:02}:{:02}", s / 3600, (s % 3600) / 60)
|
||||
}
|
||||
|
||||
/// Horizon polish Q83: the one sentence every surface shows while finality is paused. The cause is the node's own
|
||||
/// (`reason`, with who holds it) when it carries one, else the plain two-thirds line; never the word "final".
|
||||
pub fn finality_paused_line(since_unix: f64, reason: &str, held_by: &str) -> String {
|
||||
let cause = if reason.trim().is_empty() {
|
||||
"under two thirds of the weight is signing".to_string()
|
||||
} else if held_by.trim().is_empty() {
|
||||
reason.trim().to_string()
|
||||
} else {
|
||||
format!("{} (held by {})", reason.trim(), held_by.trim())
|
||||
};
|
||||
format!("Finality paused since {} UTC: {cause}", hhmm_utc(since_unix))
|
||||
}
|
||||
|
||||
/// The node's structured finality fields on igneum_getProvingStatus (the node lane, 0.3.16): "finalityReason"
|
||||
/// (empty when live), "finalityProvisional", "heldBy" {tableIndex, stayersShareBps, expiresDaa} or null,
|
||||
/// "pausedSinceMs" or null. Returns (reason, held-by words, provisional, paused_since unix seconds).
|
||||
pub struct FinalityStatus {
|
||||
/// the node's own word on whether finality is live (b2e21447: finalityActive); None on an older node
|
||||
pub active: Option<bool>,
|
||||
pub reason: String,
|
||||
pub held_by: String,
|
||||
pub provisional: bool,
|
||||
pub paused_since: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn parse_finality_status(v: &serde_json::Value) -> Option<FinalityStatus> {
|
||||
// the node's words begin "paused: ..." (b2e21447); the app's sentence already says "Finality paused since", so
|
||||
// that prefix goes; a reason that already names who holds it ("held by weight table 7, ...") is not repeated
|
||||
let reason = v.get("finalityReason")?.as_str().unwrap_or("").trim().trim_start_matches("paused:").trim().to_string();
|
||||
let active = v.get("finalityActive").and_then(|b| b.as_bool());
|
||||
let provisional = v.get("finalityProvisional").and_then(|b| b.as_bool()).unwrap_or(false);
|
||||
let held_by = match v.get("heldBy") {
|
||||
Some(h) if h.is_object() => {
|
||||
let idx = h.get("tableIndex").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
let bps = h.get("stayersShareBps").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
let exp = h.get("expiresDaa").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
format!("weight table {idx}, {}.{:02}% still signing, expires at DAA {exp}", bps / 100, bps % 100)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let held_by = if reason.contains("held by") { String::new() } else { held_by };
|
||||
let paused_since = v.get("pausedSinceMs").and_then(|x| x.as_u64()).filter(|ms| *ms > 0).map(|ms| ms as f64 / 1000.0);
|
||||
Some(FinalityStatus { active, reason, held_by, provisional, paused_since })
|
||||
}
|
||||
|
||||
/// The node's pause line, when it carries one: `... finality_reason=<words or "quoted words"> held_by=<id>`.
|
||||
/// Returns (reason, held_by); None when the line is not one.
|
||||
pub fn parse_finality_line(text: &str) -> Option<(String, String)> {
|
||||
let i = text.find("finality_reason=")?;
|
||||
let rest = &text[i + "finality_reason=".len()..];
|
||||
let (reason, after) = if let Some(q) = rest.strip_prefix('"') {
|
||||
let end = q.find('"')?;
|
||||
(q[..end].to_string(), &q[end + 1..])
|
||||
} else {
|
||||
let end = rest.find(' ').unwrap_or(rest.len());
|
||||
(rest[..end].replace('_', " "), &rest[end..])
|
||||
};
|
||||
let held_by = after.find("held_by=").map(|j| after[j + 8..].split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();
|
||||
Some((reason, held_by))
|
||||
}
|
||||
|
||||
/// Miner UI 4 (6 October 2026): the fleet's draw now: the sum over cards that mine and have a draw reading under
|
||||
/// `max_age_s` old (a stale reading is not a draw).
|
||||
pub fn fleet_watts<'a>(cards: impl Iterator<Item = (&'a str, f64, f64)>, now: f64, max_age_s: f64) -> f64 {
|
||||
cards.filter(|(state, w, at)| *state == "mining" && *w > 0.0 && now - *at < max_age_s).map(|(_, w, _)| w).sum()
|
||||
}
|
||||
|
||||
/// A hex quantity (0x-prefixed, as eth_getBalance answers) as a decimal string; None when it is not one.
|
||||
pub fn wei_from_hex(hex: &str) -> Option<String> {
|
||||
let h = hex.trim().strip_prefix("0x").unwrap_or(hex.trim());
|
||||
if h.is_empty() {
|
||||
return Some("0".into());
|
||||
}
|
||||
u128::from_str_radix(h, 16).ok().map(|v| v.to_string())
|
||||
}
|
||||
|
||||
/// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one.
|
||||
pub fn goal_for(card_goal: &str, global: &str) -> Goal {
|
||||
Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal })
|
||||
|
|
@ -1034,6 +1116,54 @@ mod tests {
|
|||
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fleet_draw_sums_mining_cards_with_a_fresh_reading_and_the_balance_reads_in_wei() {
|
||||
let now = 1_791_300_000.0;
|
||||
let cards = vec![("mining", 226.8, now - 5.0), ("mining", 75.6, now - 10.0), ("mining", 201.0, now - 120.0), ("off", 30.0, now - 1.0), ("mining", 0.0, now)];
|
||||
let w = fleet_watts(cards.iter().map(|(s, w, at)| (*s, *w, *at)), now, 60.0);
|
||||
assert!((w - 302.4).abs() < 1e-9, "the stale 9070 XT reading and the card that is off do not count: {w}");
|
||||
assert!((pounds_per_day(302.4, 28.5) - 2.068416).abs() < 1e-6);
|
||||
assert_eq!(wei_from_hex("0x1a"), Some("26".into()));
|
||||
assert_eq!(wei_from_hex("0x0"), Some("0".into()));
|
||||
assert_eq!(wei_from_hex("0x"), Some("0".into()));
|
||||
assert_eq!(wei_from_hex("0xde0b6b3a7640000"), Some("1000000000000000000".into()), "one IGN");
|
||||
assert_eq!(wei_from_hex("soon"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_finality_pause_line_names_the_time_and_the_cause_and_never_says_final() {
|
||||
// 6 October 2026 18:39:00Z
|
||||
let since = 1_791_311_940.0;
|
||||
assert_eq!(hhmm_utc(since), "18:39");
|
||||
let plain = finality_paused_line(since, "", "");
|
||||
assert_eq!(plain, "Finality paused since 18:39 UTC: under two thirds of the weight is signing");
|
||||
assert!(!plain.to_ascii_lowercase().contains("final "), "no 'final' word while paused: {plain}");
|
||||
assert_eq!(finality_paused_line(since, "a checkpoint vote is split", "ae432dc7"), "Finality paused since 18:39 UTC: a checkpoint vote is split (held by ae432dc7)");
|
||||
assert_eq!(parse_finality_line("1791311940 finality paused finality_reason=\"vote split at index 412\" held_by=ae432dc7"), Some(("vote split at index 412".into(), "ae432dc7".into())));
|
||||
assert_eq!(parse_finality_line("x finality_reason=under_two_thirds"), Some(("under two thirds".into(), String::new())));
|
||||
assert_eq!(parse_finality_line("status: accepted 3 blocks"), None);
|
||||
// the structured carrier on igneum_getProvingStatus (the node lane's field names)
|
||||
// the node's own words (b2e21447): "paused: under two thirds ... ({p}%; the table frozen at lock {j} has {q}% signing)"
|
||||
let v: serde_json::Value = serde_json::from_str(r#"{"v1":{"active":true},"finalityActive":false,"finalityReason":"paused: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)","finalityProvisional":false,"heldBy":null,"pausedSinceMs":1791311940000}"#).unwrap();
|
||||
let f = parse_finality_status(&v).unwrap();
|
||||
assert_eq!((f.active, f.provisional, f.paused_since), (Some(false), false, Some(1_791_311_940.0)));
|
||||
assert_eq!(finality_paused_line(f.paused_since.unwrap(), &f.reason, &f.held_by), "Finality paused since 18:39 UTC: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)");
|
||||
// the held-by form: the reason names the table itself, so heldBy is not repeated
|
||||
let v2: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: held by weight table 7, 61% of it signing, expires at DAA 205000","finalityProvisional":true,"heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000},"pausedSinceMs":1791311940000}"#).unwrap();
|
||||
let g = parse_finality_status(&v2).unwrap();
|
||||
assert!(g.provisional && g.held_by.is_empty());
|
||||
assert_eq!(finality_paused_line(g.paused_since.unwrap(), &g.reason, &g.held_by), "Finality paused since 18:39 UTC: held by weight table 7, 61% of it signing, expires at DAA 205000");
|
||||
// a reason without the table words keeps the heldBy suffix
|
||||
let v3: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: no checkpoint determined above the latest lock","heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000}}"#).unwrap();
|
||||
assert_eq!(parse_finality_status(&v3).unwrap().held_by, "weight table 7, 61.50% still signing, expires at DAA 205000");
|
||||
// live finality: an empty reason, null heldBy, null pausedSinceMs
|
||||
let live: serde_json::Value = serde_json::from_str(r#"{"finalityReason":"","finalityProvisional":false,"heldBy":null,"pausedSinceMs":null}"#).unwrap();
|
||||
let l = parse_finality_status(&live).unwrap();
|
||||
assert!(l.reason.is_empty() && l.held_by.is_empty() && !l.provisional && l.paused_since.is_none() && l.active.is_none());
|
||||
// a node before 0.3.16 carries none of it
|
||||
assert!(parse_finality_status(&serde_json::json!({"v1":{"active":true}})).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() {
|
||||
assert_eq!(goal_for("", "balanced"), Goal::Balanced);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ pub struct CardChoice {
|
|||
|
||||
pub enum Cmd {
|
||||
Detect,
|
||||
/// the payout address's balance (eth_getBalance, hex) or why it could not be read
|
||||
BalanceRead(Result<String, String>),
|
||||
/// the node's finality fields from igneum_getProvingStatus (None: the node carries none, or did not answer)
|
||||
FinalityStatus(Option<crate::ember::FinalityStatus>),
|
||||
ApplyCards(Vec<CardChoice>),
|
||||
/// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs)
|
||||
Detected(crate::detect::Detection),
|
||||
|
|
@ -404,6 +408,10 @@ fn address_state(s: &Settings, wallet_path: &std::path::Path) -> crate::state::A
|
|||
source: s.address_source.clone(),
|
||||
key_saved: s.key_saved,
|
||||
wallet_file: if s.address_source == "generated" { wallet_path.display().to_string() } else { String::new() },
|
||||
balance_wei: None,
|
||||
balance_age_s: -1.0,
|
||||
balance_note: String::new(),
|
||||
price_gbp_per_ign: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -610,6 +618,10 @@ pub struct Engine {
|
|||
tune_acked: Option<u64>,
|
||||
/// cards whose confirm check found a better neighbour: the full plan runs next
|
||||
tune_full_due: std::collections::HashSet<usize>,
|
||||
/// Miner UI 4: the balance read (every 30 s while the node runs; 60 s after a failure)
|
||||
balance_next: Option<Instant>,
|
||||
balance_busy: bool,
|
||||
balance_at: f64,
|
||||
/// a sweep waiting for the cap-mode probe: (card index, forced)
|
||||
sweep_pending: Option<(usize, bool)>,
|
||||
/// how caps are set: Some(true) directly, Some(false) through the elevated helper, None = not probed yet
|
||||
|
|
@ -714,6 +726,9 @@ impl Engine {
|
|||
sweep_helper_is_task: false,
|
||||
tune_acked: None,
|
||||
tune_full_due: std::collections::HashSet::new(),
|
||||
balance_next: None,
|
||||
balance_busy: false,
|
||||
balance_at: 0.0,
|
||||
sweep_pending: None,
|
||||
sweep_direct: None,
|
||||
sweep_helper: false,
|
||||
|
|
@ -1149,6 +1164,51 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
Cmd::TuneProgress(v) => self.tune_progress(&v),
|
||||
Cmd::FinalityStatus(f) => {
|
||||
let mut st = self.st();
|
||||
match f {
|
||||
Some(f) => {
|
||||
st.finality.node_active = f.active;
|
||||
st.finality.reason = f.reason;
|
||||
st.finality.held_by = f.held_by;
|
||||
st.finality.provisional = f.provisional;
|
||||
st.finality.cause_source = "node".into();
|
||||
if let Some(since) = f.paused_since {
|
||||
st.finality.paused_since = since;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// a node without the fields: the log line (parse_finality_line) may still have set a cause
|
||||
st.finality.node_active = None;
|
||||
if st.finality.cause_source != "node-line" {
|
||||
st.finality.cause_source.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::BalanceRead(r) => {
|
||||
self.balance_busy = false;
|
||||
match r {
|
||||
Ok(hex) => match crate::ember::wei_from_hex(&hex) {
|
||||
Some(wei) => {
|
||||
let mut st = self.st();
|
||||
st.address.balance_wei = Some(wei);
|
||||
st.address.balance_note.clear();
|
||||
drop(st);
|
||||
self.balance_at = crate::platform::unix_now_f();
|
||||
self.balance_next = Some(Instant::now() + Duration::from_secs(30));
|
||||
}
|
||||
None => {
|
||||
self.st().address.balance_note = format!("the node answered {hex} for the balance, not a quantity");
|
||||
self.balance_next = Some(Instant::now() + Duration::from_secs(60));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
self.st().address.balance_note = e;
|
||||
self.balance_next = Some(Instant::now() + Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::TuneCardGoal(key, goal) => {
|
||||
{
|
||||
let mut s = self.shared.settings.lock().unwrap();
|
||||
|
|
@ -1845,7 +1905,7 @@ impl Engine {
|
|||
std::thread::spawn(move || {
|
||||
let r = crate::powertask::start().and_then(|_| {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let mut seq = crate::platform::unix_now() % 1_000_000;
|
||||
let mut seq = crate::powertask::wire_seq();
|
||||
let mut text = String::new();
|
||||
for (dev, w) in &pairs {
|
||||
seq += 1;
|
||||
|
|
@ -2523,7 +2583,7 @@ impl Engine {
|
|||
// under a scratch IGNEUM_APP_DATA), no prompt
|
||||
let hdir = crate::powertask::helper_dir();
|
||||
let _ = std::fs::create_dir_all(&hdir);
|
||||
let _ = std::fs::write(hdir.join("cmd.txt"), format!("{} dev {}\n", crate::platform::unix_now() % 1_000_000, c.device));
|
||||
let _ = std::fs::write(hdir.join("cmd.txt"), format!("{} dev {}\n", crate::powertask::wire_seq(), c.device));
|
||||
crate::powertask::start()?;
|
||||
self.shared.log("tune helper: the Igneum Power Helper task (no prompt)");
|
||||
self.sweep_helper = true;
|
||||
|
|
@ -2604,7 +2664,10 @@ impl Engine {
|
|||
return;
|
||||
}
|
||||
let dev = device.to_string();
|
||||
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n{seq}3 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
|
||||
// (F) the wire numbers come from the one monotonic space every writer uses (powertask::wire_seq);
|
||||
// `seq` (the run's request index) stays the engine's own id for the acknowledgement
|
||||
let wire = crate::powertask::wire_seq();
|
||||
let cmd = format!("{} dev {dev}\n{} pl {w}\n{} {}\n{} {}\n", wire, wire + 1, wire + 2, if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, wire + 3, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
|
||||
let dir = self.helper_cmd_dir();
|
||||
let is_task = self.sweep_helper_is_task;
|
||||
// 6 October 2026, PC 1: (B) the task's helper had exited while the engine's flag still said it ran,
|
||||
|
|
@ -2614,7 +2677,7 @@ impl Engine {
|
|||
// is started and a fresh heartbeat awaited BEFORE the command is written; and the acknowledgement
|
||||
// is the helper's own log line for this sequence (`<seq>3 nvidia-smi ...`), read for up to 15 s;
|
||||
// a missing line is a failure with that reason
|
||||
let want = format!(" {seq}3 nvidia-smi");
|
||||
let want = format!(" {} nvidia-smi", wire + 3);
|
||||
let log_file = dir.join("helper.log");
|
||||
std::thread::spawn(move || {
|
||||
if is_task {
|
||||
|
|
@ -2858,6 +2921,10 @@ impl Engine {
|
|||
e.sweep_mhs = row.mhs;
|
||||
e.sweep_clock_mhz = row.point.clock_mhz;
|
||||
e.sweep_mem_mhz = row.point.mem_mhz;
|
||||
// Miner UI 4: the row's sub-line reads "(the ladder's floor)" when the chosen clock is the lowest step
|
||||
let floor_point = row.point.clock_mhz > 0 && row.point.clock_mhz <= run.plan.limits.clock_floor();
|
||||
e.sweep_floor = floor_point;
|
||||
c.tune_floor = floor_point;
|
||||
e.sweep_driver = c.driver.clone();
|
||||
e.sweep_class = c.program_class.clone();
|
||||
e.sweep_source = kind.name().into();
|
||||
|
|
@ -3004,6 +3071,7 @@ impl Engine {
|
|||
self.tick_sweep(now);
|
||||
}
|
||||
self.derive(now);
|
||||
self.tick_balance(now);
|
||||
if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) {
|
||||
self.last_status = now;
|
||||
if self.running {
|
||||
|
|
@ -3052,6 +3120,9 @@ impl Engine {
|
|||
let st = self.st();
|
||||
crate::ota::Ctx {
|
||||
node_synced: st.node.synced && st.clock.severity != "block",
|
||||
// Horizon frontier lane: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S
|
||||
// (the last LOCK line's age; or, when none was ever seen this run, the engine's own uptime)
|
||||
finality_paused: st.finality.paused,
|
||||
boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None },
|
||||
// a remote job in progress counts as busy: no update applies under it (src/jobrun.rs)
|
||||
miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting"),
|
||||
|
|
@ -3540,6 +3611,32 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
|
||||
/// Miner UI 4: the payout address's balance through the node's own RPC, every 30 s while the node answers.
|
||||
fn tick_balance(&mut self, now: Instant) {
|
||||
if self.balance_busy || self.balance_next.map(|t| now < t).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let (addr, node_up) = {
|
||||
let st = self.st();
|
||||
(st.address.value.clone(), matches!(st.node.state.as_str(), "syncing" | "synced"))
|
||||
};
|
||||
if addr.len() != 42 || !node_up {
|
||||
self.balance_next = Some(now + Duration::from_secs(10));
|
||||
return;
|
||||
}
|
||||
self.balance_busy = true;
|
||||
self.balance_next = Some(now + Duration::from_secs(30));
|
||||
let shared = self.shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = crate::prover::evm_rpc(&shared, "eth_getBalance", serde_json::json!([addr, "latest"]), Duration::from_secs(8)).and_then(|v| v.as_str().map(|s| s.to_string()).ok_or_else(|| "eth_getBalance: not a string".to_string()));
|
||||
shared.send(Cmd::BalanceRead(r));
|
||||
// the same 30-s cadence carries the node's finality fields (the node lane, 0.3.16); a node without them
|
||||
// answers without the keys and the engine's own rule stands
|
||||
let f = crate::prover::evm_rpc(&shared, "igneum_getProvingStatus", serde_json::json!([]), Duration::from_secs(8)).ok().and_then(|v| crate::ember::parse_finality_status(&v));
|
||||
shared.send(Cmd::FinalityStatus(f));
|
||||
});
|
||||
}
|
||||
|
||||
/// Derived fields: ages, the hash total, the program countdown, the finality age, the mining state word.
|
||||
fn derive(&mut self, now: Instant) {
|
||||
let unix = crate::platform::unix_now_f();
|
||||
|
|
@ -3554,6 +3651,10 @@ impl Engine {
|
|||
c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 };
|
||||
}
|
||||
st.mining.hash_total = total;
|
||||
// Miner UI 4: the fleet's draw and its £ a day (settings.power_price_pence; 0 when no price is set)
|
||||
st.mining.watts_total = crate::ember::fleet_watts(st.mining.cards.iter().map(|c| (c.state.as_str(), c.power_w, c.telemetry_at)), unix, 60.0);
|
||||
st.mining.pounds_per_day = if st.settings.power_price_pence > 0.0 { crate::ember::pounds_per_day(st.mining.watts_total, st.settings.power_price_pence) } else { 0.0 };
|
||||
st.address.balance_age_s = if self.balance_at > 0.0 { unix - self.balance_at } else { -1.0 };
|
||||
crate::hotplug::age(&mut st.mining.cards, unix);
|
||||
let cut = unix - 3600.0;
|
||||
st.mining.found.retain(|t| *t > cut);
|
||||
|
|
@ -3575,6 +3676,22 @@ impl Engine {
|
|||
st.finality.age_s = unix - st.finality.last_lock_at;
|
||||
st.finality.message = String::new();
|
||||
}
|
||||
// Horizon polish Q83/Q84: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S (the
|
||||
// last lock's age, else the engine's uptime); since the last lock (else the start); one sentence everywhere
|
||||
let gap = if st.finality.last_lock > 0 { st.finality.age_s } else { st.uptime_s as f64 };
|
||||
// the node's own word wins when it carries one (0.3.16, finalityActive); else the engine's 15-minute rule
|
||||
let paused = match st.finality.node_active {
|
||||
Some(active) => !active,
|
||||
None => st.node.synced && gap > crate::manifest::FINALITY_PAUSE_S,
|
||||
};
|
||||
if paused && !st.finality.paused && st.finality.cause_source != "node" {
|
||||
st.finality.paused_since = if st.finality.last_lock > 0 { st.finality.last_lock_at } else { unix - st.uptime_s as f64 };
|
||||
}
|
||||
st.finality.paused = paused;
|
||||
st.finality.line = if paused { crate::ember::finality_paused_line(st.finality.paused_since, &st.finality.reason, &st.finality.held_by) } else { String::new() };
|
||||
if paused {
|
||||
st.finality.message = st.finality.line.clone();
|
||||
}
|
||||
if self.running {
|
||||
let mining_now = st.mining.cards.iter().any(|c| c.state == "mining");
|
||||
let any_slot = !self.miners.is_empty();
|
||||
|
|
@ -3980,6 +4097,18 @@ impl Engine {
|
|||
st.finality.age_s = 0.0;
|
||||
}
|
||||
}
|
||||
} else if text.contains("finality_reason=") {
|
||||
// the node lane's pause line (0.3.16): the cause and who holds it, shown in the one sentence
|
||||
if let Some((reason, held_by)) = crate::ember::parse_finality_line(text) {
|
||||
let mut st = self.st();
|
||||
st.finality.reason = reason;
|
||||
st.finality.held_by = held_by;
|
||||
st.finality.cause_source = "node-line".into();
|
||||
}
|
||||
} else if text.contains("finality resumed") {
|
||||
let mut st = self.st();
|
||||
st.finality.reason.clear();
|
||||
st.finality.held_by.clear();
|
||||
} else if text.contains(" VOTE index=") {
|
||||
self.st().finality.votes += 1;
|
||||
} else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,36 @@
|
|||
//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module). The scene wants the network's
|
||||
//! blocks over the last minutes with lanes, parents, colours, the selected chain, checkpoints and proof state. The
|
||||
//! local node speaks gRPC and wRPC only and the engine carries no client for either (no TLS, no websocket stack), so
|
||||
//! this reads the observer's /api/live (the same URL ota.rs already polls for the identity count) through curl, caches
|
||||
//! it for 2 s, and marks this machine's blocks: the observer names a block's miner by the first 8 hex of its vote key
|
||||
//! hash, and every card's `ids` carry the same 8 characters for this machine's keys, so a match becomes the lane "you".
|
||||
//! Read-only; one upstream call per 2 s whatever the window asks. A local-node source is owed (a JSON RPC on igneumd or
|
||||
//! a wRPC client here); the shape stays the same when it comes.
|
||||
//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module) and the Cards tab's network
|
||||
//! numbers. Two sources, one contract:
|
||||
//!
|
||||
//! * the observer's /api/live (the same URL ota.rs polls for the identity count), read through curl, cached 2 s per
|
||||
//! window, with this machine's blocks marked as the lane "you" (`shape`): the scene's full feed (lanes, parents,
|
||||
//! colours, the selected chain, checkpoints, proof state). `state.source` = "observer".
|
||||
//! * the local node's `igneum_getRecentBlocks(seconds)` (igneumd 0.3.17, the node lane's eec34ac3): the last 600 s
|
||||
//! of chain and merged blocks with vote key hashes, blue scores and colours. The engine computes the observer's
|
||||
//! state fields from it (`shape_from_blocks`: miners_10m, blocks_10m, blocks_per_minute) and adds them to every
|
||||
//! observer reply as `state.node`, so the Cards tab reads network numbers that need no site; and when the observer
|
||||
//! is unreachable the node's rows stand in for the scene (`state.source` = "node", `partial: true`: no parents, no
|
||||
//! proof state), instead of `{ok:false}`. A node before 0.3.17 answers -32601 and the node source rests 10 minutes.
|
||||
//!
|
||||
//! Read-only; one observer call per 2 s whatever the window asks; one node call per 5 s at most.
|
||||
use crate::engine::Shared;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(2);
|
||||
|
||||
struct NodeCache {
|
||||
at: Option<Instant>,
|
||||
blocks: Vec<RecentBlock>,
|
||||
/// the node said "method not found" (-32601): rest until then
|
||||
retry: Option<Instant>,
|
||||
}
|
||||
static NODE: Mutex<NodeCache> = Mutex::new(NodeCache { at: None, blocks: Vec::new(), retry: None });
|
||||
const NODE_TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The observer's reply with this machine's lane marked. `ids` are the 8-character vote key ids of this machine's
|
||||
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` names
|
||||
/// where the data came from. Anything else passes through untouched, so the module's contract holds.
|
||||
|
|
@ -25,7 +41,9 @@ pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
|
|||
let mine = b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false);
|
||||
if mine {
|
||||
yours += 1;
|
||||
if let Some(o) = b.as_object_mut() { o.insert("miner".into(), json!("you")); }
|
||||
if let Some(o) = b.as_object_mut() {
|
||||
o.insert("miner".into(), json!("you"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,30 +59,197 @@ pub fn url_for(live_api: &str, window_s: u32) -> String {
|
|||
format!("{live_api}?window={}", window_s.clamp(30, 300))
|
||||
}
|
||||
|
||||
/// The reply for the dashboard: cached 2 s per window; `{ok:false, error}` when the observer is unreachable or the
|
||||
/// build has no observer address (the UI then draws its own blocks strip).
|
||||
pub fn fetch(live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
|
||||
if live_api.is_empty() {
|
||||
return json!({ "ok": false, "error": "no observer address in this build" });
|
||||
/// One block from `igneum_getRecentBlocks`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecentBlock {
|
||||
pub hash: String,
|
||||
pub blue_score: u64,
|
||||
pub daa_score: u64,
|
||||
pub timestamp_ms: u64,
|
||||
pub vote_key_hash: String,
|
||||
pub is_chain_block: bool,
|
||||
pub color: String,
|
||||
/// "header" or "chain_block" (eec34ac3: a merged block whose own header was not at hand carries its merging
|
||||
/// chain block's time and blue score, marked so blocks_per_minute stays honest); "" on an older row = header
|
||||
pub timestamp_source: String,
|
||||
}
|
||||
|
||||
pub fn parse_recent(v: &Value) -> Vec<RecentBlock> {
|
||||
let s = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
let n = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|b| RecentBlock {
|
||||
hash: s(b, "hash"),
|
||||
blue_score: n(b, "blue_score"),
|
||||
daa_score: n(b, "daa_score"),
|
||||
timestamp_ms: n(b, "timestamp_ms"),
|
||||
vote_key_hash: s(b, "vote_key_hash"),
|
||||
is_chain_block: b.get("is_chain_block").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||
color: s(b, "color"),
|
||||
timestamp_source: s(b, "timestamp_source"),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The lane id the UI matches against a card's `ids`: the first 8 hex of the vote key hash (engine.rs gives a card
|
||||
/// its ids the same way, `h.chars().take(8)`); "" when the block carries no key.
|
||||
pub fn lane(vote_key_hash: &str) -> String {
|
||||
vote_key_hash.trim_start_matches("0x").chars().take(8).collect()
|
||||
}
|
||||
|
||||
/// The site's `short`: the first 16 hex (site/api/live.mjs), so a hash reads the same from either source.
|
||||
fn short(h: &str) -> String {
|
||||
h.trim_start_matches("0x").chars().take(16).collect()
|
||||
}
|
||||
|
||||
/// The observer's `state` fields that come from the blocks: distinct vote keys in 10 minutes, blocks in 10
|
||||
/// minutes, blocks per minute over the last 10 minutes (oldest first), and the `blocks` (last `window_s`, in the
|
||||
/// scene's row shape as far as the node knows it: no parents) and `miners` arrays. `now_ms` is the reference time.
|
||||
pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) -> Value {
|
||||
let ten = now_ms.saturating_sub(600_000);
|
||||
let recent: Vec<&RecentBlock> = blocks.iter().filter(|b| b.timestamp_ms >= ten).collect();
|
||||
let mut miners: std::collections::BTreeMap<&str, u64> = Default::default();
|
||||
for b in &recent {
|
||||
if !b.vote_key_hash.is_empty() {
|
||||
*miners.entry(b.vote_key_hash.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let mut per_min = vec![0u64; 10];
|
||||
for b in &recent {
|
||||
let idx = ((b.timestamp_ms - ten) / 60_000).min(9) as usize;
|
||||
per_min[idx] += 1;
|
||||
}
|
||||
let win = now_ms.saturating_sub(window_s * 1000);
|
||||
let rows: Vec<Value> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.timestamp_ms >= win)
|
||||
.map(|b| {
|
||||
json!({
|
||||
"hash": short(&b.hash), "ts": b.timestamp_ms, "timestamp_ms": b.timestamp_ms, "blue_score": b.blue_score, "daa": b.daa_score,
|
||||
"parents": [], "chain": b.is_chain_block, "is_chain_block": b.is_chain_block,
|
||||
"color": if b.color.is_empty() { "pending" } else { b.color.as_str() },
|
||||
"miner": lane(&b.vote_key_hash), "vote_key_hash": short(&b.vote_key_hash),
|
||||
"timestamp_source": if b.timestamp_source.is_empty() { "header" } else { b.timestamp_source.as_str() },
|
||||
"locked": false, "final": false, "shards": [], "proven": false
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"miners_10m": miners.len(),
|
||||
"blocks_10m": recent.len(),
|
||||
"blocks_per_minute": per_min,
|
||||
"blocks": rows,
|
||||
"miners": miners.iter().map(|(k, n)| json!({ "id": lane(k), "vote_key_hash": short(k), "blocks_10m": n })).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The node's recent blocks, cached 5 s; empty when the node is down, carries no such method (-32601, rests 10
|
||||
/// minutes) or answered nothing.
|
||||
fn node_blocks(shared: &Arc<Shared>) -> Vec<RecentBlock> {
|
||||
{
|
||||
let c = NODE.lock().unwrap();
|
||||
if c.at.map(|t| t.elapsed() < NODE_TTL).unwrap_or(false) {
|
||||
return c.blocks.clone();
|
||||
}
|
||||
if c.retry.map(|t| Instant::now() < t).unwrap_or(false) {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
let node_up = matches!(shared.state.lock().unwrap().node.state.as_str(), "syncing" | "synced");
|
||||
if !node_up {
|
||||
return Vec::new();
|
||||
}
|
||||
match crate::prover::evm_rpc(shared, "igneum_getRecentBlocks", json!([600]), Duration::from_secs(6)) {
|
||||
Ok(v) if v.is_array() => {
|
||||
let blocks = parse_recent(&v);
|
||||
let mut c = NODE.lock().unwrap();
|
||||
c.at = Some(Instant::now());
|
||||
c.blocks = blocks.clone();
|
||||
blocks
|
||||
}
|
||||
Ok(_) => Vec::new(),
|
||||
Err(e) => {
|
||||
// the node's default arm: {"code": -32601, "message": "method igneum_getRecentBlocks not found"}
|
||||
if e.contains("not found") || e.contains("-32601") {
|
||||
NODE.lock().unwrap().retry = Some(Instant::now() + Duration::from_secs(600));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The node's `state` fields for the Cards tab (`state.node` on an observer reply, `state` on a node-only one).
|
||||
fn node_state(shared: &Arc<Shared>, blocks: &[RecentBlock], now_ms: u64) -> Value {
|
||||
let mut v = shape_from_blocks(blocks, now_ms, 90);
|
||||
let st = shared.state.lock().unwrap();
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.remove("blocks");
|
||||
o.remove("miners");
|
||||
o.insert("block_count".into(), json!(st.node.blocks));
|
||||
o.insert("header_count".into(), json!(st.node.headers));
|
||||
o.insert("daa".into(), json!(st.node.daa));
|
||||
o.insert("blue_score".into(), json!(st.node.blue));
|
||||
o.insert("difficulty".into(), json!(st.node.difficulty));
|
||||
o.insert("peers".into(), json!(st.node.peers));
|
||||
o.insert("synced".into(), json!(st.node.synced));
|
||||
o.insert("hashes_per_second_estimate".into(), Value::Null);
|
||||
o.insert("source".into(), json!("node"));
|
||||
v
|
||||
}
|
||||
|
||||
/// The reply for the dashboard: the observer's, cached 2 s per window, with the node's state fields added as
|
||||
/// `state.node` when the node answers; the node's rows alone (`partial: true`) when the observer is unreachable;
|
||||
/// `{ok:false, error}` when neither answers (the UI then draws its own blocks strip).
|
||||
pub fn fetch(shared: &Arc<Shared>, live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
|
||||
let window_s = window_s.clamp(30, 300);
|
||||
if let Some((at, w, v)) = CACHE.lock().unwrap().as_ref() {
|
||||
if *w == window_s && at.elapsed() < TTL { return v.clone(); }
|
||||
}
|
||||
let url = url_for(live_api, window_s);
|
||||
let out = crate::detect::run_timeout(
|
||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]),
|
||||
None,
|
||||
Duration::from_secs(6),
|
||||
);
|
||||
let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
|
||||
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids),
|
||||
_ => json!({ "ok": false, "error": "the observer did not answer" }),
|
||||
let now_ms = (crate::platform::unix_now_f() * 1000.0) as u64;
|
||||
let blocks = node_blocks(shared);
|
||||
let node = if blocks.is_empty() { None } else { Some(node_state(shared, &blocks, now_ms)) };
|
||||
let cached = CACHE.lock().unwrap().as_ref().and_then(|(at, w, v)| if *w == window_s && at.elapsed() < TTL { Some(v.clone()) } else { None });
|
||||
let mut reply = match cached {
|
||||
Some(v) => v,
|
||||
None if live_api.is_empty() => json!({ "ok": false, "error": "no observer address in this build" }),
|
||||
None => {
|
||||
let url = url_for(live_api, window_s);
|
||||
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]), None, Duration::from_secs(6));
|
||||
let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
|
||||
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids),
|
||||
_ => json!({ "ok": false, "error": "the observer did not answer" }),
|
||||
};
|
||||
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
|
||||
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone()));
|
||||
}
|
||||
reply
|
||||
}
|
||||
};
|
||||
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
|
||||
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone()));
|
||||
let observer_ok = reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false);
|
||||
match (observer_ok, node) {
|
||||
(true, Some(n)) => {
|
||||
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
|
||||
st.insert("node".into(), n);
|
||||
}
|
||||
reply
|
||||
}
|
||||
(true, None) => reply,
|
||||
(false, Some(n)) => {
|
||||
// the node's rows stand in: the scene gets blocks without parents or proof state, and says so
|
||||
let mut v = shape_from_blocks(&blocks, now_ms, window_s as u64);
|
||||
let o = v.as_object_mut().unwrap();
|
||||
let rows = o.remove("blocks").unwrap_or(Value::Null);
|
||||
let miners = o.remove("miners").unwrap_or(Value::Null);
|
||||
let mut out = json!({ "ok": true, "partial": true, "now": crate::platform::unix_now_f(), "state": n, "blocks": rows, "miners": miners, "events": [], "finality": { "checkpoints": [] } });
|
||||
out = shape(out, ids);
|
||||
if let Some(st) = out.get_mut("state").and_then(|s| s.as_object_mut()) {
|
||||
st.insert("source".into(), json!("node"));
|
||||
}
|
||||
out
|
||||
}
|
||||
(false, None) => reply,
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -131,10 +316,51 @@ mod tests {
|
|||
assert_eq!(url_for("https://igneum.network/api/live", 9000), "https://igneum.network/api/live?window=300");
|
||||
}
|
||||
|
||||
fn b(ts: u64, key: &str, chain: bool) -> RecentBlock {
|
||||
RecentBlock { hash: format!("0x{:064x}", ts), blue_score: ts / 1000, daa_score: ts / 1000, timestamp_ms: ts, vote_key_hash: key.into(), is_chain_block: chain, color: if chain { "blue".into() } else { String::new() }, timestamp_source: String::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_build_without_an_observer_address_says_so() {
|
||||
let out = fetch("", 120, &HashSet::new());
|
||||
assert_eq!(out["ok"], false);
|
||||
assert_eq!(out["error"], "no observer address in this build");
|
||||
fn the_state_fields_come_from_the_nodes_blocks_of_the_last_ten_minutes() {
|
||||
let now = 1_791_300_000_000u64;
|
||||
let blocks = vec![b(now - 5_000, "aa", true), b(now - 30_000, "bb", true), b(now - 95_000, "aa", false), b(now - 500_000, "cc", true), b(now - 700_000, "dd", true)];
|
||||
let v = shape_from_blocks(&blocks, now, 120);
|
||||
assert_eq!(v["miners_10m"], 3, "dd is older than 10 minutes");
|
||||
assert_eq!(v["blocks_10m"], 4);
|
||||
let pm = v["blocks_per_minute"].as_array().unwrap();
|
||||
assert_eq!(pm.len(), 10);
|
||||
assert_eq!(pm[9], 2, "the newest minute holds the 5 s and 30 s blocks");
|
||||
assert_eq!(pm[8], 1, "the 95 s block");
|
||||
assert_eq!(pm[1], 1, "the 500 s block");
|
||||
assert_eq!(v["blocks"].as_array().unwrap().len(), 3, "the 120 s window");
|
||||
assert_eq!(v["blocks"][2]["color"], "pending", "an unmerged block without a colour");
|
||||
assert_eq!(v["blocks"][0]["color"], "blue");
|
||||
assert_eq!(v["blocks"][0]["timestamp_source"], "header");
|
||||
assert_eq!(v["blocks"][0]["miner"], "aa", "the lane id is the first 8 hex of the vote key hash, as a card's ids");
|
||||
assert_eq!(v["blocks"][0]["chain"], true, "the scene's row shape");
|
||||
assert_eq!(v["blocks"][0]["parents"], json!([]), "the node method carries no parents");
|
||||
assert_eq!(v["miners"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(v["miners"][0]["id"], "aa", "the miners rows carry id, as the site's");
|
||||
// the lane "you" marks node rows too
|
||||
let ids: HashSet<String> = ["aa".to_string()].into_iter().collect();
|
||||
let marked = shape(json!({ "ok": true, "state": {}, "blocks": v["blocks"] }), &ids);
|
||||
assert_eq!(marked["blocks"][0]["miner"], "you");
|
||||
assert_eq!(marked["state"]["you_blocks"], 2);
|
||||
assert_eq!(lane("0x0123456789abcdef"), "01234567");
|
||||
assert_eq!(lane(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_blocks_parse_from_the_node_reply_and_an_empty_reply_is_empty() {
|
||||
let v: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"cd","is_chain_block":true,"color":"blue"},{"hash":"0xcd","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"ef","is_chain_block":false,"color":"red","timestamp_source":"chain_block"}]"#).unwrap();
|
||||
let p = parse_recent(&v);
|
||||
assert_eq!(p.len(), 2);
|
||||
assert_eq!((p[0].blue_score, p[0].is_chain_block, p[0].color.as_str()), (5, true, "blue"));
|
||||
assert_eq!((p[1].color.as_str(), p[1].timestamp_source.as_str()), ("red", "chain_block"));
|
||||
// a null vote_key_hash (a chain block with no mergeset entry, which the executor never produces) reads as ""
|
||||
let n: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":null,"is_chain_block":true,"color":"blue","timestamp_source":"header"}]"#).unwrap();
|
||||
assert_eq!(parse_recent(&n)[0].vote_key_hash, "");
|
||||
assert!(parse_recent(&json!(null)).is_empty());
|
||||
assert_eq!(short("0x1234567890abcdef00"), "1234567890abcdef", "the site's 16-hex short");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ pub struct Manifest {
|
|||
pub min_supported_version: String,
|
||||
pub notes: String,
|
||||
pub activation_height: Option<u64>,
|
||||
/// Horizon frontier lane (6 October 2026): the publisher's word that this version installs even while the
|
||||
/// network's finality is paused (nothing else does); false unless the signed manifest says so
|
||||
pub urgent: bool,
|
||||
pub deadline_note: String,
|
||||
/// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's
|
||||
/// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest.
|
||||
|
|
@ -157,6 +160,8 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
|
|||
min_supported_version: s(&v, "min_supported_version"),
|
||||
notes: s(&v, "notes"),
|
||||
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()),
|
||||
// the manifest's TOP-LEVEL "urgent": true (the publisher sets it; igneum-ota-sign passes the document through)
|
||||
urgent: v.get("urgent").and_then(|u| u.as_bool()).unwrap_or(false),
|
||||
deadline_note: s(&consensus, "deadline_note"),
|
||||
override_params: match consensus.get("override") {
|
||||
Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()),
|
||||
|
|
@ -312,8 +317,16 @@ pub struct Moment {
|
|||
pub slot_ok: bool,
|
||||
/// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent.
|
||||
pub network_drop_pct: f64,
|
||||
/// Horizon frontier lane (6 October 2026): the network's finality is paused (the node is synced and no checkpoint
|
||||
/// has locked for FINALITY_PAUSE_S); a rollout never lands on a chain that cannot lock
|
||||
pub finality_paused: bool,
|
||||
/// the signed manifest's own urgent flag: the one thing that installs while finality is paused
|
||||
pub manifest_urgent: bool,
|
||||
}
|
||||
|
||||
/// No checkpoint lock for this long on a synced node = finality paused (the devnet locks every few minutes).
|
||||
pub const FINALITY_PAUSE_S: f64 = 15.0 * 60.0;
|
||||
|
||||
/// The percentage of identities the network may lose in 10 minutes before updates hold (we are the devnet).
|
||||
pub const NETWORK_DROP_HOLD_PCT: f64 = 30.0;
|
||||
|
||||
|
|
@ -324,6 +337,10 @@ pub fn slot_minute(id8: &str) -> u64 {
|
|||
|
||||
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
|
||||
pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
|
||||
// the finality rule comes first: a fork-close or unsupported urgency does not pass it, only the manifest's flag
|
||||
if m.finality_paused && !m.manifest_urgent {
|
||||
return Err("waiting for finality: the network has not locked a checkpoint for 15 min; nothing installs on a chain that cannot lock".into());
|
||||
}
|
||||
if m.job_active {
|
||||
return Err("a remote job is running; installing when it closes".into());
|
||||
}
|
||||
|
|
@ -356,7 +373,10 @@ pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
|
|||
/// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score).
|
||||
pub fn fork_is_close(activation_height: Option<u64>, daa: u64) -> bool {
|
||||
match activation_height {
|
||||
Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
|
||||
// Horizon polish Q4 (6 October 2026): an activation at or below the DAA has PASSED, nothing is pending; the
|
||||
// old rule read it as close, so every update since the 0.3.14 manifest said "0 blocks away, installing now",
|
||||
// stripped Later and skipped every safe-moment guard (PC 1's 17:52:54Z install under a job came through it)
|
||||
Some(h) if daa > 0 && h > daa => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -499,8 +519,15 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn safe_moments() {
|
||||
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
|
||||
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false };
|
||||
assert!(safe_to_apply(&base).is_ok());
|
||||
// the finality rule (Horizon frontier lane, 6 October 2026), the known-failed case: paused, no install; active, install;
|
||||
// paused with the manifest's urgent flag, install; paused with only a fork-close urgency, no install; patience never passes it
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, ..base.clone() }).unwrap_err().starts_with("waiting for finality"));
|
||||
assert!(safe_to_apply(&Moment { finality_paused: false, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, manifest_urgent: true, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, urgent: true, ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
|
||||
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err());
|
||||
|
|
@ -543,8 +570,11 @@ mod tests {
|
|||
assert!(!fork_is_close(Some(120_000), 0));
|
||||
assert!(!fork_is_close(Some(120_000), 118_199));
|
||||
assert!(fork_is_close(Some(120_000), 118_200));
|
||||
assert!(fork_is_close(Some(120_000), 120_000));
|
||||
assert!(fork_is_close(Some(120_000), 130_000));
|
||||
assert!(fork_is_close(Some(120_000), 119_999), "one block before the activation");
|
||||
// a passed activation is not close (Horizon polish Q4): at the height and after it, nothing is pending
|
||||
assert!(!fork_is_close(Some(120_000), 120_000));
|
||||
assert!(!fork_is_close(Some(120_000), 130_000));
|
||||
assert!(!fork_is_close(Some(33_000), 201_776), "the 0.3.14 manifest's case: difficulty v2 at 33,000 against PC 1's DAA");
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert!(!unsupported(&m, "0.3.0"));
|
||||
assert!(unsupported(&m, "0.2.9"));
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ pub enum Launch {
|
|||
|
||||
pub struct Ctx {
|
||||
pub node_synced: bool,
|
||||
/// the network's finality is paused (engine: synced and no lock for manifest::FINALITY_PAUSE_S)
|
||||
pub finality_paused: bool,
|
||||
pub boundary_eta_s: Option<i64>,
|
||||
pub miner_busy: bool,
|
||||
/// A remote job is running (src/jobrun.rs): the install holds, urgent or not.
|
||||
|
|
@ -541,7 +543,8 @@ impl Updater {
|
|||
}
|
||||
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
|
||||
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
|
||||
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
|
||||
let manifest_urgent = self.manifest.as_ref().map(|m| m.urgent).unwrap_or(false);
|
||||
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct , finality_paused: ctx.finality_paused, manifest_urgent };
|
||||
if !self.auto && !urgent && !self.install_asked {
|
||||
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ fn exec_boundary(shared: &Shared) -> u64 {
|
|||
text.strip_prefix("restart at chain block ").and_then(|t| t.split_whitespace().next()).and_then(|t| t.parse().ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
||||
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
|
||||
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
|||
let window = query_param(&req.query, "window").and_then(|s| s.parse().ok()).unwrap_or(120u32);
|
||||
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
|
||||
let ids: std::collections::HashSet<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
|
||||
json_resp(&mut stream, 200, crate::live::fetch(&live_api, window, &ids));
|
||||
json_resp(&mut stream, 200, crate::live::fetch(&shared, &live_api, window, &ids));
|
||||
}
|
||||
("GET", "/api/log") => {
|
||||
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ pub struct CardState {
|
|||
pub tune_goal: String, // this card's goal override (efficiency | balanced | rate); "" = the global goal
|
||||
pub tune_before_watts: f64, // the untuned point of the last full plan (step 0); 0 = never measured
|
||||
pub tune_before_mhs: f64,
|
||||
pub tune_floor: bool, // the chosen clock is the ladder's floor (the row's sub-line says so)
|
||||
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
|
||||
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
|
||||
pub tune_step: u32,
|
||||
|
|
@ -166,6 +167,10 @@ pub struct MiningState {
|
|||
/// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime
|
||||
pub fee_session: u64,
|
||||
pub fee_total: u64,
|
||||
/// Miner UI 4 (6 October 2026): the sum of the mining cards' draw (a reading under 60 s old), and that draw as
|
||||
/// £ a day at settings.power_price_pence (0 when no price is set)
|
||||
pub watts_total: f64,
|
||||
pub pounds_per_day: f64,
|
||||
}
|
||||
|
||||
/// The miner software's dev fee as the miner reports it at start (`dev fee 1% (1 block in 100) to 0x...`).
|
||||
|
|
@ -252,6 +257,23 @@ pub struct FinalityState {
|
|||
pub age_s: f64,
|
||||
pub votes: u64,
|
||||
pub message: String,
|
||||
/// Horizon polish Q83/Q84 (6 October 2026): the network's finality is paused (a synced node, no checkpoint lock
|
||||
/// for manifest::FINALITY_PAUSE_S); since when (the last lock's time, else the engine's start); the cause when
|
||||
/// the node carries it (its `finality_reason=` / `held_by=` line), else the plain two-thirds line; and the one
|
||||
/// sentence every surface shows: "Finality paused since 18:39 UTC: under two thirds of the weight is signing"
|
||||
pub paused: bool,
|
||||
pub paused_since: f64,
|
||||
pub reason: String,
|
||||
pub held_by: String,
|
||||
pub line: String,
|
||||
/// the node's word (igneum_getProvingStatus finalityProvisional, 0.3.16): locks are provisional right now
|
||||
pub provisional: bool,
|
||||
/// where the cause came from: "node" (the RPC's structured fields), "node-line" (its log line) or "" (the
|
||||
/// engine's own rule)
|
||||
pub cause_source: String,
|
||||
/// the node's own finalityActive (b2e21447), when it carries it: then the node decides `paused`, not the
|
||||
/// engine's 15-minute rule
|
||||
pub node_active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind).
|
||||
|
|
@ -274,6 +296,15 @@ pub struct AddressState {
|
|||
pub source: String,
|
||||
pub key_saved: bool,
|
||||
pub wallet_file: String,
|
||||
/// Miner UI 4: the payout address's balance in wei as a decimal string (eth_getBalance through the node's own
|
||||
/// RPC, read every 30 s while the node runs); null until the first read; balance_age_s = -1 until then
|
||||
pub balance_wei: Option<String>,
|
||||
pub balance_age_s: f64,
|
||||
pub balance_note: String, // the last read's error, in words; "" when the last read was good
|
||||
/// £ per IGN. null until a market exists. Its one source will be a SIGNED field of the OTA manifest (`price`:
|
||||
/// gbp_per_ign, as_of, source), checked like the manifest's tuning object; the app never computes or fetches a
|
||||
/// price on its own
|
||||
pub price_gbp_per_ign: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
|
|
|
|||
|
|
@ -2661,3 +2661,11 @@ every segment record from the shipped host was refused while 8 of 8 shards passe
|
|||
and proving_aggregator_id are the statement's ids when set, so the env is not needed. Consequence per tier: a node
|
||||
without a host (a pool hand, a seed, a home node that does not prove) must carry the ids in its object or its env, or
|
||||
it refuses every segment record and pays no aggregator; the 0.3.17 object carries them.
|
||||
## 6 October 2026, 22:16Z: the shipped 0.3.16 app asked to tune itself on PC 1 (ember-installed-pc1-2): A to E held, no climb (cause F)
|
||||
|
||||
Gate ok (0.3.16, Power control on, task on the installed exe, Running at the end), prompts 0, the helper up once per
|
||||
NVIDIA card. RX 9070 XT measured as it runs: 19.2 MH/s at 202 W (0.095 MH/W), no set sent (measure only: the probe
|
||||
gave no tune line; open). RTX 5090 and 4070 climbs stopped at request 1: "the helper did not run sequence 1 within
|
||||
15 s"; cmd.txt held "00 dev 1 / 01 pl 160 / 02 rgc / 03 rmc", two-digit wire numbers below the cap path's unix-based
|
||||
ones, so the helper skipped them as stale (cause F, fixed cbd4d51 for 0.3.17). Draws unchanged: 5090 208 W at
|
||||
1845 MHz, 4070 75.5 W at 1860 MHz, 9070 XT 203 W.
|
||||
|
|
|
|||
|
|
@ -312,15 +312,53 @@ The shipped 0.3.14 app, Power control on, the task registered, asked over its ow
|
|||
What a user on 0.3.14 sees: Power control on, no prompt, and no card tunes (the rows keep the baseline or
|
||||
"tuning stopped"); the lever-2 TUNING records still flow. 0.3.15 closes it; the window repeats there.
|
||||
|
||||
### The 0.3.16 window (6 October 2026, 22:16 to 22:25Z): A to E held; a sixth cause (F) stopped the climbs
|
||||
|
||||
`ember-installed-pc1-2`, unelevated, the installed 0.3.16 app (the branch at 5429e82) tuning itself through the task.
|
||||
Gate ok; prompts 0; the helper started once per NVIDIA card and stayed up (task state Running at the end).
|
||||
|
||||
| Card | Plan | Result |
|
||||
|---|---|---|
|
||||
| RX 9070 XT | baseline, measure (the probe gave no tune line again) | "Measured: 19.2 MH/s at 202 W (0.095 MH/W)", gclk 3298, mclk 2505, 62 C, ok; no set sent (D held). Open: why the installed app's probe gets no tune line when run 6's kit engine did |
|
||||
| RTX 5090 | climb, 5 probes, floor 1390 MHz, mode helper | request 1 "the helper did not run sequence 1 within 15 s (no line in helper.log)"; 211.5 W before, 208 W after, unchanged |
|
||||
| RTX 4070 | climb | the same; 74.8 W before, 75.5 W after |
|
||||
|
||||
Cause F (cmd.txt read back: `00 dev 1 / 01 pl 160 / 02 rgc / 03 rmc`): the tune path wrote its request index as the
|
||||
wire sequence while the cap path had written unix-based numbers earlier in the same helper session; the helper runs
|
||||
only numbers above every one it has seen, so every tune command read as stale. The honest acknowledgement (B) said
|
||||
so within 15 s instead of a blind yes. Fix (cbd4d51, 0.3.17): one monotonic wire space for every writer
|
||||
(`powertask::wire_seq`, the unix time modulo 999,990), the acknowledgement keyed to the wire number; test. The
|
||||
elevated runs never crossed F (they set limits directly), which is why run 6 tuned and the self-tune did not.
|
||||
|
||||
## 8a. Next-cut notes (for the 0.3.12 shipper)
|
||||
|
||||
For 0.3.16 (Miner UI 4's Cards tab, Ember as its second layer), on ember-tune past the 0.3.15 tip 7fadd4c:
|
||||
Correction (6 October 2026, 19:0xZ): release-0.3.15 took ember-tune at 5429e82 (merge 886c075), because Miner UI 4's
|
||||
Cards tab ships in 0.3.15 and reads the per-card goal and the before fields; so the row below is IN 0.3.15 except
|
||||
`card.tune_floor` (995530b), which rides 0.3.17.
|
||||
|
||||
Numbering (the shipper, 6 October 2026, 21:xx UK): 0.3.16 is a corrective cut of 0.3.15 under a new number (the Mac and PC 1 had taken earlier 0.3.15 builds), so everything below written as "0.3.16" ships as 0.3.17; the 0.3.15/0.3.16 content is the branch at 5429e82.
|
||||
|
||||
For 0.3.17 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, on ember-tune past 5429e82:
|
||||
|
||||
| Field | What | Where |
|
||||
|---|---|---|
|
||||
| `state.mining.watts_total` | the sum of the mining cards' draw (a reading under 60 s old) | `ember::fleet_watts`, engine `derive` + test |
|
||||
| `state.mining.pounds_per_day` | that draw as £ a day at `settings.power_price_pence` (0 when no price) | `ember::pounds_per_day` |
|
||||
| `state.address.balance_wei` | the payout address's balance in wei as a decimal string, `eth_getBalance` through the node's own RPC every 30 s while the node runs (60 s after a failure); null until read; `balance_age_s` (-1 until then), `balance_note` (the last error in words) | engine `tick_balance`, `Cmd::BalanceRead`, `ember::wei_from_hex` + test |
|
||||
| `state.address.price_gbp_per_ign` | null. Its one source will be a SIGNED field of the OTA manifest (`price`: gbp_per_ign, as_of, source), checked like the tuning object; the app never computes or fetches a price itself | state.rs (documented), no code until a market exists |
|
||||
| (F) the helper's wire sequence | one monotonic space for every writer of cmd.txt (`powertask::wire_seq`); the tune path's two-digit request index read as stale beside the cap path's unix-based numbers, so the 0.3.16 self-tune's climbs stopped at their first request | powertask.rs, engine.rs + test |
|
||||
| Horizon polish Q4 (updater) | `fork_is_close`: an activation height at or below the DAA has passed, nothing is pending (the old rule read it as close: every update since the 0.3.14 manifest said "0 blocks away, installing now", stripped Later and skipped every guard; PC 1's 17:52:54Z install under a job came through it); `publish-manifest.sh` refuses an activation height at or below the live DAA (/api/live state.daa) unless `--allow-passed-activation` | manifest.rs + test, packaging/ota/publish-manifest.sh |
|
||||
| Horizon polish Q83/Q84/Q2 (the pause shown) | `state.finality.paused`, `paused_since` (the last lock's time, else the engine's start), `reason`, `held_by`, `line` = "Finality paused since 18:39 UTC: under two thirds of the weight is signing" (the node's cause when it carries one: the engine parses a node log line carrying `finality_reason=<words or "quoted words"> held_by=<id>`, the node lane's to emit); the node line, the Overview's state and the Finality card show the one sentence while paused, the Finality card's age reads "paused", and no surface calls a lock final; `finality.message` carries the sentence too so older UIs show it | ember.rs `finality_paused_line`, `parse_finality_line`, `hhmm_utc` + tests; engine.rs derive and the node-line parse; ui/app.js `View.finalityWords` + test |
|
||||
| the finality rule (updater) | Horizon frontier lane: the updater installs nothing while the network's finality is paused (a synced node with no checkpoint lock for `manifest::FINALITY_PAUSE_S` = 15 min; the last LOCK line's age, else the engine's uptime); the update card reads "waiting for finality: ..."; slot, catch-up and patience rules unchanged otherwise; only the signed manifest's own `urgent` flag installs through a pause (a fork-close or unsupported urgency does not); the known-failed case is the test | manifest.rs `Moment.finality_paused`, `manifest_urgent`, `Manifest.urgent`, `safe_to_apply` + test; ota.rs `Ctx`; engine.rs |
|
||||
| `GET /api/live` | the observer's reply shape from a local source: `"source": "node"` when igneumd carries `igneum_getRecentBlocks(seconds)` (the node lane, a283f5f0d364ceef0; the engine computes miners_10m, blocks_10m, blocks_per_minute, the 90 s blocks and the miners list from it and takes the DAG numbers from its node state), else `"source": "site"` (the public reply, fetched by curl, cached 60 s) with `age_s`; `pending: true` before the first fetch | src/live.rs (`shape_from_blocks`, `parse_recent` + tests), server.rs |
|
||||
|
||||
For Miner UI 4's Cards tab (Ember as its second layer), 0.3.17, on ember-tune:
|
||||
|
||||
| Commit | What | Where |
|
||||
|---|---|---|
|
||||
| (past 7fadd4c) | a per-card goal: `POST /api/tune/goal {key, goal}` (efficiency, balanced, rate; "" or "global" clears), persisted as `CardPref.tune_goal`, echoed as `card.tune_goal` ("" = the global goal), resolved at the tune's start (`ember::goal_for`); the untuned point of the last full plan on the row: `card.tune_before_watts`, `card.tune_before_mhs` (its step 0, kept across confirm plans; `CardPref.sweep_before_*`), so the row can read "saves 84 W, 0.15% of rate" against `sweep_watts` / `sweep_mhs`; Retune = `POST /api/sweep/start {key}` (forced: clears a back-off since 0.3.15) | ember.rs, engine.rs, server.rs, config.rs, state.rs, hotplug.rs + test |
|
||||
| 5429e82 (in 0.3.15), 995530b (`tune_floor`, next cut) | a per-card goal: `POST /api/tune/goal {key, goal}` (efficiency, balanced, rate; "" or "global" clears), persisted as `CardPref.tune_goal`, echoed as `card.tune_goal` ("" = the global goal), resolved at the tune's start (`ember::goal_for`); `card.tune_floor` (the chosen clock is the ladder's floor; `CardPref.sweep_floor`); the untuned point of the last full plan on the row: `card.tune_before_watts`, `card.tune_before_mhs` (its step 0, kept across confirm plans; `CardPref.sweep_before_*`), so the row can read "saves 84 W, 0.15% of rate" against `sweep_watts` / `sweep_mhs`; Retune = `POST /api/sweep/start {key}` (forced: clears a back-off since 0.3.15) | ember.rs, engine.rs, server.rs, config.rs, state.rs, hotplug.rs + test |
|
||||
|
||||
For the 0.3.15 cut (6 October 2026 evening), on ember-tune:
|
||||
For the 0.3.15 cut (6 October 2026 evening), on ember-tune (taken at 5429e82, see the correction above):
|
||||
|
||||
| Commit | What | Where |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
# packaging/ota/publish-manifest.sh --version 0.3.1 --mac packaging/mac/dist/Igneum-Miner-0.3.1.dmg \
|
||||
# [--win packaging/windows/dist/Igneum-Miner-Setup-0.3.1.exe] --notes "one line of what changed" \
|
||||
# [--activation-height 120000 --deadline-note "difficulty v2"] [--min-supported 0.3.0] [--channel devnet] [--deploy]
|
||||
# [--allow-passed-activation] (Horizon polish Q4: an activation height at or below the live DAA is refused otherwise)
|
||||
# --self-test-height proves the height check on known values (33000 against 201776 refused; 300000 passes) and exits
|
||||
# [--override '{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":120000}']
|
||||
# consensus.override: the exact object every app writes to its override.json (the node's
|
||||
# --override-params-file), so it carries EVERY height switch, not just the new one;
|
||||
|
|
@ -39,26 +41,6 @@ TOKEN_FILE="$HOME/.config/igneum/dl-token"
|
|||
SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign"
|
||||
|
||||
VERSION="" MAC="" WIN="" NOTES="" ACTIVATION="" DEADLINE="" MIN_SUPPORTED="" CHANNEL="devnet" BASE="" DEST="" DEPLOY=0 VERIFY_ONLY=0 TRIES=12
|
||||
|
||||
# An activation height at or below the live DAA makes every app treat the update as urgent (manifest::fork_is_close counts
|
||||
# any height already passed as "close"), which skips the slot, the patience and the busy rule; PC 1 took an install under a
|
||||
# running job on 6 October 2026 on that path. The live DAA comes from the observer's exec status (igneum_getExecStatus,
|
||||
# executedTipDaa) at 127.0.0.1:26790; a height at or below it is refused. IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 overrides for a
|
||||
# deliberate replay. `--self-test-height` exercises the rule on a known-bad and a known-good value against DAA 1000.
|
||||
height_rule() { # <height> <live daa> -> 0 ok, 1 refused
|
||||
local h="$1" daa="$2"
|
||||
[ -z "$h" ] && return 0
|
||||
[[ "$h" =~ ^[0-9]+$ ]] || { echo "activation height $h is not a number" >&2; return 1; }
|
||||
if [ "$h" -le "$daa" ]; then echo "activation height $h is at or below the live DAA $daa: every app would treat the update as urgent (fork_is_close); refused (IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 overrides a deliberate replay)" >&2; return 1; fi
|
||||
return 0
|
||||
}
|
||||
if [ "${1:-}" = "--self-test-height" ]; then
|
||||
height_rule 900 1000 2>/dev/null && { echo "self-test failed: a passed height was accepted"; exit 1; }
|
||||
height_rule 1000 1000 2>/dev/null && { echo "self-test failed: the live height was accepted"; exit 1; }
|
||||
height_rule 1001 1000 || { echo "self-test failed: a future height was refused"; exit 1; }
|
||||
height_rule "" 1000 || { echo "self-test failed: no height was refused"; exit 1; }
|
||||
echo "self-test passed: a height at or below the live DAA is refused, a future one and none pass"; exit 0
|
||||
fi
|
||||
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
|
@ -67,6 +49,8 @@ while [ $# -gt 0 ]; do
|
|||
--win) WIN="$2"; shift 2 ;;
|
||||
--notes) NOTES="$2"; shift 2 ;;
|
||||
--activation-height) ACTIVATION="$2"; shift 2 ;;
|
||||
--allow-passed-activation) ALLOW_PASSED=1; shift ;;
|
||||
--self-test-height) SELF_TEST_HEIGHT=1; shift ;;
|
||||
--deadline-note) DEADLINE="$2"; shift 2 ;;
|
||||
--override) OVERRIDE="$2"; shift 2 ;; # consensus.override: the exact JSON object every app writes to its override.json (all height switches, not just the new one)
|
||||
--min-supported) MIN_SUPPORTED="$2"; shift 2 ;;
|
||||
|
|
@ -83,12 +67,23 @@ while [ $# -gt 0 ]; do
|
|||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -n "$ACTIVATION" ] && [ "${IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT:-0}" != "1" ]; then
|
||||
LIVE_DAA="$(curl -s -m 5 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"igneum_getExecStatus","params":[]}' http://127.0.0.1:26790 2>/dev/null | python3 -c 'import json,sys; print(int(json.load(sys.stdin)["result"]["executedTipDaa"],16))' 2>/dev/null || true)"
|
||||
[ -n "$LIVE_DAA" ] || { echo "cannot read the live DAA from the observer (127.0.0.1:26790) to check --activation-height $ACTIVATION; start it or set IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 knowingly" >&2; exit 1; }
|
||||
height_rule "$ACTIVATION" "$LIVE_DAA" || exit 1
|
||||
echo "activation height $ACTIVATION is above the live DAA $LIVE_DAA ($((ACTIVATION - LIVE_DAA)) ahead)"
|
||||
# Horizon polish Q4 (6 October 2026): has an activation height passed? 0 = pending (above the DAA), 1 = passed (at or
|
||||
# below it), 2 = unknown (no DAA). A passed activation made every app since the 0.3.14 manifest read the fork as
|
||||
# close ("0 blocks away, installing now", every safe-moment guard skipped).
|
||||
activation_passed() { # <height> <live daa>
|
||||
local h="$1" daa="$2"
|
||||
[ "${daa:-0}" -gt 0 ] || return 2
|
||||
[ "$h" -le "$daa" ] && return 1 || return 0
|
||||
}
|
||||
if [ "${SELF_TEST_HEIGHT:-0}" = 1 ]; then
|
||||
# (set -e: a non-zero return is captured with `|| v=$?`, never left bare)
|
||||
fail=0; v=0
|
||||
v=0; activation_passed 33000 201776 || v=$?; [ "$v" -eq 1 ] || { echo "self-test: 33000 against 201776 should read as passed (got $v)" >&2; fail=1; }
|
||||
v=0; activation_passed 201776 201776 || v=$?; [ "$v" -eq 1 ] || { echo "self-test: a height at the DAA should read as passed (got $v)" >&2; fail=1; }
|
||||
v=0; activation_passed 300000 201776 || v=$?; [ "$v" -eq 0 ] || { echo "self-test: 300000 against 201776 should read as pending (got $v)" >&2; fail=1; }
|
||||
v=0; activation_passed 300000 0 || v=$?; [ "$v" -eq 2 ] || { echo "self-test: no DAA should read as unknown (got $v)" >&2; fail=1; }
|
||||
[ $fail -eq 0 ] && echo "self-test: the activation height check holds (passed, at the DAA, pending, unknown)"
|
||||
exit $fail
|
||||
fi
|
||||
[ -n "$VERSION" ] || [ "$VERIFY_ONLY" = 1 ] || { echo "--version is required" >&2; exit 2; }
|
||||
[ -n "$VERSION" ] || VERSION="(the folder's)"
|
||||
|
|
@ -200,6 +195,24 @@ fi
|
|||
|
||||
# canonical JSON: sorted keys, no whitespace; the signature is over these exact bytes
|
||||
NEW="$DEST/igneum-app-latest.json.new"
|
||||
# Horizon polish Q4 (6 October 2026): an activation height that has already passed makes every app read the fork as
|
||||
# close (0.3.14's manifest carried difficulty v2 at 33,000 against a DAA over 200,000: "0 blocks away, installing
|
||||
# now" on every update, every safe-moment guard skipped). The live DAA comes from the public /api/live; a height at or
|
||||
# below it is refused unless --allow-passed-activation says so.
|
||||
if [ -n "$ACTIVATION" ]; then
|
||||
LIVE_DAA="$(curl -fsS --max-time 10 "${IGNEUM_LIVE_API:-https://igneum.network/api/live}" 2>/dev/null | python3 -c 'import json,sys; print(int((json.load(sys.stdin).get("state") or {}).get("daa") or 0))' 2>/dev/null || echo 0)"
|
||||
verdict=0; activation_passed "$ACTIVATION" "${LIVE_DAA:-0}" || verdict=$?
|
||||
if [ "$verdict" -eq 1 ]; then
|
||||
if [ "${ALLOW_PASSED:-0}" = 1 ]; then
|
||||
echo "activation height $ACTIVATION is at or below the live DAA $LIVE_DAA (passed); published anyway on --allow-passed-activation" >&2
|
||||
else
|
||||
echo "refused: activation height $ACTIVATION is at or below the live DAA $LIVE_DAA, so it has passed; a passed activation makes every app read the fork as close (0.3.14 manifest). Drop --activation-height or pass --allow-passed-activation" >&2
|
||||
exit 2
|
||||
fi
|
||||
elif [ "$verdict" -eq 2 ]; then
|
||||
echo "note: the live DAA could not be read from /api/live; the activation height $ACTIVATION is not checked against it" >&2
|
||||
fi
|
||||
fi
|
||||
python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" <<'PY'
|
||||
import json, sys, datetime
|
||||
out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning = sys.argv[1:12]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue