Skip to main content

magi/
clean.rs

1//! The disk janitor: finished runs get their worktrees folded and the shared
2//! build cache is pruned to its cap. A run whose state magi cannot read is
3//! left alone here — see [`fold_due`] — and is only ever removed by an
4//! explicit operator action (`magi fold`, or the equivalent phone route).
5//!
6//! Everything policy-shaped — which statuses are foldable, how long a finished
7//! run is left alone, whether the cache is over its limit — is a pure function
8//! injected with numbers, so nothing here has to ask the operating system to
9//! be testable. The only I/O is the removal itself.
10
11use std::collections::BTreeSet;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context as _, Result, bail};
15use jiff::{SignedDuration, Timestamp};
16use serde::Deserialize;
17
18use crate::ask::Questions;
19use crate::config::Disk;
20use crate::run::{RunState, RunStatus, SCHEMA, short_of};
21
22use crate::disk::{Prune, dir_size, prune_dir};
23
24/// What one janitor pass did, for the caller's log line.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub struct Housekeeping {
27    /// Runs folded (worktrees dropped).
28    pub folded: usize,
29    /// Unused: automatic housekeeping never removes a run whose state it
30    /// cannot read (see [`fold_due`]), so this is always `0`. Kept on the
31    /// struct because [`crate::daemon`] already reports it and a run that
32    /// changes the shape of this type is a bigger diff than leaving a field
33    /// that is honest about counting nothing.
34    pub unreadable: usize,
35    /// Files dropped from the shared cache.
36    pub cache_files: usize,
37    /// Bytes freed from the shared cache.
38    pub cache_freed: u64,
39    /// Open questions abandoned because the run that asked them has already
40    /// settled where nothing is coming back to read an answer.
41    pub questions_abandoned: usize,
42}
43
44/// Run the janitor: fold due runs, then prune the cache if it is over its cap.
45///
46/// Both halves are best-effort; a jammed cache lock or a run whose worktree
47/// another borrower holds must not stop the other half. Errors are reported
48/// through `tracing::warn` - this is housekeeping, and the daemon keeps
49/// serving either way.
50pub async fn housekeep(
51    cfg: &crate::config::Config,
52    home: &Path,
53    worktrees_root: &Path,
54    now: Timestamp,
55) -> Housekeeping {
56    let mut out = Housekeeping::default();
57    if cfg.disk.auto_fold {
58        match fold_due(&home.join("runs"), home, worktrees_root, &cfg.disk, now).await {
59            Ok(folded) => out.folded = folded,
60            Err(e) => tracing::warn!("housekeep: fold due runs: {e:#}"),
61        }
62    }
63    // A cap of `0` is the operator's opt-out (see `Disk::cache_limit_bytes`);
64    // `prune_dir`'s `over_limit` cannot distinguish "cap of zero" from "cache
65    // must be emptied", so the opt-out is handled here, before the cache is
66    // ever measured - the same place `disk_gate` handles a zero
67    // `min_free_bytes`.
68    if cfg.disk.cache_limit_bytes > 0 {
69        if let Some(cache) = cfg.cache_dir() {
70            match prune_cache(&cache, cfg.disk.cache_limit_bytes) {
71                Ok(pruned) => {
72                    out.cache_files = pruned.files;
73                    out.cache_freed = pruned.freed;
74                }
75                Err(e) => tracing::warn!("housekeep: prune cache: {e:#}"),
76            }
77        }
78    }
79    // Unconditional, unlike the two passes above: this is not a disk policy
80    // with a cap or an opt-out, it is closing a gap `graph::Runner` itself
81    // cannot - a run that reached `Merged`/`Ready`/`Failed` before this
82    // cleanup existed, or whose process died between saving that status and
83    // abandoning the question it leaves behind (see `Runner::settle_questions`).
84    // Left alone, that question sits `open` forever: the owner's badge,
85    // banner and title all keep counting a decision nobody is left to read.
86    out.questions_abandoned =
87        abandon_settled_questions(&Questions::at(home.join("questions")), &home.join("runs"));
88    out
89}
90
91/// Abandon every open question whose run has already settled into a status
92/// nothing comes back from, worded with what the run became - the same
93/// cleanup `graph::Runner::settle_questions` runs the moment `status` lands
94/// there, for questions that missed it.
95///
96/// Scans questions rather than runs: the open list is normally short, and a
97/// run that never asked anything costs nothing here. A run this cannot read,
98/// deleted or written by a schema this build does not speak, is left alone
99/// the same as everywhere else in this module; the question stays open
100/// rather than guessed at.
101pub fn abandon_settled_questions(store: &Questions, runs: &Path) -> usize {
102    let waiting_on: BTreeSet<String> = store
103        .list()
104        .into_iter()
105        .filter(|q| q.status.open())
106        .map(|q| q.run)
107        .collect();
108    let mut abandoned = 0;
109    for run in waiting_on {
110        let Ok(meta) = read_meta(runs, &run) else {
111            continue;
112        };
113        match store.settle_run(&run, meta.status) {
114            Ok(n) => abandoned += n,
115            Err(e) => tracing::warn!("housekeep: abandon questions for {run}: {e:#}"),
116        }
117    }
118    abandoned
119}
120
121/// Fold every run that is finished, older than the grace period, and not being
122/// worked on; count them.
123///
124/// A run that magi can no longer read — a state file from another schema, a
125/// half-written `run.json` — is left exactly as it is. Automatic housekeeping
126/// cannot tell a mid-write file from one that will never parse again, and
127/// `<home>/runs/<id>/` is the evidence `magi stats` and the deck read; when
128/// unsure whether it is safe to touch, the janitor keeps rather than deletes
129/// (see the module docs). Discarding a record this unreadable is an explicit
130/// operator action (`magi fold`, or the equivalent phone route), never
131/// something that happens unattended.
132///
133/// Runnable statuses and runs newer than the grace period are also left
134/// alone; folding them would throw away work that is still the answer to
135/// somebody's question. `Merged` runs forget their winner's worktree (the
136/// merge already landed it); `Ready` and `Failed` runs keep it.
137pub async fn fold_due(
138    runs: &Path,
139    home: &Path,
140    _worktrees_root: &Path,
141    disk: &Disk,
142    now: Timestamp,
143) -> Result<usize> {
144    let mut folded = 0usize;
145    let mut ids: Vec<String> = std::fs::read_dir(runs)
146        .into_iter()
147        .flatten()
148        .flatten()
149        .filter(|e| e.path().join("run.json").is_file())
150        .map(|e| e.file_name().to_string_lossy().into_owned())
151        .collect();
152    ids.sort_unstable();
153    for id in ids {
154        if crate::daemon::is_working_on(home, &id, now) {
155            continue;
156        }
157        let Ok(meta) = read_meta(runs, &id) else {
158            continue;
159        };
160        if meta.status.resumable() || !due(now, meta.updated_at, disk.fold_grace_secs) {
161            continue;
162        }
163        // `read_meta` only demands `status` and `updated_at`, which an older
164        // schema's `run.json` can still supply; the stricter schema check in
165        // `read_state` can still fail here. That must not cost every other
166        // run its turn through this loop, so it is a skip, not a `?`.
167        let Ok(mut state) = read_state(runs, &id) else {
168            continue;
169        };
170        let drop_winner = state.status == RunStatus::Merged;
171        // One run's fold must not cost every later run its turn. A worktree
172        // another borrower holds, a branch git refuses to delete, a repository
173        // that has since moved: each is a reason this run cannot be folded
174        // now, and none is a reason to stop the pass. Left unfolded, it is
175        // simply due again next time; a `?` here stopped automatic folding
176        // permanently at the first such run (finding R3-1-1 of run 51a3).
177        match crate::graph::fold_run(&mut state, drop_winner).await {
178            Ok(_) => folded += 1,
179            Err(e) => tracing::warn!("housekeep: fold {id}: {e:#}"),
180        }
181    }
182    Ok(folded)
183}
184
185/// Is `updated` old enough, measured against `now`, that the run may fold?
186///
187/// Pure; the janitor compares against wallclock, tests inject both sides. The
188/// comparison is strict, so a run exactly at the edge of its grace period is
189/// left alone one more pass — the same convention as [`crate::disk::over_limit`].
190pub fn due(now: Timestamp, updated: Timestamp, grace_secs: u64) -> bool {
191    now.duration_since(updated) > SignedDuration::new(grace_secs as i64, 0)
192}
193
194/// The two fields the janitor decides on, read with a serde that tolerates
195/// everything else about the run being unreadable.
196#[derive(Deserialize)]
197struct Meta {
198    status: RunStatus,
199    updated_at: Timestamp,
200}
201
202/// Read `status` and `updated_at` straight off the state file, asking for
203/// nothing else. `Err` when the file is missing, not parseable, or a status in
204/// a version this build does not speak - all of which mean "unreadable".
205fn read_meta(runs: &Path, id: &str) -> Result<Meta> {
206    let path = runs.join(id).join("run.json");
207    let body =
208        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
209    let meta: Meta =
210        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
211    Ok(meta)
212}
213
214/// Read and version-check a whole run state from a runs directory.
215///
216/// Mirrors [`RunState::load`] but against an explicit directory rather than
217/// the process-global home.
218fn read_state(runs: &Path, id: &str) -> Result<RunState> {
219    let path = runs.join(id).join("run.json");
220    let body =
221        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
222    let state: RunState =
223        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
224    if state.schema != SCHEMA {
225        bail!(
226            "run {} was written by a different magi (schema {}, this build \
227             speaks {SCHEMA})",
228            state.id,
229            state.schema
230        );
231    }
232    Ok(state)
233}
234
235/// Remove a run that cannot be read: its state directory under `runs` and its
236/// worktree directory under `worktrees_root`.
237///
238/// The state file is the only record of a run's repository and branches, so a
239/// run this unreadable is discarded at the filesystem level - there is no
240/// candidate list to fold first. The worktrees live under
241/// [`crate::run::default_worktree_root`] unless the run's config relocated
242/// them, which an unreadable run cannot tell us; the default location is
243/// removed, and anything the run placed elsewhere is a leftover for whoever
244/// knows where it went.
245///
246/// Deleting a worktree directory by hand leaves its registration in git, and a
247/// registered path cannot be re-`worktree add`-ed until it is pruned - so every
248/// worktree is unregistered from its repository first, best-effort, via the
249/// `gitdir:` link git keeps inside the directory.
250pub async fn fold_unreadable(runs: &Path, worktrees_root: &Path, id: &str) -> Result<Vec<String>> {
251    let resolved = resolve_id_path(runs, id)?;
252    let mut removed = Vec::new();
253    let run_dir = runs.join(&resolved);
254    if run_dir.exists() {
255        std::fs::remove_dir_all(&run_dir)
256            .with_context(|| format!("remove {}", run_dir.display()))?;
257        removed.push(format!("runs/{resolved}"));
258    }
259    let wt = worktrees_root.join(short_of(&resolved));
260    if wt.exists() {
261        crate::git::remove_worktree_from_linked(&wt).await;
262        for e in std::fs::read_dir(&wt).into_iter().flatten().flatten() {
263            crate::git::remove_worktree_from_linked(&e.path()).await;
264        }
265        std::fs::remove_dir_all(&wt).with_context(|| format!("remove {}", wt.display()))?;
266        removed.push(wt.to_string_lossy().into_owned());
267    }
268    Ok(removed)
269}
270
271/// Resolve an id or prefix against an explicit runs directory, exactly the way
272/// [`crate::run::resolve_id`] does against the global home.
273fn resolve_id_path(runs: &Path, prefix: &str) -> Result<String> {
274    // Keyed on the directory, not on a readable state file: the record this
275    // route exists to remove may be a lone `run.json.tmp` from a save that
276    // ran out of disk, and that is precisely the one a human needs a way to
277    // clear (see `crate::run::list_ids`).
278    if runs.join(prefix).is_dir() && crate::run::is_run_id(prefix) {
279        return Ok(prefix.to_owned());
280    }
281    let mut hits: Vec<String> = Vec::new();
282    for e in std::fs::read_dir(runs).into_iter().flatten().flatten() {
283        if !e.path().is_dir() {
284            continue;
285        }
286        let id = e.file_name().to_string_lossy().into_owned();
287        if crate::run::is_run_id(&id) && (id.starts_with(prefix) || id.ends_with(prefix)) {
288            hits.push(id);
289        }
290    }
291    match hits.len() {
292        1 => Ok(hits.into_iter().next().expect("exactly one hit")),
293        0 => bail!("no run matches `{prefix}`"),
294        _ => bail!(
295            "`{prefix}` matches {} runs: {}",
296            hits.len(),
297            hits.join(", ")
298        ),
299    }
300}
301
302/// Delete files from the shared build cache until it fits its cap.
303///
304/// See [`crate::disk::prune_dir`] for the oldest-first policy.
305pub fn prune_cache(cache: &Path, limit_bytes: u64) -> Result<Prune> {
306    prune_dir(cache, limit_bytes)
307}
308
309/// The cache's path, size and cap, for `magi cache show` and the health view.
310/// `None` when the config declares no `CARGO_TARGET_DIR` to aggregate.
311///
312/// A cap of `0` means the operator opted out of pruning; the size is then
313/// reported but never acted on.
314pub fn cache_report(cfg: &crate::config::Config) -> Option<(PathBuf, u64, u64)> {
315    let cache = cfg.cache_dir()?;
316    Some((
317        cache.clone(),
318        cache_size(&cache),
319        cfg.disk.cache_limit_bytes,
320    ))
321}
322
323/// Size in bytes of the shared build cache.
324pub fn cache_size(cache: &Path) -> u64 {
325    dir_size(cache)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::config::Disk;
332    use std::fs;
333
334    fn ts(s: &str) -> Timestamp {
335        s.parse().expect("rfc3339")
336    }
337
338    fn block_on<F: std::future::Future>(f: F) -> F::Output {
339        tokio::runtime::Runtime::new().expect("runtime").block_on(f)
340    }
341
342    #[test]
343    fn a_run_is_due_after_its_grace_and_not_before() {
344        let now = ts("2026-09-05T00:00:00Z");
345        let grace = 600;
346        let old = now - SignedDuration::new(601, 0);
347        let fresh = now - SignedDuration::new(599, 0);
348        assert!(due(now, old, grace));
349        assert!(!due(now, fresh, grace));
350        // Exactly at the edge: not yet due.
351        let edge = now - SignedDuration::new(600, 0);
352        assert!(!due(now, edge, grace));
353        // A zero grace folds everything, ever.
354        assert!(due(now, old, 0));
355    }
356
357    #[test]
358    fn the_meta_reader_is_tolerant_of_everything_except_the_deciders() {
359        let dir = tempfile::tempdir().unwrap();
360        let runs = dir.path().join("runs");
361        let id = "20260905-000000-abcd";
362        std::fs::create_dir_all(runs.join(id)).unwrap();
363        std::fs::write(
364            runs.join(id).join("run.json"),
365            r#"{"schema": 99, "id": "20260905-000000-abcd", "updated_at": "2026-09-05T00:00:00Z", "status": "ready", "junk_from_another_build": [1, 2, 3]}"#,
366        )
367        .unwrap();
368        let meta = read_meta(&runs, id).expect("readable");
369        assert_eq!(meta.status, RunStatus::Ready);
370        assert_eq!(meta.updated_at, ts("2026-09-05T00:00:00Z"));
371        assert!(read_meta(&runs, "nope").is_err(), "missing file unreadable");
372        std::fs::write(runs.join(id).join("run.json"), "not json at all").unwrap();
373        assert!(read_meta(&runs, id).is_err(), "garbage unreadable");
374    }
375
376    #[test]
377    fn fold_unreadable_releases_run_dir_and_worktrees() {
378        let dir = tempfile::tempdir().unwrap();
379        let runs = dir.path().join("runs");
380        let wt = dir.path().join("wt");
381        let id = "20260905-000000-abcd";
382        std::fs::create_dir_all(runs.join(id)).unwrap();
383        std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
384        std::fs::create_dir_all(wt.join("abcd")).unwrap();
385        std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
386
387        let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold");
388        assert_eq!(removed.len(), 2);
389        assert!(!runs.join(id).exists(), "run dir gone");
390        assert!(!wt.join("abcd").exists(), "worktrees gone");
391
392        // A prefix resolves like `run::resolve_id` does.
393        std::fs::create_dir_all(runs.join(id)).unwrap();
394        std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
395        std::fs::create_dir_all(wt.join("abcd")).unwrap();
396        std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
397        let removed = block_on(fold_unreadable(&runs, &wt, "20260905")).expect("by prefix");
398        assert_eq!(removed.len(), 2);
399        // Once gone, `id` cannot be resolved at all - same as `run::resolve_id`
400        // on an id nothing on disk matches - so a repeat pass errors rather
401        // than silently reporting nothing removed.
402        assert!(
403            block_on(fold_unreadable(&runs, &wt, id)).is_err(),
404            "a run already gone cannot be resolved again"
405        );
406    }
407
408    #[test]
409    fn prune_cache_sheds_the_oldest_generation_until_it_fits() {
410        let dir = tempfile::tempdir().unwrap();
411        // Same size, different age: only the age decides, and the newest
412        // generation - the one the next build reuses - is what survives.
413        fs::write(dir.path().join("old"), b"xx").unwrap();
414        fs::write(dir.path().join("new"), b"yy").unwrap();
415        touch(&dir.path().join("old"), 1_000_000);
416        touch(&dir.path().join("new"), 2_000_000);
417
418        let out = prune_cache(dir.path(), 2).expect("prune");
419        assert_eq!(out.files, 1, "one deletion is enough to reach the cap");
420        assert_eq!(out.remaining, 2);
421        assert!(!dir.path().join("old").exists(), "the older file went");
422        assert!(dir.path().join("new").exists(), "the newer one stayed");
423
424        // A whole generation shares one timestamp tick, so the tie has to be
425        // decided too: largest first, which reaches the cap in the fewest
426        // deletions. Left to `read_dir` and an unstable sort this deleted
427        // both files on Linux and one on Windows.
428        let tied = tempfile::tempdir().unwrap();
429        fs::write(tied.path().join("big"), b"xxxx").unwrap();
430        fs::write(tied.path().join("small"), b"yy").unwrap();
431        touch(&tied.path().join("big"), 1_000_000);
432        touch(&tied.path().join("small"), 1_000_000);
433        let out = prune_cache(tied.path(), 2).expect("prune");
434        assert_eq!(out.files, 1, "the big one alone gets under the cap");
435        assert_eq!(out.remaining, 2);
436        assert!(tied.path().join("small").exists());
437    }
438
439    /// Pin a file's mtime, so a test asserts the policy and not the runner's
440    /// timestamp granularity.
441    fn touch(path: &Path, secs: u64) {
442        let f = fs::File::options().write(true).open(path).unwrap();
443        f.set_times(fs::FileTimes::new().set_modified(
444            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs),
445        ))
446        .unwrap();
447    }
448
449    /// The disk-full casualty: a run whose first save left `run.json.tmp` and
450    /// nothing else. It has to be clearable, or the record is permanent.
451    #[test]
452    fn fold_unreadable_clears_a_run_whose_state_never_landed() {
453        let dir = tempfile::tempdir().unwrap();
454        let runs = dir.path().join("runs");
455        let wt = dir.path().join("wt");
456        let id = "20260904-014540-88c0";
457        std::fs::create_dir_all(runs.join(id)).unwrap();
458        std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
459
460        let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold by id");
461        assert_eq!(removed, vec![format!("runs/{id}")]);
462        assert!(!runs.join(id).exists(), "record gone");
463
464        // And by prefix, the way the deck and the phone address a run.
465        std::fs::create_dir_all(runs.join(id)).unwrap();
466        std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
467        assert!(
468            block_on(fold_unreadable(&runs, &wt, "88c0")).is_ok(),
469            "by prefix"
470        );
471
472        // A directory under `runs` that is not a run is never a fold target.
473        std::fs::create_dir_all(runs.join("scratch")).unwrap();
474        assert!(
475            block_on(fold_unreadable(&runs, &wt, "scratch")).is_err(),
476            "a stray directory is not a run"
477        );
478    }
479
480    #[test]
481    fn fold_due_skips_fresh_runnable_and_unreadable_but_folds_a_due_terminal_run() {
482        let dir = tempfile::tempdir().unwrap();
483        let runs = dir.path().join("runs");
484        let wt = dir.path().join("wt");
485        let home = dir.path().to_path_buf();
486        let disk = Disk::default();
487        let now = ts("2026-09-05T00:00:00Z");
488        // `graph::fold_run` (invoked below for the due, readable run) saves
489        // through the process-global home; pinning it to this test's own
490        // directory is what keeps that write off the operator's real one (see
491        // `run::home`'s doc). Harmless if another test already pinned it
492        // first - this test never reads that global value back.
493        crate::run::set_home(dir.path().to_path_buf());
494
495        // 1. Runnable (judging): never folded, however old.
496        let judging = "20260801-000000-0001";
497        write_meta(&runs, judging, "judging", "2026-08-01T00:00:00Z");
498
499        // 2. Finished but fresh: grace not elapsed.
500        let ready_fresh = "20260904-000000-0002";
501        write_meta(&runs, ready_fresh, "ready", "2026-09-04T00:00:00Z");
502
503        // 3. Unreadable: left alone. Automatic housekeeping never deletes a
504        //    run record it cannot parse (see `fold_due`'s docs); that is an
505        //    explicit operator action, not something a background pass does.
506        let garbage = "20260901-000000-0004";
507        std::fs::create_dir_all(runs.join(garbage)).unwrap();
508        std::fs::write(runs.join(garbage).join("run.json"), "not json").unwrap();
509        std::fs::create_dir_all(wt.join("0004")).unwrap();
510
511        // 4. Finished, well past grace, and readable: this is the one run
512        //    `fold_due` should actually act on.
513        let due_ready = "20260801-000000-ffff";
514        let mut ready_state = RunState::new(
515            PathBuf::from("/nonexistent/repo"),
516            "main".to_owned(),
517            "0000000000000000000000000000000000000000".to_owned(),
518            String::new(),
519            crate::config::Config::default(),
520        );
521        ready_state.id = due_ready.to_owned();
522        ready_state.status = RunStatus::Ready;
523        ready_state.updated_at = ts("2026-08-01T00:00:00Z");
524        std::fs::create_dir_all(runs.join(due_ready)).unwrap();
525        std::fs::write(
526            runs.join(due_ready).join("run.json"),
527            serde_json::to_string_pretty(&ready_state).unwrap(),
528        )
529        .unwrap();
530
531        let folded = block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
532        assert_eq!(folded, 1, "only the due, readable run");
533        assert!(runs.join(judging).exists(), "runnable never folded");
534        assert!(runs.join(ready_fresh).exists(), "fresh never folded");
535        assert!(runs.join(garbage).exists(), "unreadable record kept");
536        assert!(wt.join("0004").exists(), "unreadable worktree kept");
537        assert!(
538            runs.join(due_ready).exists(),
539            "folding drops worktrees, not the record"
540        );
541    }
542
543    /// A fresh open question on `run`, stored and handed back for assertions.
544    fn open_question(store: &Questions, run: &str) -> crate::ask::Question {
545        let mut q = crate::ask::Question::new(
546            run.to_owned(),
547            "implement".to_owned(),
548            "impl-A".to_owned(),
549            "Which storage backend should the cache use?".to_owned(),
550            String::new(),
551            vec!["SQLite".to_owned(), "Redis".to_owned()],
552        );
553        store.put(&mut q).unwrap();
554        q
555    }
556
557    /// The exact ghost the phone showed: a run that already finished, with a
558    /// question its dead seat asked still sitting `open` because it reached
559    /// that status before `graph::Runner::settle_questions` existed (or
560    /// missed it in the crash window `daemon::reclaim_orphaned_running`
561    /// covers). This sweep is the second door to the same fact.
562    #[test]
563    fn a_finished_runs_open_question_is_swept_up() {
564        let dir = tempfile::tempdir().unwrap();
565        let runs = dir.path().join("runs");
566        let store = Questions::at(dir.path().join("questions"));
567
568        let failed = "20260908-205802-c9eb";
569        write_meta(&runs, failed, "failed", "2026-09-08T20:58:02Z");
570        let failed_q = open_question(&store, failed);
571
572        let merged = "20260908-205501-ca67";
573        write_meta(&runs, merged, "merged", "2026-09-08T20:55:01Z");
574        let merged_q = open_question(&store, merged);
575
576        let n = abandon_settled_questions(&store, &runs);
577        assert_eq!(n, 2, "both dead runs' questions are swept in one pass");
578
579        for (id, run) in [(&failed_q.id, failed), (&merged_q.id, merged)] {
580            let back = store.get(id).unwrap();
581            assert!(!back.status.open(), "{run} is done; nobody reads an answer");
582            assert!(back.detail.contains(run), "{}", back.detail);
583        }
584    }
585
586    #[test]
587    fn a_still_alive_runs_open_question_survives_the_sweep() {
588        let dir = tempfile::tempdir().unwrap();
589        let runs = dir.path().join("runs");
590        let store = Questions::at(dir.path().join("questions"));
591
592        // `Blocked` and `Stalled` are `RunStatus::resumable`: the run can
593        // still be picked back up, so its question may yet get a real
594        // answer. A run still mid-competition is even more obviously alive.
595        for (id, status) in [
596            ("20260908-000000-b10c", "blocked"),
597            ("20260908-000000-5ta1", "stalled"),
598            ("20260908-000000-jud6", "judging"),
599        ] {
600            write_meta(&runs, id, status, "2026-09-08T00:00:00Z");
601            let q = open_question(&store, id);
602
603            let n = abandon_settled_questions(&store, &runs);
604            assert_eq!(n, 0, "{status} run is not done; nothing to sweep");
605            assert!(
606                store.get(&q.id).unwrap().status.open(),
607                "{status} run's question must still be waiting"
608            );
609        }
610    }
611
612    #[test]
613    fn the_sweep_leaves_an_answered_question_and_an_unreadable_run_alone() {
614        let dir = tempfile::tempdir().unwrap();
615        let runs = dir.path().join("runs");
616        let store = Questions::at(dir.path().join("questions"));
617
618        // Already decided: a sweep must never revisit it, whatever the run
619        // that asked went on to become.
620        let done = "20260908-000000-answ";
621        write_meta(&runs, done, "failed", "2026-09-08T00:00:00Z");
622        let mut answered = open_question(&store, done);
623        answered
624            .answer(crate::ask::Answer::Choice("SQLite".to_owned()))
625            .unwrap();
626        store.put(&mut answered).unwrap();
627
628        // No `run.json` at all for this one - deleted, or never landed.
629        let gone = "20260908-000000-gone";
630        let orphan = open_question(&store, gone);
631
632        assert_eq!(abandon_settled_questions(&store, &runs), 0);
633        assert_eq!(
634            store.get(&answered.id).unwrap().status,
635            crate::ask::QuestionStatus::Answered,
636            "a real answer is never overwritten by a sweep"
637        );
638        assert!(
639            store.get(&orphan.id).unwrap().status.open(),
640            "a run this sweep cannot read is left exactly as it was, not guessed at"
641        );
642    }
643
644    /// Write a whole `run.json` that magi can read, over the given state.
645    fn write_meta(runs: &Path, id: &str, status: &str, updated_at: &str) {
646        let day = &updated_at[..10];
647        std::fs::create_dir_all(runs.join(id)).unwrap();
648        let body = format!(
649            r#"{{"schema": {SCHEMA}, "id": "{id}", "repo": "/nonexistent/repo", "base_branch": "main", "base_commit": "0000000000000000000000000000000000000000", "instruction": "", "created_at": "{day}T00:00:00Z", "updated_at": "{updated_at}", "status": "{status}", "seed": 1}}"#
650        );
651        std::fs::write(runs.join(id).join("run.json"), body).unwrap();
652    }
653}