From 50a08e8d50c36fe028c08f5cb95f7707a718a534 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:13:51 +0000 Subject: [PATCH] app: an update published over an hour before the engine started skips the hourly rollout slot PC 1, 6 October 2026 06:58:47Z: the app came up after the 0.3.11 publish, had the installer verified 44 s later and sat on "installs at the next safe moment" with its slot at minute 35; the project lead pressed Install now at 07:00:48Z. The slot staggers a fleet through a NEW publish; a machine that was off through the publish has nothing to stagger. Now: published_at + 3,600 s <= engine start => slot_ok, logged once. The other safe-moment guards (node synced, miner idle, boundary, network drop) are unchanged. manifest::unix_from_rfc3339 with tests. For 0.3.12. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/manifest.rs | 39 ++++++++++++++++++++++++++++++++++ app/igneum-app/src/ota.rs | 16 +++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index dd3d609e5..8ad020f81 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -546,3 +546,42 @@ mod tests { assert_eq!(fingerprint("zz"), ""); } } + +/// Unix seconds of a manifest `published_at` ("2026-10-04T13:00:00Z", whole seconds, UTC); `None` for any other shape. +pub fn unix_from_rfc3339(t: &str) -> Option { + let t = t.trim(); + let b = t.as_bytes(); + if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' || !t.ends_with('Z') { + return None; + } + let n = |a: usize, z: usize| t[a..z].parse::().ok(); + let (y, m, d, hh, mm, ss) = (n(0, 4)?, n(5, 7)?, n(8, 10)?, n(11, 13)?, n(14, 16)?, n(17, 19)?); + if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 { + return None; + } + // days from civil (Howard Hinnant), valid for every date after 1970 + let (y2, m2) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) }; + let era = y2.div_euclid(400); + let yoe = y2 - era * 400; + let doy = (153 * m2 + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146097 + doe - 719468; + if days < 0 { + return None; + } + Some((days as u64) * 86400 + (hh as u64) * 3600 + (mm as u64) * 60 + ss as u64) +} + +#[cfg(test)] +mod rfc3339_tests { + use super::unix_from_rfc3339; + #[test] + fn a_manifest_publish_time_parses_to_unix_seconds() { + assert_eq!(unix_from_rfc3339("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!(unix_from_rfc3339("2026-10-05T23:57:49Z"), Some(1791244669)); + assert_eq!(unix_from_rfc3339("2026-10-04T13:00:00Z"), Some(1791118800)); + assert_eq!(unix_from_rfc3339(""), None); + assert_eq!(unix_from_rfc3339("2026-10-05 23:57:49"), None); + assert_eq!(unix_from_rfc3339("2026-13-05T23:57:49Z"), None); + } +} diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index 1313984cc..f119b254d 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -34,6 +34,8 @@ use std::process::Command; use std::sync::Arc; use std::time::{Duration, Instant}; +/// A manifest published this long before the engine started is a catch-up: the hourly rollout slot does not apply. +const CATCH_UP_AFTER_S: u64 = 3600; const HEALTHY_AFTER_S: u64 = 90; const CHECK_EVERY_S: u64 = 3600; const RETRY_AFTER_ERROR_S: u64 = 600; @@ -117,6 +119,11 @@ pub struct Updater { deferred_until: Option, /// this machine's minute of the hour for applying (manifest::slot_minute of the machine id) slot: u64, + /// When this engine started (unix seconds): an update published more than an hour before it is a catch-up, not a + /// rollout, and skips the hourly slot (the project lead's morning of 6 October 2026: PC 1 came up after the 0.3.11 publish and + /// sat on "installs at the next safe moment" until he pressed Install now). + started_unix: u64, + catch_up_logged: bool, /// identity counts from /api/live over the last 10 minutes, sampled while an update is ready live_samples: Vec<(Instant, u64)>, live_next: Instant, @@ -163,6 +170,8 @@ impl Updater { apply_launched: None, deferred_until: None, slot: manifest::slot_minute(&shared.runtime.id8()), + started_unix: crate::platform::unix_now(), + catch_up_logged: false, live_samples: Vec::new(), live_next: now, live_busy: false, @@ -519,7 +528,12 @@ impl Updater { } }; let minute = (crate::platform::unix_now() / 60) % 60; - let slot_ok = minute == self.slot || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false); + let catch_up = self.manifest.as_ref().and_then(|m| manifest::unix_from_rfc3339(&m.published_at)).map(|p| p + CATCH_UP_AFTER_S <= self.started_unix).unwrap_or(false); + if catch_up && !self.catch_up_logged { + self.catch_up_logged = true; + shared.log(&format!("update: {} was published over an hour before this start, so it installs at the first safe moment (no hourly slot)", self.version())); + } + 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, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct }; if !self.auto && !urgent && !self.install_asked {