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] 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]