Skip to main content

magi/
updater.rs

1//! Self-update, via `kaishin`.
2//!
3//! A magi run takes minutes of agent latency, so a background release check
4//! costs nothing measurable: it is spawned on the same tokio runtime as the
5//! command, overlaps it, and is drained with a bounded wait at shutdown. It
6//! never delays the graph.
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use anyhow::{Context, Result};
11use jiff::Timestamp;
12use serde::{Deserialize, Serialize};
13
14use crate::config::{Update, UpdateMode};
15
16/// Env kill-switch. Any non-empty value other than `0` / `false` disables the
17/// background check, and it is read before the config so a broken `magi.toml`
18/// cannot force a network call.
19pub const NO_AUTOUPDATE_ENV: &str = "MAGI_NO_AUTOUPDATE";
20
21/// Default interval between checks.
22pub fn default_interval() -> Duration {
23    kaishin::default_interval()
24}
25
26/// Floor under [`effective_interval`], matching GitHub's unauthenticated rate
27/// limit for the releases API (60 requests/hour/address).
28///
29/// A one-off CLI invocation honouring a shorter configured interval could
30/// only ever make one call per process, so it was never at risk of tripping
31/// that limit on its own. The background recheck loop in `web.rs` is
32/// different: it polls for as long as `magi web` stays up, so an interval
33/// configured well below an hour would have it repeat the same call for as
34/// long as the deck runs - a shared floor here is what keeps it, and every
35/// other caller of [`Checker::new`], inside the budget regardless.
36const MIN_INTERVAL: Duration = Duration::from_secs(60);
37
38/// The interval `cfg` configures, floored at [`MIN_INTERVAL`], or
39/// [`default_interval`] when unset or unparsable.
40///
41/// Shared by [`Checker::new`], which throttles the network call itself
42/// against it, and the background recheck loop in `web.rs`, which uses it to
43/// decide how often to even ask - it cannot track an interval it never sees.
44pub fn effective_interval(cfg: &Update) -> Duration {
45    let interval = cfg
46        .interval
47        .as_deref()
48        .and_then(|s| kaishin::parse_interval(s).ok())
49        .unwrap_or_else(default_interval);
50    interval.max(MIN_INTERVAL)
51}
52
53/// Is the background check switched off by the environment?
54pub fn disabled_by_env() -> bool {
55    match std::env::var(NO_AUTOUPDATE_ENV) {
56        Ok(v) => {
57            let v = v.trim();
58            !(v.is_empty() || v == "0" || v.eq_ignore_ascii_case("false"))
59        }
60        Err(_) => false,
61    }
62}
63
64/// Worker descendants must never replace or re-exec their immutable binary.
65fn pinned_worker_generation() -> bool {
66    std::env::var_os("MAGI_WORKER_GENERATION").is_some()
67}
68
69/// GitHub owner.
70const OWNER: &str = "yukimemi";
71/// GitHub repository — *not* `CARGO_PKG_NAME`, which is the published package.
72const REPO: &str = "magi";
73/// Binary inside the release asset.
74const BIN: &str = "magi";
75/// Published package name, for kaishin's `cargo install` fallback.
76const CRATE: &str = "magi-cli";
77
78/// This binary's own repository name, for `--repo .` auto-discovery
79/// (`web::serve`, `main::resolve_repo`) to match a checkout against without
80/// a second, driftable copy of [`REPO`] anywhere else.
81pub fn repo_name() -> &'static str {
82    REPO
83}
84
85/// kaishin options.
86///
87/// All four names are spelled out because three of them differ from
88/// `CARGO_PKG_NAME`: the package is `magi-cli` (the short name is a squatted
89/// placeholder on crates.io) while the repo, the binary and the library are
90/// `magi`. Deriving any of these from `CARGO_PKG_NAME` would send the updater
91/// looking for a `yukimemi/magi-cli` repository that does not exist.
92fn options() -> kaishin::KaishinOptions {
93    kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
94}
95
96/// Throttle bookkeeping is transient, so it belongs in the cache dir rather
97/// than beside the run history in the data dir.
98fn state_path() -> Option<PathBuf> {
99    dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
100}
101
102/// `magi self-update`.
103pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
104    if !check_only && pinned_worker_generation() {
105        anyhow::bail!(
106            "this worker executable generation is immutable; run self-update \
107             from the installed magi executable outside the worker"
108        );
109    }
110    let opts = kaishin::UpdateOptions::new()
111        .yes(yes)
112        .check_only(check_only)
113        .non_interactive(non_interactive);
114    kaishin::run_self_update(&options(), opts).await
115}
116
117/// A background update check, resolved at shutdown.
118pub enum Pending {
119    /// A previous run already found a newer release; just print the banner.
120    Cached {
121        /// For [`Checker::format_banner`].
122        checker: Checker,
123        /// The release found earlier.
124        latest: kaishin::LatestRelease,
125    },
126    /// A notify-mode check is in flight.
127    Notify {
128        /// For [`Checker::format_banner`].
129        checker: Checker,
130        /// The spawned task.
131        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
132    },
133    /// An install-mode update is in flight.
134    Install {
135        /// The spawned task.
136        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
137    },
138}
139
140/// Throttled release checker.
141#[derive(Clone)]
142pub struct Checker {
143    inner: kaishin::Checker,
144}
145
146impl Checker {
147    /// Build a checker honouring `cfg`, or `None` when checking is off.
148    ///
149    /// The `Option` had no `None` arm: every caller that asked for a checker
150    /// got one, so `[update] mode = "off"` was honoured by the *notify* path
151    /// alone (see [`cached_update`], which matches on the mode itself) and
152    /// ignored everywhere else. `POST /api/upgrade` therefore called the
153    /// GitHub releases API on a deck configured never to check - and so did
154    /// every unit test that reached that route, unauthenticated, against
155    /// GitHub's 60-per-hour-per-address limit.
156    ///
157    /// An operator who writes `mode = "off"` means it. The button is still
158    /// theirs to press; what it may not do is go to the network behind a
159    /// configuration that says not to.
160    pub fn new(cfg: &Update) -> Option<Self> {
161        if cfg.mode == UpdateMode::Off || pinned_worker_generation() {
162            return None;
163        }
164        let mut inner = kaishin::Checker::new(BIN, options());
165        if let Some(path) = state_path() {
166            inner = inner.state_path(path);
167        }
168        Some(Self {
169            inner: inner.interval(effective_interval(cfg)),
170        })
171    }
172
173    /// Is a check due?
174    pub fn should_check(&self) -> bool {
175        self.inner.should_check()
176    }
177
178    /// Ask the forge now: is there a release newer than this build?
179    ///
180    /// Unlike [`Checker::cached_update`] this method does not consult
181    /// [`Checker::should_check`] itself - it has two callers, and they throttle
182    /// differently. `POST /api/upgrade` calls it unconditionally, because the
183    /// caller there is an operator who just pressed a button and is owed an
184    /// answer about the state of the world rather than about the last time
185    /// magi looked. `magi web`'s background recheck (`web::run_update_recheck`)
186    /// calls [`Checker::should_check`] itself first and only reaches here when
187    /// it says yes, which is what keeps that task's network use to at most
188    /// once per `[update] interval` no matter how often it polls.
189    pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
190        self.inner.check_and_save().await
191    }
192
193    /// A newer release already known from a previous run.
194    pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
195        self.inner.cached_update()
196    }
197
198    /// One-line "a newer version exists" banner.
199    pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
200        self.inner.format_banner(latest)
201    }
202
203    /// A checker over an explicit state path and interval, for a test that
204    /// must control throttle timing without touching the operator's real
205    /// cache directory - see [`state_path`] for why sharing it would be
206    /// unsafe.
207    #[cfg(test)]
208    pub(crate) fn for_test(interval: Duration, state_path: PathBuf) -> Self {
209        let opts = kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION"));
210        Self {
211            inner: kaishin::Checker::new(BIN, opts)
212                .state_path(state_path)
213                .interval(interval),
214        }
215    }
216}
217
218/// How far a self-upgrade this deck set in motion has gotten.
219///
220/// `POST /api/upgrade` answers `202` and returns immediately - see its own
221/// doc for why - so [`Progress`] is the only way a phone that asked for an
222/// upgrade learns anything about it afterwards.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "snake_case")]
225pub enum Stage {
226    /// Downloading the release asset and replacing the binary. This bundles
227    /// what would otherwise be two stages: kaishin re-confirms the release
228    /// and downloads it inside one `await` with no hook to split, so from
229    /// here a phone cannot tell "still checking" from "still downloading" -
230    /// only that nothing has been swapped in yet. The confirmation that ran
231    /// *before* this stage started is already known to the phone: it is what
232    /// the `to` version in the `202` answered with.
233    Downloading,
234    /// The new binary is in place and [`crate::web`]'s handover has been
235    /// signalled, but has not acted yet.
236    Replaced,
237    /// Legacy progress from an embedded scheduler upgrade. Retained for
238    /// first-migration reporting only; independent workers never enter it.
239    Parking,
240    /// Restarting the scheduler and web listener. Execution workers continue
241    /// on their immutable executable and are never awaited by this stage.
242    Restarting,
243    /// A successor came up and confirmed it is running the release this
244    /// upgrade asked for.
245    Done,
246    /// The upgrade did not reach [`Stage::Done`]. `detail` on [`Progress`]
247    /// says why.
248    Failed,
249}
250
251impl Stage {
252    /// Position in the upgrade's progression. Only meaningful for the
253    /// non-terminal stages; a terminal one ends the upgrade.
254    #[must_use]
255    pub fn rank(self) -> u8 {
256        match self {
257            Self::Downloading => 0,
258            Self::Replaced => 1,
259            Self::Parking => 2,
260            Self::Restarting => 3,
261            Self::Done | Self::Failed => 4,
262        }
263    }
264
265    /// Finished, one way or the other - nothing is still moving.
266    #[must_use]
267    pub fn terminal(self) -> bool {
268        matches!(self, Self::Done | Self::Failed)
269    }
270
271    /// The wire spelling, the same one serde writes to `upgrade.json`.
272    #[must_use]
273    pub fn as_str(self) -> &'static str {
274        match self {
275            Self::Downloading => "downloading",
276            Self::Replaced => "replaced",
277            Self::Parking => "parking",
278            Self::Restarting => "restarting",
279            Self::Done => "done",
280            Self::Failed => "failed",
281        }
282    }
283}
284
285/// Source of a shared update-and-restart request.
286#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(rename_all = "lowercase")]
288pub enum UpgradeTrigger {
289    /// Requested through the web UI.
290    #[default]
291    Manual,
292    /// Requested by the idle-only background checker.
293    Auto,
294}
295
296/// One upgrade's progress, persisted at [`progress_path`].
297///
298/// Kept on disk rather than in memory because the process that finishes an
299/// upgrade is never the one that started it: the successor is a fresh binary
300/// (see `web::spawn_successor`), and the only thing the two share is the
301/// disk. Beside the run history rather than under the cache dir alongside
302/// [`state_path`]: this is not throttle bookkeeping, it is the record of one
303/// upgrade the operator asked for, and - like a parked run - it is meant to
304/// outlive the process that wrote it.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct Progress {
307    /// Whether the operator or the background checker requested this upgrade.
308    #[serde(default)]
309    pub trigger: UpgradeTrigger,
310    /// Where this upgrade has gotten to.
311    pub stage: Stage,
312    /// Version this upgrade started from.
313    pub from: String,
314    /// Version it is replacing itself with.
315    pub to: Option<String>,
316    /// The run [`Stage::Parking`] is waiting on, when one was in flight.
317    #[serde(default)]
318    pub parked_run: Option<String>,
319    /// Chat turns [`Stage::Parking`] is still waiting on, by talk id.
320    #[serde(default)]
321    pub parked_talks: Vec<String>,
322    /// When this upgrade was asked for.
323    pub started_at: Timestamp,
324    /// Last time `stage` changed.
325    pub updated_at: Timestamp,
326    /// Why [`Stage::Failed`] happened; `None` for every other stage.
327    #[serde(default)]
328    pub detail: Option<String>,
329}
330
331impl Progress {
332    /// A fresh record for an upgrade that is about to replace the binary.
333    #[must_use]
334    pub fn new(from: String, to: String) -> Self {
335        let now = Timestamp::now();
336        Self {
337            trigger: UpgradeTrigger::Manual,
338            stage: Stage::Downloading,
339            from,
340            to: Some(to),
341            parked_run: None,
342            parked_talks: Vec::new(),
343            started_at: now,
344            updated_at: now,
345            detail: None,
346        }
347    }
348
349    /// Move to `stage`, stamping when it changed.
350    pub fn advance(&mut self, stage: Stage) {
351        self.stage = stage;
352        self.updated_at = Timestamp::now();
353        // A note belongs to the stage it was written for.
354        self.detail = None;
355        self.parked_talks.clear();
356    }
357
358    /// Stop at [`Stage::Failed`], with a reason a human can read.
359    pub fn fail(&mut self, detail: impl Into<String>) {
360        self.stage = Stage::Failed;
361        self.updated_at = Timestamp::now();
362        self.detail = Some(detail.into());
363    }
364}
365
366/// Where [`Progress`] is recorded: beside `daemon.json`, not under the cache
367/// dir - see [`Progress`]'s own doc for why the two are not the same place.
368#[must_use]
369pub fn progress_path(home: &Path) -> PathBuf {
370    home.join("upgrade.json")
371}
372
373/// Serialises the read-compare-rename of [`write_progress`], so two writers
374/// in this process cannot each compare against the same stale record.
375static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
376
377/// What to persist when `candidate` is written over `current`.
378///
379/// An upgrade in progress never goes backwards: a candidate whose stage is
380/// not past the recorded one (a second `POST /api/upgrade` landing on a live
381/// handover wrote `replaced` over `parking`) keeps the recorded stage and
382/// timestamps and only refreshes `to`. A terminal record is over, so whatever
383/// comes next starts a new upgrade, and a candidate that ends the upgrade
384/// (`Done` / `Failed`) always goes through.
385#[must_use]
386pub fn monotonic(current: Option<&Progress>, candidate: &Progress) -> Progress {
387    match current {
388        Some(cur)
389            if !cur.stage.terminal()
390                && !candidate.stage.terminal()
391                && candidate.stage.rank() <= cur.stage.rank() =>
392        {
393            let mut kept = cur.clone();
394            if candidate.to.is_some() {
395                kept.to.clone_from(&candidate.to);
396            }
397            kept
398        }
399        _ => candidate.clone(),
400    }
401}
402
403/// Persist `progress`, atomically, never moving an upgrade in progress back
404/// to an earlier stage - see [`monotonic`].
405///
406/// Written to a sibling `.tmp` and renamed, the same reason
407/// `daemon::write_status_to` does it: `/api/health` reads this file on every
408/// poll and must never see a half-written one.
409pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
410    let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
411    write_progress_locked(home, progress)
412}
413
414fn write_progress_locked(home: &Path, progress: &Progress) -> Result<()> {
415    let path = progress_path(home);
416    if let Some(parent) = path.parent() {
417        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
418    }
419    let to_write = monotonic(read_progress(home).as_ref(), progress);
420    store_progress(home, &to_write)
421}
422
423/// Write `progress` as given, atomically. The caller holds [`WRITE_LOCK`] and
424/// has already decided the record may replace the current one.
425fn store_progress(home: &Path, progress: &Progress) -> Result<()> {
426    let path = progress_path(home);
427    let body = serde_json::to_string_pretty(progress).context("serialize upgrade progress")?;
428    let tmp = path.with_extension("json.tmp");
429    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
430    std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
431    Ok(())
432}
433
434/// The phrase naming the chat turns a park waits on, empty for none.
435#[must_use]
436pub fn talks_phrase(talks: &[String]) -> String {
437    match talks {
438        [] => String::new(),
439        [one] => format!("chat turn {one} is"),
440        many => format!("chat turns {} are", many.join(", ")),
441    }
442}
443
444/// Record an upgrade failure. A legacy embedded handover owns its own
445/// parking state; new detached restarts may fail and must report that failure.
446pub fn fail_progress(home: &Path, detail: &str) -> Result<bool> {
447    let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
448    let Some(mut progress) = read_progress(home) else {
449        return Ok(false);
450    };
451    if progress.stage == Stage::Parking {
452        return Ok(false);
453    }
454    progress.fail(detail);
455    write_progress_locked(home, &progress)?;
456    Ok(true)
457}
458
459/// The last upgrade this deck recorded, if it has ever started one.
460#[must_use]
461pub fn read_progress(home: &Path) -> Option<Progress> {
462    let body = std::fs::read_to_string(progress_path(home)).ok()?;
463    serde_json::from_str(&body).ok()
464}
465
466/// Where every handover step is appended, beside `upgrade.json`. Written by
467/// this module directly, so no supervisor redirection of stderr can orphan it.
468#[must_use]
469pub fn log_path(home: &Path) -> PathBuf {
470    home.join("upgrade.log")
471}
472
473/// The log is rotated to `upgrade.log.1` (one generation) past this size.
474pub const LOG_MAX_BYTES: u64 = 256 * 1024;
475
476/// A non-terminal `replaced` / `restarting` stage older than this is stuck.
477pub const STALL_AFTER_SECS: i64 = 120;
478
479/// A handover lease with no beat for this long is not alive. Same idiom as
480/// `ask::LEASE_TTL`; no pid is consulted.
481pub const LEASE_TTL_SECS: i64 = 90;
482
483/// How often the watchdog repeats itself for one stage.
484pub const HEARTBEAT_SECS: i64 = 60;
485
486/// How often the watchdog thread looks at `upgrade.json`.
487pub const WATCHDOG_POLL: Duration = Duration::from_secs(30);
488
489/// Append `line` to `path`, rotating to `<path>.1` first when the file has
490/// reached `max` bytes. Open-append-close every time, so a rename or a
491/// deleted file never leaves a stale handle.
492pub fn append_bounded(path: &Path, line: &str, max: u64) -> std::io::Result<()> {
493    use std::io::Write as _;
494    if std::fs::metadata(path).is_ok_and(|m| m.len() >= max) {
495        let mut old = path.as_os_str().to_owned();
496        old.push(".1");
497        std::fs::rename(path, PathBuf::from(old))?;
498    }
499    if let Some(parent) = path.parent() {
500        std::fs::create_dir_all(parent)?;
501    }
502    let mut file = std::fs::OpenOptions::new()
503        .create(true)
504        .append(true)
505        .open(path)?;
506    writeln!(file, "{line}")
507}
508
509/// One handover step: to `tracing` at INFO and to `<home>/upgrade.log`, with
510/// a UTC timestamp and this process's pid. Best-effort - a failed write is a
511/// warning, never a failed upgrade.
512pub fn log_step(home: &Path, msg: &str) {
513    tracing::info!("handover: {msg}");
514    log_line(home, "INFO", msg);
515}
516
517/// Like [`log_step`], at WARN.
518pub fn log_warn(home: &Path, msg: &str) {
519    tracing::warn!("handover: {msg}");
520    log_line(home, "WARN", msg);
521}
522
523fn log_line(home: &Path, level: &str, msg: &str) {
524    let line = format!(
525        "{} pid={} {level} {msg}",
526        Timestamp::now(),
527        std::process::id()
528    );
529    if let Err(e) = append_bounded(&log_path(home), &line, LOG_MAX_BYTES) {
530        tracing::warn!("could not append to {}: {e}", log_path(home).display());
531    }
532}
533
534/// [`write_progress`] that says so when it fails, instead of dropping the
535/// error: a progress file that silently stops moving is the symptom this
536/// module exists to explain.
537pub fn write_progress_logged(home: &Path, progress: &Progress) {
538    if let Err(e) = write_progress(home, progress) {
539        log_warn(home, &format!("could not write upgrade.json: {e:#}"));
540    }
541}
542
543/// Proof that `hand_over` is running: written when it is entered, beaten
544/// while it waits on the loop, removed when it leaves.
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct HandoverLease {
547    /// When `hand_over` was entered.
548    pub entered_at: Timestamp,
549    /// Last time it said it was alive.
550    pub beat_at: Timestamp,
551    /// The run it is waiting on, when one was in flight.
552    #[serde(default)]
553    pub parked_run: Option<String>,
554}
555
556impl HandoverLease {
557    /// Whether the last beat is recent enough at `now`.
558    #[must_use]
559    pub fn fresh(&self, now: Timestamp) -> bool {
560        now.as_second() - self.beat_at.as_second() <= LEASE_TTL_SECS
561    }
562}
563
564/// Where the [`HandoverLease`] lives, beside `upgrade.json`.
565#[must_use]
566pub fn lease_path(home: &Path) -> PathBuf {
567    home.join("upgrade.handover.json")
568}
569
570/// The lease on disk, if there is one.
571#[must_use]
572pub fn read_lease(home: &Path) -> Option<HandoverLease> {
573    let body = std::fs::read_to_string(lease_path(home)).ok()?;
574    serde_json::from_str(&body).ok()
575}
576
577#[cfg(test)]
578fn write_lease(home: &Path, lease: &HandoverLease) {
579    let path = lease_path(home);
580    let tmp = path.with_extension("json.tmp");
581    let written = serde_json::to_string(lease)
582        .map_err(std::io::Error::other)
583        .and_then(|body| std::fs::write(&tmp, body))
584        .and_then(|()| std::fs::rename(&tmp, &path));
585    if let Err(e) = written {
586        log_warn(home, &format!("could not write the handover lease: {e}"));
587    }
588}
589
590/// Legacy handover fixture for compatibility tests; new handovers never park.
591#[cfg(test)]
592#[derive(Debug)]
593pub struct LeaseGuard {
594    home: PathBuf,
595    lease: HandoverLease,
596}
597
598#[cfg(test)]
599impl LeaseGuard {
600    /// Record that `hand_over` has been entered.
601    #[must_use]
602    pub fn enter(home: &Path, parked_run: Option<String>) -> Self {
603        let now = Timestamp::now();
604        let lease = HandoverLease {
605            entered_at: now,
606            beat_at: now,
607            parked_run,
608        };
609        write_lease(home, &lease);
610        Self {
611            home: home.to_owned(),
612            lease,
613        }
614    }
615
616    /// Say it is still alive.
617    pub fn beat(&mut self) {
618        self.lease.beat_at = Timestamp::now();
619        write_lease(&self.home, &self.lease);
620    }
621}
622
623#[cfg(test)]
624impl Drop for LeaseGuard {
625    fn drop(&mut self) {
626        let _ = std::fs::remove_file(lease_path(&self.home));
627    }
628}
629
630/// How a non-terminal stage came to be called stuck.
631#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
632#[serde(rename_all = "snake_case")]
633pub enum StallKind {
634    /// The handover was signalled but `hand_over` left no record of entering.
635    NeverEntered,
636    /// `hand_over` did enter (or the successor is starting) but nothing has
637    /// moved or beaten for longer than allowed.
638    StoppedBeating,
639}
640
641/// A non-terminal stage that has outlived what it should take.
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub struct Stall {
644    /// The stage that is stuck.
645    pub stage: Stage,
646    /// Which kind of stuck, which decides what the operator is told.
647    pub kind: StallKind,
648    /// How long it has been without progress.
649    pub age_secs: i64,
650    /// What it is waiting on, in words.
651    pub waiting_on: String,
652}
653
654/// Seconds `progress` has been in its stage at `now`. A clock that went
655/// backwards counts as zero, never as a negative age.
656#[must_use]
657pub fn stage_age_secs(progress: &Progress, now: Timestamp) -> i64 {
658    (now.as_second() - progress.updated_at.as_second()).max(0)
659}
660
661/// The lease, when it proves `hand_over` of *this* upgrade is alive at `now`.
662#[must_use]
663pub fn live_lease<'a>(
664    progress: &Progress,
665    lease: Option<&'a HandoverLease>,
666    now: Timestamp,
667) -> Option<&'a HandoverLease> {
668    lease.filter(|l| {
669        !progress.stage.terminal() && l.fresh(now) && l.entered_at >= progress.started_at
670    })
671}
672
673/// What a stage is waiting on, in words.
674fn waiting_on(progress: &Progress, lease: Option<&HandoverLease>) -> String {
675    match progress.stage {
676        Stage::Replaced => "hand_over to start (HANDOVER was signalled; hand_over has left no \
677                            record that it was entered)"
678            .to_owned(),
679        Stage::Parking => {
680            let run = lease
681                .and_then(|l| l.parked_run.as_ref())
682                .or(progress.parked_run.as_ref());
683            let talks = talks_phrase(&progress.parked_talks);
684            let talks = if talks.is_empty() {
685                String::new()
686            } else {
687                format!("{talks} still finishing; ")
688            };
689            match run {
690                Some(run) => {
691                    format!("{talks}the loop to finish run {run} at its next node boundary")
692                }
693                None => format!("{talks}the loop to stop (no run was recorded as in flight)"),
694            }
695        }
696        Stage::Restarting => "spawn_successor returning and this process exiting".to_owned(),
697        Stage::Downloading => "the release download and binary replacement".to_owned(),
698        Stage::Done | Stage::Failed => String::new(),
699    }
700}
701
702/// Seconds `hand_over` has been alive and waiting, when `lease` proves it.
703#[must_use]
704pub fn waited_secs(lease: &HandoverLease, now: Timestamp) -> i64 {
705    (now.as_second() - lease.entered_at.as_second()).max(0)
706}
707
708/// Pure: whether `progress` is stuck at `now`.
709///
710/// A fresh lease means `hand_over` is alive and waiting on the loop, which is
711/// legitimate for as long as the run's node takes, so it is never stuck.
712#[must_use]
713pub fn stall(progress: &Progress, lease: Option<&HandoverLease>, now: Timestamp) -> Option<Stall> {
714    if progress.stage.terminal() || progress.stage == Stage::Downloading {
715        return None;
716    }
717    if live_lease(progress, lease, now).is_some() {
718        return None;
719    }
720    // A stale lease for this upgrade means hand_over was alive and went quiet.
721    let stale = lease.filter(|l| l.entered_at >= progress.started_at);
722    let (kind, since) = match (progress.stage, stale) {
723        (_, Some(l)) => (StallKind::StoppedBeating, l.beat_at),
724        (Stage::Replaced, None) => (StallKind::NeverEntered, progress.updated_at),
725        _ => (StallKind::StoppedBeating, progress.updated_at),
726    };
727    let age_secs = (now.as_second() - since.as_second()).max(0);
728    let limit = if stale.is_some() {
729        LEASE_TTL_SECS
730    } else {
731        STALL_AFTER_SECS
732    };
733    (age_secs > limit).then(|| Stall {
734        stage: progress.stage,
735        kind,
736        age_secs,
737        waiting_on: waiting_on(progress, lease),
738    })
739}
740
741/// What the watchdog wants said this tick.
742#[derive(Debug, Clone, PartialEq, Eq)]
743pub struct Beat {
744    /// The stage this beat is about.
745    pub stage: Stage,
746    /// Whether to say it as a warning (stuck) rather than a heartbeat.
747    pub warn: bool,
748    /// The line to log and to persist as `detail`.
749    pub message: String,
750}
751
752/// Decides when the watchdog speaks. Pure: the caller supplies `now`.
753#[derive(Debug, Default)]
754pub struct Watchdog {
755    last: Option<(Stage, Timestamp)>,
756}
757
758impl Watchdog {
759    /// Look at the record at `now`. `None` means stay quiet: terminal, too
760    /// early, or already spoken within [`HEARTBEAT_SECS`] for this stage.
761    pub fn tick(
762        &mut self,
763        progress: &Progress,
764        lease: Option<&HandoverLease>,
765        now: Timestamp,
766    ) -> Option<Beat> {
767        if progress.stage.terminal() {
768            self.last = None;
769            return None;
770        }
771        if self.last.is_some_and(|(stage, _)| stage != progress.stage) {
772            self.last = None;
773        }
774        let stalled = stall(progress, lease, now);
775        let alive = live_lease(progress, lease, now);
776        // A live `hand_over` is allowed to wait long, but says what it waits
777        // on; the other stages are silent until they are stalled.
778        if stalled.is_none() && alive.is_none() && progress.stage != Stage::Parking {
779            return None;
780        }
781        if let Some((_, at)) = self.last
782            && now.as_second() - at.as_second() < HEARTBEAT_SECS
783        {
784            return None;
785        }
786        self.last = Some((progress.stage, now));
787        let age = alive.map_or_else(|| stage_age_secs(progress, now), |l| waited_secs(l, now));
788        let (warn, message) = match &stalled {
789            Some(s) => (
790                true,
791                format!(
792                    "stuck in {:?} for {} min {} s without progress, waiting on {}",
793                    s.stage,
794                    s.age_secs / 60,
795                    s.age_secs % 60,
796                    s.waiting_on
797                ),
798            ),
799            None => (
800                false,
801                format!(
802                    "parking for {} min {} s, waiting on {}",
803                    age / 60,
804                    age % 60,
805                    waiting_on(progress, lease)
806                ),
807            ),
808        };
809        Some(Beat {
810            stage: progress.stage,
811            warn,
812            message,
813        })
814    }
815}
816
817/// The watchdog's latest message, kept apart from `upgrade.json` so a
818/// diagnostic write can never race a stage transition.
819#[derive(Debug, Serialize, Deserialize)]
820struct Note {
821    stage: Stage,
822    stage_since: Timestamp,
823    message: String,
824}
825
826fn note_path(home: &Path) -> PathBuf {
827    home.join("upgrade.note.json")
828}
829
830fn write_note(home: &Path, progress: &Progress, message: &str) {
831    let note = Note {
832        stage: progress.stage,
833        stage_since: progress.updated_at,
834        message: message.to_owned(),
835    };
836    let path = note_path(home);
837    let tmp = path.with_extension("json.tmp");
838    let written = serde_json::to_string(&note)
839        .map_err(std::io::Error::other)
840        .and_then(|body| std::fs::write(&tmp, body))
841        .and_then(|()| std::fs::rename(&tmp, &path));
842    if let Err(e) = written {
843        tracing::warn!("could not write {}: {e}", path.display());
844    }
845}
846
847/// The watchdog's message for exactly this stage of this upgrade, if any;
848/// a note from another stage or another upgrade is ignored.
849#[must_use]
850pub fn read_note(home: &Path, progress: &Progress) -> Option<String> {
851    let body = std::fs::read_to_string(note_path(home)).ok()?;
852    let note: Note = serde_json::from_str(&body).ok()?;
853    (note.stage == progress.stage && note.stage_since == progress.updated_at)
854        .then_some(note.message)
855}
856
857/// Run the watchdog on a thread of its own, so a blocked runtime, or a
858/// process half-way through dropping one, still speaks. It never ends; it is
859/// a daemon thread and dies with the process.
860pub fn spawn_watchdog(home: PathBuf) {
861    let spawned = std::thread::Builder::new()
862        .name("upgrade-watchdog".to_owned())
863        .spawn(move || {
864            let mut dog = Watchdog::default();
865            loop {
866                std::thread::sleep(WATCHDOG_POLL);
867                let Some(progress) = read_progress(&home) else {
868                    continue;
869                };
870                let lease = read_lease(&home);
871                let Some(beat) = dog.tick(&progress, lease.as_ref(), Timestamp::now()) else {
872                    continue;
873                };
874                if beat.warn {
875                    log_warn(&home, &beat.message);
876                } else {
877                    log_step(&home, &beat.message);
878                }
879                // Never rewrites upgrade.json: a read-modify-write here could
880                // overwrite a stage the handover or the successor saved in
881                // between. The note goes to its own file, which only this
882                // thread writes, and is matched to the record when read.
883                write_note(&home, &progress, &beat.message);
884            }
885        });
886    if let Err(e) = spawned {
887        tracing::warn!("could not start the upgrade watchdog: {e}");
888    }
889}
890
891/// Reconcile a leftover progress record on startup, before the server starts
892/// answering requests.
893///
894/// A non-terminal record on disk when a process starts can only mean one of
895/// two things: this *is* the successor `spawn_successor` started, or the
896/// predecessor died before finishing the handover (a crash, a reboot, an
897/// operator killing it by hand). Either way waiting longer will not resolve
898/// it - this process is already up - so it is settled immediately:
899/// [`Stage::Done`] when the running version matches what was asked for,
900/// [`Stage::Failed`] otherwise, so the operator is told rather than left
901/// watching a stage that will never move again.
902pub fn reconcile_after_restart(home: &Path) {
903    let Some(mut progress) = read_progress(home) else {
904        return;
905    };
906    // Whoever held it is not this process.
907    let _ = std::fs::remove_file(lease_path(home));
908    if progress.stage.terminal() {
909        return;
910    }
911    let running = env!("CARGO_PKG_VERSION");
912    // `progress.to` is `latest.tag_name` from the forge, which - like every
913    // tag in this repository - carries a `v` prefix `CARGO_PKG_VERSION` does
914    // not. kaishin's own `is_update_available` strips it before comparing;
915    // an exact-string match here would call a successful upgrade `Failed`
916    // every time, because "v0.5.2" is never equal to "0.5.2".
917    if progress
918        .to
919        .as_deref()
920        .is_some_and(|to| to.trim_start_matches('v') == running)
921    {
922        progress.advance(Stage::Done);
923    } else {
924        let to = progress
925            .to
926            .clone()
927            .unwrap_or_else(|| "the expected release".to_owned());
928        progress.fail(format!(
929            "this process came up on {running}, not {to} - the upgrade may \
930             not have replaced the binary"
931        ));
932    }
933    write_progress_logged(home, &progress);
934}
935
936/// Spawn the background check for `cfg`, unless it is switched off.
937pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
938    if disabled_by_env() || pinned_worker_generation() || cfg.mode == UpdateMode::Off {
939        return None;
940    }
941    let checker = Checker::new(cfg)?;
942    match cfg.mode {
943        UpdateMode::Off => None,
944        UpdateMode::Notify => {
945            if !checker.should_check() {
946                let latest = checker.cached_update()?;
947                return Some(Pending::Cached { checker, latest });
948            }
949            let inner = checker.inner.clone();
950            let handle = rt.spawn(async move { inner.check_and_save().await });
951            Some(Pending::Notify { checker, handle })
952        }
953        UpdateMode::Install => {
954            let inner = checker.inner.clone();
955            let handle = rt.spawn(async move { inner.auto_update().await });
956            Some(Pending::Install { handle })
957        }
958    }
959}
960
961/// Drain a pending check and print at most one line.
962///
963/// Bounds only this async wait. The caller must also bound runtime shutdown:
964/// synchronous or blocking-pool work can outlive the timeout.
965pub async fn finalize(pending: Option<Pending>, budget: Duration) {
966    let Some(pending) = pending else {
967        return;
968    };
969    match pending {
970        Pending::Cached { checker, latest } => {
971            eprintln!("{}", checker.format_banner(&latest));
972        }
973        Pending::Notify { checker, handle } => {
974            if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
975                eprintln!("{}", checker.format_banner(&latest));
976            }
977        }
978        Pending::Install { handle } => {
979            if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
980                eprintln!("magi updated itself to {}", latest.tag_name);
981            }
982        }
983    }
984}
985
986/// The check can block the only runtime worker in synchronous authentication,
987/// starving Tokio's timer driver. Deliver the deadline from an OS thread so
988/// the thread polling `async_main` can reach bounded runtime shutdown anyway.
989async fn finalize_task<T>(handle: tokio::task::JoinHandle<T>, budget: Duration) -> Option<T> {
990    let (send, deadline) = tokio::sync::oneshot::channel();
991    if let Err(error) = std::thread::Builder::new()
992        .name("update-finalize-deadline".to_owned())
993        .spawn(move || {
994            std::thread::sleep(budget);
995            let _ = send.send(());
996        })
997    {
998        tracing::warn!("could not start release-check deadline: {error}");
999        return None;
1000    }
1001    tokio::select! {
1002        result = handle => result.ok(),
1003        _ = deadline => None,
1004    }
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010
1011    /// An interval configured below GitHub's unauthenticated 60 req/hour/IP
1012    /// limit must be floored, or `web`'s background recheck loop - which,
1013    /// unlike a one-off CLI invocation, keeps polling for as long as `magi
1014    /// web` stays up - would repeat the same call far past that limit.
1015    #[test]
1016    fn effective_interval_floors_a_configured_interval_below_githubs_rate_limit() {
1017        let cfg = Update {
1018            mode: UpdateMode::Notify,
1019            interval: Some("1s".to_owned()),
1020            auto_restart: false,
1021        };
1022        assert_eq!(
1023            effective_interval(&cfg),
1024            MIN_INTERVAL,
1025            "an interval that would exceed GitHub's rate limit under continuous \
1026             polling must be floored rather than honoured verbatim"
1027        );
1028
1029        let sane = Update {
1030            mode: UpdateMode::Notify,
1031            interval: Some("2h".to_owned()),
1032            auto_restart: false,
1033        };
1034        assert_eq!(
1035            effective_interval(&sane),
1036            Duration::from_secs(2 * 60 * 60),
1037            "an interval already above the floor must pass through unchanged"
1038        );
1039    }
1040
1041    #[test]
1042    fn env_kill_switch_semantics() {
1043        // SAFETY: single-threaded test, no other thread reads the variable.
1044        unsafe {
1045            std::env::remove_var(NO_AUTOUPDATE_ENV);
1046        }
1047        assert!(!disabled_by_env());
1048        for (value, disabled) in [
1049            ("1", true),
1050            ("true", true),
1051            ("yes", true),
1052            ("0", false),
1053            ("false", false),
1054            ("FALSE", false),
1055            ("", false),
1056            ("  ", false),
1057        ] {
1058            unsafe {
1059                std::env::set_var(NO_AUTOUPDATE_ENV, value);
1060            }
1061            assert_eq!(
1062                disabled_by_env(),
1063                disabled,
1064                "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
1065                if disabled { "" } else { "not" }
1066            );
1067        }
1068        unsafe {
1069            std::env::remove_var(NO_AUTOUPDATE_ENV);
1070        }
1071    }
1072
1073    #[test]
1074    fn off_mode_never_spawns() {
1075        let rt = tokio::runtime::Builder::new_current_thread()
1076            .enable_all()
1077            .build()
1078            .unwrap();
1079        let cfg = Update {
1080            mode: UpdateMode::Off,
1081            interval: None,
1082            auto_restart: false,
1083        };
1084        assert!(spawn(&cfg, rt.handle()).is_none());
1085    }
1086
1087    #[test]
1088    fn state_path_lives_under_the_cache_dir() {
1089        let path = state_path().expect("a cache dir on every supported platform");
1090        assert!(path.ends_with("magi/last_update_check.json"));
1091        let data = dirs::data_local_dir().unwrap_or_default();
1092        assert!(
1093            !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
1094            "throttle state must not sit in the run history directory"
1095        );
1096    }
1097
1098    #[tokio::test]
1099    async fn finalize_of_nothing_is_a_no_op() {
1100        finalize(None, Duration::from_millis(1)).await;
1101    }
1102
1103    #[tokio::test]
1104    async fn finalize_task_preserves_a_timely_result() {
1105        let handle = tokio::spawn(async { 42 });
1106        assert_eq!(
1107            finalize_task(handle, Duration::from_secs(1)).await,
1108            Some(42)
1109        );
1110    }
1111
1112    /// `mode = "off"` means no checker, for every caller.
1113    ///
1114    /// It used to mean it only for the notify path: `Checker::new` returned
1115    /// `Some` unconditionally, so `POST /api/upgrade` went to the GitHub
1116    /// releases API on a deck configured never to check. A test that reached
1117    /// that route made a live, unauthenticated request, and GitHub's
1118    /// 60-per-hour-per-address limit then turned the suite red on one runner
1119    /// at a time - for as long as anyone kept re-running it, since each
1120    /// attempt spent another request.
1121    #[test]
1122    fn checking_is_off_for_every_caller_when_the_config_says_off() {
1123        assert!(
1124            Checker::new(&Update {
1125                mode: UpdateMode::Off,
1126                interval: None,
1127                auto_restart: false,
1128            })
1129            .is_none(),
1130            "an operator who writes mode = \"off\" means it"
1131        );
1132        for mode in [UpdateMode::Notify, UpdateMode::Install] {
1133            assert!(
1134                Checker::new(&Update {
1135                    mode,
1136                    interval: None,
1137                    auto_restart: false,
1138                })
1139                .is_some(),
1140                "{mode:?} still asks the forge"
1141            );
1142        }
1143    }
1144
1145    /// `cached_update` never touches the network: a state file written the
1146    /// way `check_and_save` writes one is enough to answer, and no file at
1147    /// all answers "unknown" rather than blocking or erroring.
1148    ///
1149    /// Built from `kaishin::Checker` directly, with an explicit state path,
1150    /// rather than through [`Checker::new`]: that constructor always points
1151    /// at the real cache directory, which is right for production - every
1152    /// `magi` invocation on the machine shares one throttle file - but wrong
1153    /// for a test, which must never read or write the operator's actual
1154    /// state.
1155    #[test]
1156    fn cached_update_answers_from_disk_with_no_network_call() {
1157        let dir = tempfile::tempdir().expect("temp dir");
1158        let path = dir.path().join("state.json");
1159        let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
1160        let checker = Checker {
1161            inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
1162        };
1163
1164        assert!(
1165            checker.cached_update().is_none(),
1166            "no state file yet must read as \"unknown\", not an error"
1167        );
1168
1169        let state = kaishin::UpdateCheckState {
1170            last_checked_unix: 0,
1171            last_known_latest: Some("v9.9.9".to_owned()),
1172            last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
1173        };
1174        kaishin::save_check_state(&path, &state).expect("seed the state file");
1175
1176        let latest = checker.cached_update().expect("a newer release was cached");
1177        assert_eq!(latest.tag_name, "v9.9.9");
1178    }
1179
1180    #[test]
1181    fn reconcile_after_restart_confirms_a_matching_version() {
1182        // `to` is `latest.tag_name` as the forge and this repository's own
1183        // tags spell it - with a `v` - which `CARGO_PKG_VERSION` never
1184        // carries. A test that leaves the `v` off would not have caught the
1185        // exact-string-equality bug this function used to have.
1186        let home = tempfile::tempdir().expect("temp home");
1187        let mut progress = Progress::new(
1188            "0.1.0".to_owned(),
1189            format!("v{}", env!("CARGO_PKG_VERSION")),
1190        );
1191        progress.advance(Stage::Restarting);
1192        write_progress(home.path(), &progress).expect("seed progress");
1193
1194        reconcile_after_restart(home.path());
1195
1196        let after = read_progress(home.path()).expect("progress on disk");
1197        assert_eq!(
1198            after.stage,
1199            Stage::Done,
1200            "the successor is running exactly the release that was asked for, \
1201             `v` prefix and all"
1202        );
1203    }
1204
1205    #[test]
1206    fn reconcile_after_restart_flags_a_mismatched_version() {
1207        let home = tempfile::tempdir().expect("temp home");
1208        let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
1209        progress.advance(Stage::Restarting);
1210        write_progress(home.path(), &progress).expect("seed progress");
1211
1212        reconcile_after_restart(home.path());
1213
1214        let after = read_progress(home.path()).expect("progress on disk");
1215        assert_eq!(after.stage, Stage::Failed);
1216        assert!(
1217            after.detail.is_some_and(|d| d.contains("9.9.9")),
1218            "the operator needs to know which release it did not come back on"
1219        );
1220    }
1221
1222    #[test]
1223    fn reconcile_after_restart_leaves_a_settled_record_alone() {
1224        let home = tempfile::tempdir().expect("temp home");
1225        let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
1226        progress.advance(Stage::Done);
1227        write_progress(home.path(), &progress).expect("seed progress");
1228
1229        reconcile_after_restart(home.path());
1230
1231        let after = read_progress(home.path()).expect("progress on disk");
1232        assert_eq!(
1233            after.stage,
1234            Stage::Done,
1235            "an already-settled record must not be rewritten by a later, unrelated start"
1236        );
1237    }
1238
1239    #[test]
1240    fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
1241        let home = tempfile::tempdir().expect("temp home");
1242        reconcile_after_restart(home.path());
1243        assert!(read_progress(home.path()).is_none());
1244    }
1245
1246    fn at(secs: i64) -> Timestamp {
1247        Timestamp::from_second(secs).expect("timestamp")
1248    }
1249
1250    #[test]
1251    fn auto_upgrade_progress_defaults_old_records_to_manual() {
1252        let progress = Progress::new("old".into(), "new".into());
1253        let mut value = serde_json::to_value(progress).unwrap();
1254        value.as_object_mut().unwrap().remove("trigger");
1255        let old: Progress = serde_json::from_value(value).unwrap();
1256        assert_eq!(old.trigger, UpgradeTrigger::Manual);
1257    }
1258
1259    fn staged(stage: Stage, since: i64) -> Progress {
1260        let mut p = Progress::new("0.1.0".to_owned(), "v0.2.0".to_owned());
1261        p.stage = stage;
1262        p.started_at = at(since);
1263        p.updated_at = at(since);
1264        p
1265    }
1266
1267    fn lease(entered: i64, beat: i64) -> HandoverLease {
1268        HandoverLease {
1269            entered_at: at(entered),
1270            beat_at: at(beat),
1271            parked_run: Some("r1".to_owned()),
1272        }
1273    }
1274
1275    #[test]
1276    fn a_handover_never_entered_is_stuck_and_says_only_what_is_known() {
1277        let p = staged(Stage::Replaced, 1000);
1278        assert!(stall(&p, None, at(1000 + STALL_AFTER_SECS)).is_none());
1279        let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1280        assert_eq!(s.stage, Stage::Replaced);
1281        assert_eq!(s.kind, StallKind::NeverEntered);
1282        assert_eq!(s.age_secs, STALL_AFTER_SECS + 1);
1283        assert!(s.waiting_on.contains("hand_over"), "{}", s.waiting_on);
1284
1285        let p = staged(Stage::Restarting, 1000);
1286        let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1287        assert_eq!(s.kind, StallKind::StoppedBeating);
1288
1289        for stage in [Stage::Done, Stage::Failed, Stage::Downloading] {
1290            assert!(stall(&staged(stage, 0), None, at(1_000_000)).is_none());
1291        }
1292    }
1293
1294    #[test]
1295    fn a_live_parking_wait_is_never_stuck_however_long_it_lasts() {
1296        let mut p = staged(Stage::Parking, 1000);
1297        p.started_at = at(900);
1298        let hours = 5 * 3600;
1299        let l = lease(1000, 1000 + hours);
1300        assert!(stall(&p, Some(&l), at(1000 + hours + 10)).is_none());
1301        // Even a record that regressed to `replaced` is read through the lease.
1302        let r = staged(Stage::Replaced, 1000);
1303        assert!(stall(&r, Some(&l), at(1000 + hours + 10)).is_none());
1304        // Once the beat stops, it is stuck, and says so.
1305        let s = stall(&p, Some(&l), at(1000 + hours + LEASE_TTL_SECS + 1)).expect("stuck");
1306        assert_eq!(s.kind, StallKind::StoppedBeating);
1307    }
1308
1309    #[test]
1310    fn a_lease_from_an_earlier_upgrade_proves_nothing() {
1311        let p = staged(Stage::Replaced, 2000);
1312        let old = lease(10, 3000);
1313        assert!(live_lease(&p, Some(&old), at(3001)).is_none());
1314    }
1315
1316    #[test]
1317    fn a_stage_never_goes_backwards_but_a_new_upgrade_after_a_terminal_one_starts() {
1318        let parking = staged(Stage::Parking, 1000);
1319        for back in [Stage::Replaced, Stage::Downloading, Stage::Parking] {
1320            let mut cand = staged(back, 5000);
1321            cand.to = Some("v9.9.9".to_owned());
1322            let kept = monotonic(Some(&parking), &cand);
1323            assert_eq!(kept.stage, Stage::Parking);
1324            assert_eq!(kept.updated_at, at(1000));
1325            assert_eq!(kept.started_at, parking.started_at);
1326            assert_eq!(kept.to.as_deref(), Some("v9.9.9"), "data is refreshed");
1327        }
1328        assert_eq!(
1329            monotonic(Some(&parking), &staged(Stage::Restarting, 5000)).stage,
1330            Stage::Restarting
1331        );
1332        assert_eq!(
1333            monotonic(Some(&parking), &staged(Stage::Failed, 5000)).stage,
1334            Stage::Failed
1335        );
1336        let done = staged(Stage::Done, 1000);
1337        assert_eq!(
1338            monotonic(Some(&done), &staged(Stage::Downloading, 5000)).stage,
1339            Stage::Downloading
1340        );
1341    }
1342
1343    #[test]
1344    fn write_progress_refuses_a_regression_on_disk() {
1345        let home = tempfile::tempdir().expect("temp home");
1346        write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1347        write_progress(home.path(), &staged(Stage::Replaced, 5000)).expect("write");
1348        let on_disk = read_progress(home.path()).expect("record");
1349        assert_eq!(on_disk.stage, Stage::Parking);
1350        assert_eq!(on_disk.updated_at, at(1000));
1351    }
1352
1353    #[test]
1354    fn a_failed_request_cannot_overwrite_a_live_handover() {
1355        let home = tempfile::tempdir().expect("temp home");
1356        write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1357        assert!(!fail_progress(home.path(), "boom").expect("fail"));
1358        assert_eq!(read_progress(home.path()).unwrap().stage, Stage::Parking);
1359        write_progress(home.path(), &staged(Stage::Replaced, 1000)).ok();
1360        let fresh = tempfile::tempdir().expect("temp home");
1361        write_progress(fresh.path(), &staged(Stage::Downloading, 1000)).expect("write");
1362        assert!(fail_progress(fresh.path(), "boom").expect("fail"));
1363        assert_eq!(read_progress(fresh.path()).unwrap().stage, Stage::Failed);
1364    }
1365
1366    #[test]
1367    fn a_failed_detached_restart_reports_the_failure() {
1368        let home = tempfile::tempdir().expect("temp home");
1369        write_progress(home.path(), &staged(Stage::Restarting, 1000)).expect("write");
1370        assert!(fail_progress(home.path(), "successor spawn failed").expect("fail"));
1371        let progress = read_progress(home.path()).expect("progress");
1372        assert_eq!(progress.stage, Stage::Failed);
1373        assert_eq!(progress.detail.as_deref(), Some("successor spawn failed"));
1374    }
1375
1376    #[test]
1377    fn the_lease_guard_writes_beats_and_removes_the_lease() {
1378        let home = tempfile::tempdir().expect("temp home");
1379        {
1380            let mut guard = LeaseGuard::enter(home.path(), Some("r1".to_owned()));
1381            let first = read_lease(home.path()).expect("lease");
1382            assert_eq!(first.parked_run.as_deref(), Some("r1"));
1383            guard.beat();
1384            assert!(read_lease(home.path()).is_some());
1385        }
1386        assert!(read_lease(home.path()).is_none());
1387    }
1388
1389    #[test]
1390    fn a_clock_that_went_backwards_is_age_zero() {
1391        let p = staged(Stage::Replaced, 5000);
1392        assert_eq!(stage_age_secs(&p, at(100)), 0);
1393        assert!(stall(&p, None, at(100)).is_none());
1394    }
1395
1396    #[test]
1397    fn a_note_is_kept_beside_the_record_and_matches_only_its_own_stage() {
1398        let home = tempfile::tempdir().expect("temp home");
1399        let p = staged(Stage::Replaced, 1000);
1400        write_progress(home.path(), &p).expect("write");
1401        write_note(home.path(), &p, "stuck");
1402        assert_eq!(read_note(home.path(), &p).as_deref(), Some("stuck"));
1403        assert_eq!(read_progress(home.path()).unwrap().updated_at, at(1000));
1404        assert!(read_note(home.path(), &staged(Stage::Parking, 1000)).is_none());
1405        assert!(read_note(home.path(), &staged(Stage::Replaced, 2000)).is_none());
1406    }
1407
1408    #[test]
1409    fn the_watchdog_speaks_once_a_minute_and_resets_on_a_new_stage() {
1410        let mut dog = Watchdog::default();
1411        let p = staged(Stage::Replaced, 1000);
1412        assert!(dog.tick(&p, None, at(1060)).is_none(), "not stalled yet");
1413        let beat = dog.tick(&p, None, at(1200)).expect("stalled");
1414        assert!(beat.warn);
1415        assert!(dog.tick(&p, None, at(1230)).is_none(), "spoke 30 s ago");
1416        assert!(dog.tick(&p, None, at(1260)).is_some(), "a minute later");
1417
1418        let parking = staged(Stage::Parking, 1260);
1419        let l = lease(1260, 1270);
1420        let beat = dog
1421            .tick(&parking, Some(&l), at(1275))
1422            .expect("parking heartbeat at once");
1423        assert!(!beat.warn, "a live wait is not a warning");
1424        assert!(dog.tick(&parking, Some(&l), at(1300)).is_none());
1425        let l = lease(1260, 1260 + 4 * 3600);
1426        let later = dog
1427            .tick(&parking, Some(&l), at(1260 + 4 * 3600 + 5))
1428            .expect("heartbeat");
1429        assert!(!later.warn, "hours of waiting on a run is still not stuck");
1430        assert!(later.message.contains("r1"), "{}", later.message);
1431
1432        let done = staged(Stage::Done, 0);
1433        assert!(dog.tick(&done, None, at(9_999_999)).is_none());
1434    }
1435
1436    #[test]
1437    fn the_upgrade_log_appends_and_rotates_to_one_generation() {
1438        let dir = tempfile::tempdir().expect("temp dir");
1439        let path = dir.path().join("upgrade.log");
1440        append_bounded(&path, "one", 16).expect("append");
1441        append_bounded(&path, "two", 16).expect("append");
1442        assert_eq!(std::fs::read_to_string(&path).unwrap(), "one\ntwo\n");
1443        append_bounded(&path, "three-and-more", 16).expect("append");
1444        append_bounded(&path, "four", 16).expect("append");
1445        assert_eq!(std::fs::read_to_string(&path).unwrap(), "four\n");
1446        let old = dir.path().join("upgrade.log.1");
1447        assert!(
1448            std::fs::read_to_string(old)
1449                .unwrap()
1450                .contains("three-and-more")
1451        );
1452    }
1453}