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/// GitHub owner.
65const OWNER: &str = "yukimemi";
66/// GitHub repository — *not* `CARGO_PKG_NAME`, which is the published package.
67const REPO: &str = "magi";
68/// Binary inside the release asset.
69const BIN: &str = "magi";
70/// Published package name, for kaishin's `cargo install` fallback.
71const CRATE: &str = "magi-cli";
72
73/// This binary's own repository name, for `--repo .` auto-discovery
74/// (`web::serve`, `main::resolve_repo`) to match a checkout against without
75/// a second, driftable copy of [`REPO`] anywhere else.
76pub fn repo_name() -> &'static str {
77 REPO
78}
79
80/// kaishin options.
81///
82/// All four names are spelled out because three of them differ from
83/// `CARGO_PKG_NAME`: the package is `magi-cli` (the short name is a squatted
84/// placeholder on crates.io) while the repo, the binary and the library are
85/// `magi`. Deriving any of these from `CARGO_PKG_NAME` would send the updater
86/// looking for a `yukimemi/magi-cli` repository that does not exist.
87fn options() -> kaishin::KaishinOptions {
88 kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
89}
90
91/// Throttle bookkeeping is transient, so it belongs in the cache dir rather
92/// than beside the run history in the data dir.
93fn state_path() -> Option<PathBuf> {
94 dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
95}
96
97/// `magi self-update`.
98pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
99 let opts = kaishin::UpdateOptions::new()
100 .yes(yes)
101 .check_only(check_only)
102 .non_interactive(non_interactive);
103 kaishin::run_self_update(&options(), opts).await
104}
105
106/// A background update check, resolved at shutdown.
107pub enum Pending {
108 /// A previous run already found a newer release; just print the banner.
109 Cached {
110 /// For [`Checker::format_banner`].
111 checker: Checker,
112 /// The release found earlier.
113 latest: kaishin::LatestRelease,
114 },
115 /// A notify-mode check is in flight.
116 Notify {
117 /// For [`Checker::format_banner`].
118 checker: Checker,
119 /// The spawned task.
120 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
121 },
122 /// An install-mode update is in flight.
123 Install {
124 /// The spawned task.
125 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
126 },
127}
128
129/// Throttled release checker.
130#[derive(Clone)]
131pub struct Checker {
132 inner: kaishin::Checker,
133}
134
135impl Checker {
136 /// Build a checker honouring `cfg`, or `None` when checking is off.
137 ///
138 /// The `Option` had no `None` arm: every caller that asked for a checker
139 /// got one, so `[update] mode = "off"` was honoured by the *notify* path
140 /// alone (see [`cached_update`], which matches on the mode itself) and
141 /// ignored everywhere else. `POST /api/upgrade` therefore called the
142 /// GitHub releases API on a deck configured never to check - and so did
143 /// every unit test that reached that route, unauthenticated, against
144 /// GitHub's 60-per-hour-per-address limit.
145 ///
146 /// An operator who writes `mode = "off"` means it. The button is still
147 /// theirs to press; what it may not do is go to the network behind a
148 /// configuration that says not to.
149 pub fn new(cfg: &Update) -> Option<Self> {
150 if cfg.mode == UpdateMode::Off {
151 return None;
152 }
153 let mut inner = kaishin::Checker::new(BIN, options());
154 if let Some(path) = state_path() {
155 inner = inner.state_path(path);
156 }
157 Some(Self {
158 inner: inner.interval(effective_interval(cfg)),
159 })
160 }
161
162 /// Is a check due?
163 pub fn should_check(&self) -> bool {
164 self.inner.should_check()
165 }
166
167 /// Ask the forge now: is there a release newer than this build?
168 ///
169 /// Unlike [`Checker::cached_update`] this method does not consult
170 /// [`Checker::should_check`] itself - it has two callers, and they throttle
171 /// differently. `POST /api/upgrade` calls it unconditionally, because the
172 /// caller there is an operator who just pressed a button and is owed an
173 /// answer about the state of the world rather than about the last time
174 /// magi looked. `magi web`'s background recheck (`web::run_update_recheck`)
175 /// calls [`Checker::should_check`] itself first and only reaches here when
176 /// it says yes, which is what keeps that task's network use to at most
177 /// once per `[update] interval` no matter how often it polls.
178 pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
179 self.inner.check_and_save().await
180 }
181
182 /// A newer release already known from a previous run.
183 pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
184 self.inner.cached_update()
185 }
186
187 /// One-line "a newer version exists" banner.
188 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
189 self.inner.format_banner(latest)
190 }
191
192 /// A checker over an explicit state path and interval, for a test that
193 /// must control throttle timing without touching the operator's real
194 /// cache directory - see [`state_path`] for why sharing it would be
195 /// unsafe.
196 #[cfg(test)]
197 pub(crate) fn for_test(interval: Duration, state_path: PathBuf) -> Self {
198 let opts = kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION"));
199 Self {
200 inner: kaishin::Checker::new(BIN, opts)
201 .state_path(state_path)
202 .interval(interval),
203 }
204 }
205}
206
207/// How far a self-upgrade this deck set in motion has gotten.
208///
209/// `POST /api/upgrade` answers `202` and returns immediately - see its own
210/// doc for why - so [`Progress`] is the only way a phone that asked for an
211/// upgrade learns anything about it afterwards.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case")]
214pub enum Stage {
215 /// Downloading the release asset and replacing the binary. This bundles
216 /// what would otherwise be two stages: kaishin re-confirms the release
217 /// and downloads it inside one `await` with no hook to split, so from
218 /// here a phone cannot tell "still checking" from "still downloading" -
219 /// only that nothing has been swapped in yet. The confirmation that ran
220 /// *before* this stage started is already known to the phone: it is what
221 /// the `to` version in the `202` answered with.
222 Downloading,
223 /// The new binary is in place and [`crate::web`]'s handover has been
224 /// signalled, but has not acted yet.
225 Replaced,
226 /// The handover is waiting for the run in flight, if any, to reach its
227 /// next node boundary. The deck answers throughout this - it is not the
228 /// unreachable gap, see `web::hand_over`.
229 Parking,
230 /// The listener has been released and the successor is starting. This is
231 /// the one genuinely unreachable moment, and it is meant to be
232 /// sub-second - see `web::bind_waiting`.
233 Restarting,
234 /// A successor came up and confirmed it is running the release this
235 /// upgrade asked for.
236 Done,
237 /// The upgrade did not reach [`Stage::Done`]. `detail` on [`Progress`]
238 /// says why.
239 Failed,
240}
241
242impl Stage {
243 /// Finished, one way or the other - nothing is still moving.
244 #[must_use]
245 pub fn terminal(self) -> bool {
246 matches!(self, Self::Done | Self::Failed)
247 }
248}
249
250/// One upgrade's progress, persisted at [`progress_path`].
251///
252/// Kept on disk rather than in memory because the process that finishes an
253/// upgrade is never the one that started it: the successor is a fresh binary
254/// (see `web::spawn_successor`), and the only thing the two share is the
255/// disk. Beside the run history rather than under the cache dir alongside
256/// [`state_path`]: this is not throttle bookkeeping, it is the record of one
257/// upgrade the operator asked for, and - like a parked run - it is meant to
258/// outlive the process that wrote it.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct Progress {
261 /// Where this upgrade has gotten to.
262 pub stage: Stage,
263 /// Version this upgrade started from.
264 pub from: String,
265 /// Version it is replacing itself with.
266 pub to: Option<String>,
267 /// The run [`Stage::Parking`] is waiting on, when one was in flight.
268 #[serde(default)]
269 pub parked_run: Option<String>,
270 /// When this upgrade was asked for.
271 pub started_at: Timestamp,
272 /// Last time `stage` changed.
273 pub updated_at: Timestamp,
274 /// Why [`Stage::Failed`] happened; `None` for every other stage.
275 #[serde(default)]
276 pub detail: Option<String>,
277}
278
279impl Progress {
280 /// A fresh record for an upgrade that is about to replace the binary.
281 #[must_use]
282 pub fn new(from: String, to: String) -> Self {
283 let now = Timestamp::now();
284 Self {
285 stage: Stage::Downloading,
286 from,
287 to: Some(to),
288 parked_run: None,
289 started_at: now,
290 updated_at: now,
291 detail: None,
292 }
293 }
294
295 /// Move to `stage`, stamping when it changed.
296 pub fn advance(&mut self, stage: Stage) {
297 self.stage = stage;
298 self.updated_at = Timestamp::now();
299 }
300
301 /// Stop at [`Stage::Failed`], with a reason a human can read.
302 pub fn fail(&mut self, detail: impl Into<String>) {
303 self.stage = Stage::Failed;
304 self.updated_at = Timestamp::now();
305 self.detail = Some(detail.into());
306 }
307}
308
309/// Where [`Progress`] is recorded: beside `daemon.json`, not under the cache
310/// dir - see [`Progress`]'s own doc for why the two are not the same place.
311#[must_use]
312pub fn progress_path(home: &Path) -> PathBuf {
313 home.join("upgrade.json")
314}
315
316/// Persist `progress`, atomically.
317///
318/// Written to a sibling `.tmp` and renamed, the same reason
319/// `daemon::write_status_to` does it: `/api/health` reads this file on every
320/// poll and must never see a half-written one.
321pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
322 let path = progress_path(home);
323 if let Some(parent) = path.parent() {
324 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
325 }
326 let body = serde_json::to_string_pretty(progress).context("serialize upgrade progress")?;
327 let tmp = path.with_extension("json.tmp");
328 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
329 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
330 Ok(())
331}
332
333/// The last upgrade this deck recorded, if it has ever started one.
334#[must_use]
335pub fn read_progress(home: &Path) -> Option<Progress> {
336 let body = std::fs::read_to_string(progress_path(home)).ok()?;
337 serde_json::from_str(&body).ok()
338}
339
340/// Reconcile a leftover progress record on startup, before the server starts
341/// answering requests.
342///
343/// A non-terminal record on disk when a process starts can only mean one of
344/// two things: this *is* the successor `spawn_successor` started, or the
345/// predecessor died before finishing the handover (a crash, a reboot, an
346/// operator killing it by hand). Either way waiting longer will not resolve
347/// it - this process is already up - so it is settled immediately:
348/// [`Stage::Done`] when the running version matches what was asked for,
349/// [`Stage::Failed`] otherwise, so the operator is told rather than left
350/// watching a stage that will never move again.
351pub fn reconcile_after_restart(home: &Path) {
352 let Some(mut progress) = read_progress(home) else {
353 return;
354 };
355 if progress.stage.terminal() {
356 return;
357 }
358 let running = env!("CARGO_PKG_VERSION");
359 // `progress.to` is `latest.tag_name` from the forge, which - like every
360 // tag in this repository - carries a `v` prefix `CARGO_PKG_VERSION` does
361 // not. kaishin's own `is_update_available` strips it before comparing;
362 // an exact-string match here would call a successful upgrade `Failed`
363 // every time, because "v0.5.2" is never equal to "0.5.2".
364 if progress
365 .to
366 .as_deref()
367 .is_some_and(|to| to.trim_start_matches('v') == running)
368 {
369 progress.advance(Stage::Done);
370 } else {
371 let to = progress
372 .to
373 .clone()
374 .unwrap_or_else(|| "the expected release".to_owned());
375 progress.fail(format!(
376 "this process came up on {running}, not {to} - the upgrade may \
377 not have replaced the binary"
378 ));
379 }
380 let _ = write_progress(home, &progress);
381}
382
383/// Spawn the background check for `cfg`, unless it is switched off.
384pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
385 if disabled_by_env() || cfg.mode == UpdateMode::Off {
386 return None;
387 }
388 let checker = Checker::new(cfg)?;
389 match cfg.mode {
390 UpdateMode::Off => None,
391 UpdateMode::Notify => {
392 if !checker.should_check() {
393 let latest = checker.cached_update()?;
394 return Some(Pending::Cached { checker, latest });
395 }
396 let inner = checker.inner.clone();
397 let handle = rt.spawn(async move { inner.check_and_save().await });
398 Some(Pending::Notify { checker, handle })
399 }
400 UpdateMode::Install => {
401 let inner = checker.inner.clone();
402 let handle = rt.spawn(async move { inner.auto_update().await });
403 Some(Pending::Install { handle })
404 }
405 }
406}
407
408/// Drain a pending check and print at most one line.
409///
410/// Bounded on purpose: a slow network must never hold up the exit of a command
411/// that already did its work.
412pub async fn finalize(pending: Option<Pending>, budget: Duration) {
413 let Some(pending) = pending else {
414 return;
415 };
416 match pending {
417 Pending::Cached { checker, latest } => {
418 eprintln!("{}", checker.format_banner(&latest));
419 }
420 Pending::Notify { checker, handle } => {
421 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
422 eprintln!("{}", checker.format_banner(&latest));
423 }
424 }
425 Pending::Install { handle } => {
426 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
427 eprintln!("magi updated itself to {}", latest.tag_name);
428 }
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 /// An interval configured below GitHub's unauthenticated 60 req/hour/IP
438 /// limit must be floored, or `web`'s background recheck loop - which,
439 /// unlike a one-off CLI invocation, keeps polling for as long as `magi
440 /// web` stays up - would repeat the same call far past that limit.
441 #[test]
442 fn effective_interval_floors_a_configured_interval_below_githubs_rate_limit() {
443 let cfg = Update {
444 mode: UpdateMode::Notify,
445 interval: Some("1s".to_owned()),
446 };
447 assert_eq!(
448 effective_interval(&cfg),
449 MIN_INTERVAL,
450 "an interval that would exceed GitHub's rate limit under continuous \
451 polling must be floored rather than honoured verbatim"
452 );
453
454 let sane = Update {
455 mode: UpdateMode::Notify,
456 interval: Some("2h".to_owned()),
457 };
458 assert_eq!(
459 effective_interval(&sane),
460 Duration::from_secs(2 * 60 * 60),
461 "an interval already above the floor must pass through unchanged"
462 );
463 }
464
465 #[test]
466 fn env_kill_switch_semantics() {
467 // SAFETY: single-threaded test, no other thread reads the variable.
468 unsafe {
469 std::env::remove_var(NO_AUTOUPDATE_ENV);
470 }
471 assert!(!disabled_by_env());
472 for (value, disabled) in [
473 ("1", true),
474 ("true", true),
475 ("yes", true),
476 ("0", false),
477 ("false", false),
478 ("FALSE", false),
479 ("", false),
480 (" ", false),
481 ] {
482 unsafe {
483 std::env::set_var(NO_AUTOUPDATE_ENV, value);
484 }
485 assert_eq!(
486 disabled_by_env(),
487 disabled,
488 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
489 if disabled { "" } else { "not" }
490 );
491 }
492 unsafe {
493 std::env::remove_var(NO_AUTOUPDATE_ENV);
494 }
495 }
496
497 #[test]
498 fn off_mode_never_spawns() {
499 let rt = tokio::runtime::Builder::new_current_thread()
500 .enable_all()
501 .build()
502 .unwrap();
503 let cfg = Update {
504 mode: UpdateMode::Off,
505 interval: None,
506 };
507 assert!(spawn(&cfg, rt.handle()).is_none());
508 }
509
510 #[test]
511 fn state_path_lives_under_the_cache_dir() {
512 let path = state_path().expect("a cache dir on every supported platform");
513 assert!(path.ends_with("magi/last_update_check.json"));
514 let data = dirs::data_local_dir().unwrap_or_default();
515 assert!(
516 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
517 "throttle state must not sit in the run history directory"
518 );
519 }
520
521 #[tokio::test]
522 async fn finalize_of_nothing_is_a_no_op() {
523 finalize(None, Duration::from_millis(1)).await;
524 }
525
526 /// `mode = "off"` means no checker, for every caller.
527 ///
528 /// It used to mean it only for the notify path: `Checker::new` returned
529 /// `Some` unconditionally, so `POST /api/upgrade` went to the GitHub
530 /// releases API on a deck configured never to check. A test that reached
531 /// that route made a live, unauthenticated request, and GitHub's
532 /// 60-per-hour-per-address limit then turned the suite red on one runner
533 /// at a time - for as long as anyone kept re-running it, since each
534 /// attempt spent another request.
535 #[test]
536 fn checking_is_off_for_every_caller_when_the_config_says_off() {
537 assert!(
538 Checker::new(&Update {
539 mode: UpdateMode::Off,
540 interval: None,
541 })
542 .is_none(),
543 "an operator who writes mode = \"off\" means it"
544 );
545 for mode in [UpdateMode::Notify, UpdateMode::Install] {
546 assert!(
547 Checker::new(&Update {
548 mode,
549 interval: None,
550 })
551 .is_some(),
552 "{mode:?} still asks the forge"
553 );
554 }
555 }
556
557 /// `cached_update` never touches the network: a state file written the
558 /// way `check_and_save` writes one is enough to answer, and no file at
559 /// all answers "unknown" rather than blocking or erroring.
560 ///
561 /// Built from `kaishin::Checker` directly, with an explicit state path,
562 /// rather than through [`Checker::new`]: that constructor always points
563 /// at the real cache directory, which is right for production - every
564 /// `magi` invocation on the machine shares one throttle file - but wrong
565 /// for a test, which must never read or write the operator's actual
566 /// state.
567 #[test]
568 fn cached_update_answers_from_disk_with_no_network_call() {
569 let dir = tempfile::tempdir().expect("temp dir");
570 let path = dir.path().join("state.json");
571 let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
572 let checker = Checker {
573 inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
574 };
575
576 assert!(
577 checker.cached_update().is_none(),
578 "no state file yet must read as \"unknown\", not an error"
579 );
580
581 let state = kaishin::UpdateCheckState {
582 last_checked_unix: 0,
583 last_known_latest: Some("v9.9.9".to_owned()),
584 last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
585 };
586 kaishin::save_check_state(&path, &state).expect("seed the state file");
587
588 let latest = checker.cached_update().expect("a newer release was cached");
589 assert_eq!(latest.tag_name, "v9.9.9");
590 }
591
592 #[test]
593 fn reconcile_after_restart_confirms_a_matching_version() {
594 // `to` is `latest.tag_name` as the forge and this repository's own
595 // tags spell it - with a `v` - which `CARGO_PKG_VERSION` never
596 // carries. A test that leaves the `v` off would not have caught the
597 // exact-string-equality bug this function used to have.
598 let home = tempfile::tempdir().expect("temp home");
599 let mut progress = Progress::new(
600 "0.1.0".to_owned(),
601 format!("v{}", env!("CARGO_PKG_VERSION")),
602 );
603 progress.advance(Stage::Restarting);
604 write_progress(home.path(), &progress).expect("seed progress");
605
606 reconcile_after_restart(home.path());
607
608 let after = read_progress(home.path()).expect("progress on disk");
609 assert_eq!(
610 after.stage,
611 Stage::Done,
612 "the successor is running exactly the release that was asked for, \
613 `v` prefix and all"
614 );
615 }
616
617 #[test]
618 fn reconcile_after_restart_flags_a_mismatched_version() {
619 let home = tempfile::tempdir().expect("temp home");
620 let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
621 progress.advance(Stage::Restarting);
622 write_progress(home.path(), &progress).expect("seed progress");
623
624 reconcile_after_restart(home.path());
625
626 let after = read_progress(home.path()).expect("progress on disk");
627 assert_eq!(after.stage, Stage::Failed);
628 assert!(
629 after.detail.is_some_and(|d| d.contains("9.9.9")),
630 "the operator needs to know which release it did not come back on"
631 );
632 }
633
634 #[test]
635 fn reconcile_after_restart_leaves_a_settled_record_alone() {
636 let home = tempfile::tempdir().expect("temp home");
637 let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
638 progress.advance(Stage::Done);
639 write_progress(home.path(), &progress).expect("seed progress");
640
641 reconcile_after_restart(home.path());
642
643 let after = read_progress(home.path()).expect("progress on disk");
644 assert_eq!(
645 after.stage,
646 Stage::Done,
647 "an already-settled record must not be rewritten by a later, unrelated start"
648 );
649 }
650
651 #[test]
652 fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
653 let home = tempfile::tempdir().expect("temp home");
654 reconcile_after_restart(home.path());
655 assert!(read_progress(home.path()).is_none());
656 }
657}