Skip to main content

magi/
clean.rs

1//! The disk janitor: finished runs get their worktrees folded, worktrees whose
2//! run record is already gone get reclaimed too, and the shared build cache is
3//! pruned to its cap.
4//!
5//! A run's state being written by an older schema is not the same thing as it
6//! being unreadable, and this module used to conflate the two: [`fold_due`]
7//! treated any `run.json` its version check rejected exactly like one that
8//! failed to parse at all, so a single schema bump silently stopped every
9//! automatic fold in the fleet the moment it shipped, and did so with no
10//! counter and no log line to say so. A record magi genuinely cannot parse —
11//! missing fields, broken JSON, a schema newer than this build has ever heard
12//! of — is still left alone here, still counted in
13//! [`Housekeeping::unreadable`], and still only ever removed by an explicit
14//! operator action (`magi fold`, or the equivalent phone route). One written
15//! by a schema this build merely disagrees with the *meaning* of is not that:
16//! as long as it still parses, folding proceeds regardless of the number in
17//! its `schema` field.
18//!
19//! Everything policy-shaped — which statuses are foldable, how long a finished
20//! run is left alone, whether the cache is over its limit — is a pure function
21//! injected with numbers, so nothing here has to ask the operating system to
22//! be testable. The only I/O is the removal itself.
23
24use std::collections::BTreeSet;
25use std::path::{Path, PathBuf};
26
27use anyhow::{Context as _, Result, bail};
28use jiff::{SignedDuration, Timestamp};
29use serde::Deserialize;
30
31use crate::ask::Questions;
32use crate::config::Disk;
33use crate::run::{RunState, RunStatus, SCHEMA, short_of};
34
35use crate::disk::{Prune, dir_size};
36
37/// What one janitor pass did, for the caller's log line.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub struct Housekeeping {
40    /// Runs folded (worktrees dropped).
41    pub folded: usize,
42    /// Runs [`fold_due`] left alone because their `run.json` genuinely could
43    /// not be read - missing, broken JSON, a schema this build has never
44    /// heard of - as opposed to one merely written by a different schema
45    /// number, which is folded like any other (see the module docs). This was
46    /// defined but never incremented for a long stretch of this module's
47    /// history, which is exactly how 90 of 93 runs sat unfolded on one
48    /// operator's machine with nothing anywhere saying why: every one of them
49    /// was misclassified as unreadable by a schema check that has since been
50    /// narrowed to only the runs that actually are.
51    pub unreadable: usize,
52    /// Worktrees under the worktree bay reclaimed because no run record in
53    /// `runs/` claims them anymore (see [`fold_orphaned_worktrees`]).
54    pub orphaned_worktrees: usize,
55    /// Files dropped from the shared cache.
56    pub cache_files: usize,
57    /// Bytes freed from the shared cache.
58    pub cache_freed: u64,
59    /// Open questions abandoned because the run that asked them has already
60    /// settled where nothing is coming back to read an answer.
61    pub questions_abandoned: usize,
62    /// Runs found blocked on a merge magi never recorded — a pull request the
63    /// operator merged by hand while `land::land` never got as far as
64    /// opening one itself — and corrected automatically. See
65    /// [`reconcile_external_merges`].
66    pub external_merges_recorded: usize,
67    /// Run records that still called their pull request open after it was
68    /// merged or closed, and were rewritten (see
69    /// [`crate::land::repair_stale_pr_states`]).
70    pub stale_pr_states_repaired: usize,
71}
72
73/// Run the janitor: fold due runs, reclaim orphaned worktrees, prune stale
74/// worktree registrations, then prune the cache if it is over its cap.
75///
76/// Every part is best-effort; a jammed cache lock or a run whose worktree
77/// another borrower holds must not stop the rest. Errors are reported through
78/// `tracing::warn` - this is housekeeping, and the daemon keeps serving
79/// either way.
80pub async fn housekeep(
81    cfg: &crate::config::Config,
82    home: &Path,
83    worktrees_root: &Path,
84    repo: &Path,
85    now: Timestamp,
86) -> Housekeeping {
87    let mut out = Housekeeping::default();
88    crate::retention::sweep(home, &cfg.disk, now).await;
89    if cfg.disk.auto_fold {
90        let runs = home.join("runs");
91        match fold_due(&runs, home, worktrees_root, &cfg.disk, now).await {
92            Ok((folded, unreadable)) => {
93                out.folded = folded;
94                out.unreadable = unreadable;
95            }
96            Err(e) => {
97                tracing::warn!("housekeep: fold due runs: {e:#}");
98                // Stable wording: the reason varies per pass, and a changed
99                // message would relight the bell on every retry.
100                crate::notices::raise_in(
101                    home,
102                    crate::notices::Notice::warn(
103                        "housekeep:fold",
104                        "Automatic cleanup of finished runs failed; disk usage may keep growing.",
105                    ),
106                );
107            }
108        }
109        out.orphaned_worktrees =
110            fold_orphaned_worktrees(&runs, worktrees_root, home, cfg.disk.fold_grace_secs, now)
111                .await;
112        // Best-effort in the same sense as everything else here: a repository
113        // this janitor pass has nothing to do with (or none at all, in a unit
114        // test) must not turn a `warn` into a reason to skip the rest.
115        if let Err(e) = crate::git::worktree_prune(repo).await {
116            tracing::warn!("housekeep: prune worktree registrations: {e:#}");
117        }
118        out.external_merges_recorded = reconcile_external_merges(&runs, home, &cfg.disk, now).await;
119        // Bounded: a handful of forge lookups a pass keeps this cheap, and the
120        // pass is idempotent, so a backlog drains over several passes.
121        out.stale_pr_states_repaired = crate::land::repair_stale_pr_states(home, 5).await.0;
122    }
123    match prune_cache_if_over_limit(cfg, home) {
124        Ok(Some(pruned)) => {
125            out.cache_files = pruned.files;
126            out.cache_freed = pruned.freed;
127        }
128        Ok(None) => {}
129        Err(e) => tracing::warn!("housekeep: prune cache: {e:#}"),
130    }
131    // Unconditional, unlike the two passes above: this is not a disk policy
132    // with a cap or an opt-out, it is closing a gap `graph::Runner` itself
133    // cannot - a run that reached `Merged`/`Ready`/`Failed` before this
134    // cleanup existed, or whose process died between saving that status and
135    // abandoning the question it leaves behind (see `Runner::settle_questions`).
136    // Left alone, that question sits `open` forever: the owner's badge,
137    // banner and title all keep counting a decision nobody is left to read.
138    out.questions_abandoned =
139        abandon_settled_questions(&Questions::at(home.join("questions")), &home.join("runs"));
140    out
141}
142
143/// Abandon every open question whose run has already settled into a status
144/// nothing comes back from, worded with what the run became - the same
145/// cleanup `graph::Runner::settle_questions` runs the moment `status` lands
146/// there, for questions that missed it.
147///
148/// Scans questions rather than runs: the open list is normally short, and a
149/// run that never asked anything costs nothing here. A run this cannot read,
150/// deleted or written by a schema this build does not speak, is left alone
151/// the same as everywhere else in this module; the question stays open
152/// rather than guessed at.
153pub fn abandon_settled_questions(store: &Questions, runs: &Path) -> usize {
154    let waiting_on: BTreeSet<String> = store
155        .list()
156        .into_iter()
157        .filter(|q| q.status.open())
158        .map(|q| q.run)
159        .collect();
160    let mut abandoned = 0;
161    for run in waiting_on {
162        let Ok(meta) = read_meta(runs, &run) else {
163            continue;
164        };
165        match store.settle_run(&run, meta.status) {
166            Ok(n) => abandoned += n,
167            Err(e) => tracing::warn!("housekeep: abandon questions for {run}: {e:#}"),
168        }
169    }
170    abandoned
171}
172
173/// Fold every run that is finished, older than the grace period, and not being
174/// worked on; return `(folded, unreadable)`.
175///
176/// A run whose `run.json` genuinely cannot be parsed — missing fields, broken
177/// JSON, a schema newer than this build has ever heard of — is left exactly
178/// as it is. Automatic housekeeping cannot tell a mid-write file from one that
179/// will never parse again, and `<home>/runs/<id>/` is the evidence `magi
180/// stats` and the deck read; when unsure whether it is safe to touch, the
181/// janitor keeps rather than deletes (see the module docs). Discarding a
182/// record this unreadable is an explicit operator action (`magi fold`, or the
183/// equivalent phone route), never something that happens unattended. Every
184/// such skip is counted in the returned `unreadable` and logged through
185/// `tracing::warn` with the parse failure that caused it - silence here is
186/// exactly the failure mode that let 90 of 93 runs sit unfolded with nothing
187/// to show for it.
188///
189/// A run merely written by a *different* schema number is not unreadable: as
190/// long as `run.json` still parses, it folds like any other terminal run (see
191/// the module docs for why the two are different questions).
192///
193/// Runnable statuses and runs newer than the grace period are also left
194/// alone; folding them would throw away work that is still the answer to
195/// somebody's question. `Merged` runs forget their winner's worktree (the
196/// merge already landed it); `Ready` and `Failed` runs keep it.
197pub async fn fold_due(
198    runs: &Path,
199    home: &Path,
200    _worktrees_root: &Path,
201    disk: &Disk,
202    now: Timestamp,
203) -> Result<(usize, usize)> {
204    let mut folded = 0usize;
205    let mut unreadable = 0usize;
206    let mut ids: Vec<String> = std::fs::read_dir(runs)
207        .into_iter()
208        .flatten()
209        .flatten()
210        .filter(|e| e.path().join("run.json").is_file())
211        .map(|e| e.file_name().to_string_lossy().into_owned())
212        .collect();
213    ids.sort_unstable();
214    for id in ids {
215        if crate::daemon::is_working_on(home, &id, now) {
216            continue;
217        }
218        let meta = match read_meta(runs, &id) {
219            Ok(meta) => meta,
220            Err(e) => {
221                unreadable += 1;
222                tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
223                continue;
224            }
225        };
226        if meta.status.resumable() || !due(now, meta.updated_at, disk.fold_grace_secs) {
227            continue;
228        }
229        // `read_meta` already proved the file parses; `read_state` asks for
230        // the rest of the fields `graph::fold_run` needs (worktree paths,
231        // candidates, tally). A schema mismatch alone does not fail this -
232        // see the module docs - so reaching `Err` here means the JSON itself
233        // is broken in a way `read_meta` did not exercise, which is rare but
234        // not impossible (a body truncated between the two fields it reads
235        // and the rest). That must not cost every other run its turn through
236        // this loop, so it is a skip, not a `?`.
237        let mut state = match read_state(runs, &id) {
238            Ok(state) => state,
239            Err(e) => {
240                unreadable += 1;
241                tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
242                continue;
243            }
244        };
245        if state.startup.as_ref().is_some_and(|s| !s.complete) {
246            continue;
247        }
248        if let Some(reason) = crate::pr_recovery::source_retention_reason(home, &state) {
249            tracing::info!(%id, %reason, "housekeep: source retained; fold refused");
250            continue;
251        }
252        if state.schema != SCHEMA {
253            tracing::info!(
254                "housekeep: run {id} was written by schema {} (this build speaks {SCHEMA}); \
255                 folding it anyway",
256                state.schema
257            );
258        }
259        // `Merged`'s winner branch is safe to drop because it already landed
260        // in the real target branch; `Superseded`'s winner branch is safe to
261        // drop for a different reason - a later attempt at the same task is
262        // what actually landed (or didn't), so *this* run's own winner is
263        // never going to be merged by anyone. Every other terminal status
264        // keeps the winner: `Ready`/`Blocked`/`Stalled`/`Failed`/`VerifiedNoop`
265        // may still have a human's decision pending on that exact branch.
266        // `AlreadyInBase` is the same: the change is on the base under other
267        // commit ids, so the branch holds nothing anyone still needs.
268        let drop_winner = matches!(
269            state.status,
270            RunStatus::Merged | RunStatus::Superseded | RunStatus::AlreadyInBase
271        );
272        // One run's fold must not cost every later run its turn. A worktree
273        // another borrower holds, a branch git refuses to delete, a repository
274        // that has since moved: each is a reason this run cannot be folded
275        // now, and none is a reason to stop the pass. Left unfolded, it is
276        // simply due again next time; a `?` here stopped automatic folding
277        // permanently at the first such run (finding R3-1-1 of run 51a3).
278        match crate::graph::fold_run(&mut state, drop_winner, home).await {
279            Ok(_) => folded += 1,
280            Err(e) => tracing::warn!("housekeep: fold {id}: {e:#}"),
281        }
282    }
283    Ok((folded, unreadable))
284}
285
286/// What one [`fetch_origins`] pass did.
287#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
288pub struct FetchReport {
289    /// Repos whose fetch succeeded.
290    pub fetched: usize,
291    /// Repos with no `origin` remote, skipped.
292    pub no_origin: usize,
293    /// Repos whose fetch failed or timed out.
294    pub failed: usize,
295}
296
297/// Run `git fetch origin` in every checkout under `roots` (the set `magi
298/// repos` lists), so `origin/main` stays current.
299///
300/// Fetch only: never a checkout, merge or fast-forward, so no HEAD, branch or
301/// working tree moves. Best-effort like [`fold_due`]: a repo with no `origin`
302/// is skipped silently, a failure or timeout is a warning, and the pass never
303/// errors. `stop` is checked between repos.
304pub async fn fetch_origins(
305    roots: &[PathBuf],
306    per_repo: std::time::Duration,
307    stop: impl Fn() -> bool,
308) -> FetchReport {
309    let mut report = FetchReport::default();
310    for repo in crate::repos::scan(roots) {
311        if stop() {
312            break;
313        }
314        match crate::git::git_raw(&repo.path, &["remote", "get-url", "origin"]).await {
315            Ok(out) if out.ok() => {}
316            _ => {
317                report.no_origin += 1;
318                continue;
319            }
320        }
321        match crate::git::fetch_origin(&repo.path, per_repo).await {
322            Ok(()) => report.fetched += 1,
323            Err(e) => {
324                tracing::warn!("fetch origin in {}: {e:#}", repo.name);
325                report.failed += 1;
326            }
327        }
328    }
329    report
330}
331
332/// Is `updated` old enough, measured against `now`, that the run may fold?
333///
334/// Pure; the janitor compares against wallclock, tests inject both sides. The
335/// comparison is strict, so a run exactly at the edge of its grace period is
336/// left alone one more pass — the same convention as [`crate::disk::over_limit`].
337pub fn due(now: Timestamp, updated: Timestamp, grace_secs: u64) -> bool {
338    now.duration_since(updated) > SignedDuration::new(grace_secs as i64, 0)
339}
340
341/// The two fields the janitor decides on, read with a serde that tolerates
342/// everything else about the run being unreadable.
343#[derive(Deserialize)]
344struct Meta {
345    status: RunStatus,
346    updated_at: Timestamp,
347}
348
349/// Read `status` and `updated_at` straight off the state file, asking for
350/// nothing else. `Err` when the file is missing, not parseable, or a status in
351/// a version this build does not speak - all of which mean "unreadable".
352fn read_meta(runs: &Path, id: &str) -> Result<Meta> {
353    let path = runs.join(id).join("run.json");
354    let body =
355        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
356    let meta: Meta =
357        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
358    Ok(meta)
359}
360
361/// Runs checked against GitHub in one janitor pass, at most.
362///
363/// A fleet with many stuck runs must not turn every idle tick into a burst of
364/// `gh pr list` calls; a run left unchecked this pass is checked again next
365/// pass, same as an unfolded due run is.
366const MAX_EXTERNAL_MERGE_CHECKS_PER_PASS: usize = 5;
367
368/// The fields [`reconcile_external_merges`] filters on before paying for a
369/// full [`RunState`] parse or a `gh` round trip.
370#[derive(Deserialize)]
371struct ExternalMergeMeta {
372    status: RunStatus,
373    updated_at: Timestamp,
374    #[serde(default)]
375    merge: Option<crate::run::MergeOutcome>,
376}
377
378fn read_external_merge_meta(runs: &Path, id: &str) -> Result<ExternalMergeMeta> {
379    let path = runs.join(id).join("run.json");
380    let body =
381        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
382    let meta: ExternalMergeMeta =
383        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
384    Ok(meta)
385}
386
387/// Is a run worth asking GitHub about?
388///
389/// Pure, so the filter itself is assertable without a fixture that writes
390/// `run.json` files or spawns `gh`. Narrower than "blocked": `merge_recorded`
391/// excludes a run `land::land` already finished landing (successfully or
392/// not) - that path already carries its own record of what happened - and
393/// `due` gives an operator who is about to fix a run by hand a grace window
394/// before the janitor starts asking the same question about it.
395pub fn eligible_for_external_merge_check(
396    status: RunStatus,
397    merge_recorded: bool,
398    updated_at: Timestamp,
399    now: Timestamp,
400    grace_secs: u64,
401) -> bool {
402    status == RunStatus::Blocked && !merge_recorded && due(now, updated_at, grace_secs)
403}
404
405/// Rotate `ids` so a fixed per-pass cap does not always land on the same
406/// prefix of the list, starting at `start` (already reduced modulo nothing -
407/// callers pass whatever a persisted cursor last recorded, and this takes it
408/// modulo `ids.len()` itself).
409///
410/// Pure: the offset is the caller's problem, not wall-clock time. An earlier
411/// version derived `start` from `now.as_second() % ids.len()`, which looked
412/// independent of any state to persist but is not actually independent of
413/// how often the janitor runs - a poll interval and a pass's own duration
414/// that alias onto the same handful of `now.as_second()` values (say, a
415/// ten-second poll and a pass that finishes in under a second) revisit only
416/// those same few residues forever and never advance into the rest of the
417/// list at all. [`reconcile_external_merges`] instead persists a cursor
418/// across passes ([`read_external_merge_cursor`] /
419/// [`write_external_merge_cursor`]) and advances it by exactly how many ids
420/// this pass actually looked at, which is the only quantity that is
421/// guaranteed to move forward pass over pass regardless of timing.
422fn rotate_from(ids: &[String], start: usize) -> Vec<String> {
423    if ids.is_empty() {
424        return Vec::new();
425    }
426    let start = start % ids.len();
427    ids[start..]
428        .iter()
429        .chain(ids[..start].iter())
430        .cloned()
431        .collect()
432}
433
434/// Where [`reconcile_external_merges`] remembers how far it got, so a fixed
435/// per-pass cap advances through a fleet's eligible runs pass over pass
436/// instead of camping on whichever ones happen to sort first.
437const EXTERNAL_MERGE_CURSOR_FILE: &str = "external-merge-cursor";
438
439/// `0` for anything this cannot read as a plain number - a first run, a
440/// corrupt or missing file, a build that has never written one - which is
441/// exactly as good a starting point as any: the cursor's whole job is to
442/// keep moving, not to encode any particular position as meaningful.
443fn read_external_merge_cursor(home: &Path) -> usize {
444    std::fs::read_to_string(home.join(EXTERNAL_MERGE_CURSOR_FILE))
445        .ok()
446        .and_then(|s| s.trim().parse().ok())
447        .unwrap_or(0)
448}
449
450/// Best-effort, like every other write in this module's sweep: a failure to
451/// persist the cursor costs the fleet one pass's worth of forward progress
452/// on this sweep, never the run whose housekeeping it is racing to keep up
453/// with.
454fn write_external_merge_cursor(home: &Path, cursor: usize) {
455    if let Err(e) = std::fs::write(home.join(EXTERNAL_MERGE_CURSOR_FILE), cursor.to_string()) {
456        tracing::warn!("housekeep: persist the external-merge sweep's cursor: {e:#}");
457    }
458}
459
460/// Close the gap `magi fold --merged` exists for, without waiting for an
461/// operator to notice and go find the pull request URL: a run that stopped
462/// `Blocked` with no `merge` recorded may have been merged anyway, by hand,
463/// on a pull request `land::land` never opened or never got to observe as
464/// merged. `land::find_external_merge` asks GitHub about the run's own
465/// winning branch, and only a pull request it can uniquely tie back to this
466/// run is ever acted on - see that function's own doc for what "uniquely"
467/// excludes.
468///
469/// A run this finds and fixes goes through
470/// [`crate::land::correct_confirmed_external_merge`] - the same correction
471/// `magi fold --merged` performs, minus the same-repo guard that command
472/// needs and this loop doesn't (see that function's own doc: the URL here
473/// was never operator-supplied, it came from asking `state.repo`'s own
474/// remote) - then through [`crate::graph::fold_run`] so it stops holding
475/// worktrees the moment it stops needing them. A run this cannot decide
476/// about - `gh` unreachable, more than one candidate pull
477/// request, nothing found at all - is left exactly as it is; only an error
478/// asking GitHub raises a notice, since "nothing found" is the ordinary,
479/// expected shape of a run that really is just blocked.
480async fn reconcile_external_merges(runs: &Path, home: &Path, disk: &Disk, now: Timestamp) -> usize {
481    let mut ids: Vec<String> = std::fs::read_dir(runs)
482        .into_iter()
483        .flatten()
484        .flatten()
485        .filter(|e| e.path().join("run.json").is_file())
486        .map(|e| e.file_name().to_string_lossy().into_owned())
487        .collect();
488    ids.sort_unstable();
489
490    // Every eligible id first, cheaply (a `Meta` read, no `gh`), then rotated
491    // before the cap is applied. Capping the sorted order directly would
492    // always land on the same lexicographic prefix - a fleet's oldest run ids
493    // - so a handful of long-blocked runs that never turn out to be merged
494    // would starve every id after them of a `gh` check, forever.
495    let mut eligible: Vec<String> = Vec::new();
496    for id in ids {
497        if crate::daemon::is_working_on(home, &id, now) {
498            continue;
499        }
500        let meta = match read_external_merge_meta(runs, &id) {
501            Ok(meta) => meta,
502            // Already counted as unreadable by `fold_due`'s own pass; not
503            // worth a second warning for the same file.
504            Err(_) => continue,
505        };
506        if eligible_for_external_merge_check(
507            meta.status,
508            meta.merge.is_some(),
509            meta.updated_at,
510            now,
511            disk.fold_grace_secs,
512        ) {
513            eligible.push(id);
514        }
515    }
516
517    if eligible.is_empty() {
518        return 0;
519    }
520    let cursor = read_external_merge_cursor(home);
521    let window: Vec<String> = rotate_from(&eligible, cursor)
522        .into_iter()
523        .take(MAX_EXTERNAL_MERGE_CHECKS_PER_PASS)
524        .collect();
525    // Advances by exactly how many ids this pass looked at, regardless of
526    // what came of looking - a run that turned out not to be merged after
527    // all must not be revisited before every other eligible run has had its
528    // own turn.
529    write_external_merge_cursor(home, (cursor + window.len()) % eligible.len());
530
531    let mut reconciled = 0usize;
532    for id in window {
533        let mut state = match read_state(runs, &id) {
534            Ok(state) => state,
535            Err(_) => continue,
536        };
537        match crate::land::find_external_merge(&state).await {
538            Ok(Some(found)) => {
539                match crate::land::correct_confirmed_external_merge(&mut state, &found.url).await {
540                    Ok(_) => {
541                        if let Err(e) = crate::graph::fold_run(&mut state, true, home).await {
542                            tracing::warn!(
543                                "housekeep: fold {id} after recording its external merge: {e:#}"
544                            );
545                        }
546                        reconciled += 1;
547                    }
548                    Err(e) => tracing::warn!(
549                        "housekeep: record external merge {} for {id}: {e:#}",
550                        found.url
551                    ),
552                }
553            }
554            Ok(None) => {}
555            Err(e) => {
556                tracing::warn!("housekeep: check external merge for {id}: {e:#}");
557                crate::notices::raise_in(
558                    home,
559                    crate::notices::Notice::warn(
560                        &format!("merged-unrecorded:{id}"),
561                        format!(
562                            "Run {id} is blocked with no recorded merge, and checking GitHub \
563                             for a merge failed; check by hand."
564                        ),
565                    )
566                    .link(crate::notices::Link::Run { id: id.clone() }),
567                );
568            }
569        }
570    }
571    reconciled
572}
573
574/// Read a whole run state from a runs directory, for folding only.
575///
576/// Deliberately more permissive than [`RunState::load`], which this does not
577/// call: `load` backs `--resume` and every hand-driven command, where a
578/// schema this build disagrees with the *meaning* of must refuse outright
579/// rather than resume a review round or a tally against stale semantics
580/// (`RunState::SCHEMA`'s own docs list what has changed meaning at each
581/// bump). Folding recomputes nothing - it only reads worktree paths, branch
582/// names and a tally winner off the struct to remove them - so an old
583/// schema's values are exactly as good here as a current one's; every schema
584/// bump so far has only ever added a field or a variant, never repurposed an
585/// existing one, and serde already fills an added field's default when an
586/// older record has nothing to say about it. What this cannot tolerate, and
587/// what still surfaces as an `Err`, is `run.json` failing to parse at all.
588fn read_state(runs: &Path, id: &str) -> Result<RunState> {
589    let path = runs.join(id).join("run.json");
590    let body =
591        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
592    let state: RunState =
593        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
594    Ok(state)
595}
596
597/// Remove only verified clean Git worktrees and empty directory scaffolding.
598/// Unknown files or unreadable Git state require recovery, never recursive deletion.
599async fn remove_clean_bay(root: &Path) -> Result<()> {
600    let mut pending = vec![root.to_path_buf()];
601    let mut directories = Vec::new();
602    let mut worktrees = Vec::new();
603    while let Some(path) = pending.pop() {
604        if path.join(".git").exists() {
605            if !crate::git::is_clean(&path).await? {
606                anyhow::bail!("uncommitted work in {}; recovery required", path.display());
607            }
608            worktrees.push(path);
609        } else {
610            for entry in std::fs::read_dir(&path)? {
611                let entry = entry?;
612                if !entry.file_type()?.is_dir() {
613                    anyhow::bail!(
614                        "unknown worktree content in {}; recovery required",
615                        path.display()
616                    );
617                }
618                pending.push(entry.path());
619            }
620            directories.push(path);
621        }
622    }
623    for path in worktrees {
624        if !crate::git::worktree_remove_clean(&path, &path).await? {
625            anyhow::bail!("clean worktree removal refused: {}", path.display());
626        }
627    }
628    for path in directories.into_iter().rev() {
629        std::fs::remove_dir(path)?;
630    }
631    Ok(())
632}
633
634/// Remove a run that cannot be read: its state directory under `runs` and its
635/// worktree directory under `worktrees_root`.
636///
637/// Inspect worktrees before deleting any state or artifacts. Only clean registered
638/// worktrees and empty directory scaffolding can be reclaimed; unknown or dirty
639/// content remains available for recovery. Nonstandard worktree roots remain the
640/// operator's responsibility when the state file cannot identify them.
641pub async fn fold_unreadable(runs: &Path, worktrees_root: &Path, id: &str) -> Result<Vec<String>> {
642    let resolved = resolve_id_path(runs, id)?;
643    let mut removed = Vec::new();
644    let run_dir = runs.join(&resolved);
645    let wt = worktrees_root.join(short_of(&resolved));
646    if wt.exists() {
647        remove_clean_bay(&wt).await?;
648        removed.push(wt.to_string_lossy().into_owned());
649    }
650    // Keep the recovery artifacts if any worktree could not be removed safely.
651    if run_dir.exists() {
652        std::fs::remove_dir_all(&run_dir)
653            .with_context(|| format!("remove {}", run_dir.display()))?;
654        removed.push(format!("runs/{resolved}"));
655    }
656    Ok(removed)
657}
658
659/// Reclaim worktrees under `worktrees_root` that no run record in `runs`
660/// claims anymore, and return how many were removed.
661///
662/// [`fold_due`] only ever sees a worktree by walking `runs/` first, so a
663/// worktree whose run record is already gone — `magi run rm`, or a record
664/// deleted before its worktree — never enters that loop at all: nothing there
665/// is looking for it. This walks the worktree bay directly instead, and
666/// removes any `<short>` directory that no run id maps to.
667///
668/// Two things must never happen, and this checks both before ever touching a
669/// directory:
670///
671/// - **A worktree bay is never the only kind of thing under `worktrees_root`,
672///   and this must not assume it is.** A hand-placed scratch directory, or
673///   anything else an operator or another tool left in the same bay, has the
674///   same "no run claims it" shape as a genuine orphan but is not one -
675///   [`looks_like_a_worktree_bay`] is the same tag shape [`crate::run::is_run_id`]
676///   already requires of a real run's short id, and anything else is left
677///   alone regardless of what else is true about it.
678/// - **A worktree that was only just created might not have a `run.json` yet
679///   for a reason that has nothing to do with being orphaned.** `Runner::start`
680///   and `Runner::review` both create the worktree before the first
681///   `RunState::save` lands, and that gap - several `git` subprocesses wide -
682///   is invisible to [`crate::daemon::is_working_on_short`] whenever the run
683///   is not being driven through this daemon's own `poll` loop at all (a
684///   `magi review` invocation, for one). A directory whose own modification
685///   time is within `grace_secs` of `now` is left alone on that basis alone,
686///   the same margin [`fold_due`] gives a run before treating it as truly
687///   finished - long enough that no realistic gap between a `worktree add`
688///   and its `run.json` could ever be mistaken for one.
689///
690/// The one failure this must never cause is deleting the worktree of a run
691/// that is genuinely in flight. [`crate::daemon::is_working_on_short`] is the
692/// same liveness check [`fold_due`] trusts everywhere else in this module,
693/// checked by short id because there is no full id to compare here; when it
694/// cannot tell, this leaves the directory alone. Best-effort like the rest of
695/// housekeeping: one directory git or the filesystem refuses to give up is a
696/// `tracing::warn`, not a reason to abandon the rest of the pass.
697pub async fn fold_orphaned_worktrees(
698    runs: &Path,
699    worktrees_root: &Path,
700    home: &Path,
701    grace_secs: u64,
702    now: Timestamp,
703) -> usize {
704    let known: std::collections::HashSet<String> = std::fs::read_dir(runs)
705        .into_iter()
706        .flatten()
707        .flatten()
708        .map(|e| e.file_name().to_string_lossy().into_owned())
709        .filter(|name| crate::run::is_run_id(name))
710        .map(|id| short_of(&id).to_owned())
711        .collect();
712
713    let mut folded = 0usize;
714    for entry in std::fs::read_dir(worktrees_root)
715        .into_iter()
716        .flatten()
717        .flatten()
718    {
719        if !entry.path().is_dir() {
720            continue;
721        }
722        let short = entry.file_name().to_string_lossy().into_owned();
723        if !looks_like_a_worktree_bay(&short) {
724            continue;
725        }
726        if known.contains(&short) || crate::daemon::is_working_on_short(home, &short, now) {
727            continue;
728        }
729        let wt = entry.path();
730        if !stale_enough(&wt, grace_secs, now) {
731            continue;
732        }
733        match remove_clean_bay(&wt).await {
734            Ok(()) => folded += 1,
735            Err(e) => tracing::warn!(
736                "housekeep: remove orphaned worktree {}: {e:#}",
737                wt.display()
738            ),
739        }
740    }
741    folded
742}
743
744/// Does `name` have the shape a run's own worktree bay is named with: the
745/// same 4-character alphanumeric tag [`crate::run::is_run_id`] requires of a
746/// full id's trailing block (see [`short_of`])?
747///
748/// Anything else under `worktrees_root` is not a bay this function reclaims
749/// at all, claimed or not - answering "does a run claim this?" about a
750/// directory that was never a run's worktree in the first place is exactly
751/// the wrong question to ask before deleting it.
752fn looks_like_a_worktree_bay(name: &str) -> bool {
753    name.len() == 4 && name.bytes().all(|b| b.is_ascii_alphanumeric())
754}
755
756/// Is `dir`'s own modification time old enough, against `grace_secs` and
757/// `now`, that its emptiness of a run record can be trusted rather than
758/// caught mid-creation?
759///
760/// A directory this pass cannot stat at all - a race with its own removal, a
761/// permission error - is treated as not yet stale: unreadable metadata is not
762/// evidence of anything, and the janitor already keeps rather than deletes
763/// whenever it cannot tell (see the module docs).
764///
765/// `grace_secs` is floored at [`MIN_ORPHAN_AGE_SECS`] regardless of what the
766/// caller passes: `0` is a documented, legitimate value for
767/// [`crate::config::Disk::fold_grace_secs`] (`due`'s own "always due" case),
768/// because that grace answers a policy question the operator owns - how long
769/// a *known, finished* run's worktree lingers before cleanup. Whether an
770/// orphan worktree is actually a race with `Runner::review`'s `git worktree
771/// add` landing before its `run.json` is not a policy question, and must not
772/// collapse to zero just because the operator turned the other grace off -
773/// that would defeat the very check meant to catch it.
774fn stale_enough(dir: &Path, grace_secs: u64, now: Timestamp) -> bool {
775    let Ok(modified) = std::fs::metadata(dir).and_then(|m| m.modified()) else {
776        return false;
777    };
778    let Ok(ts) = Timestamp::try_from(modified) else {
779        return false;
780    };
781    due(now, ts, grace_secs.max(MIN_ORPHAN_AGE_SECS))
782}
783
784/// The floor under [`stale_enough`]'s grace, independent of
785/// [`crate::config::Disk::fold_grace_secs`].
786///
787/// Ample next to the race it guards: the gap between `Runner::start` or
788/// `Runner::review` creating a worktree and the first `RunState::save`
789/// landing is a handful of `git` subprocess calls, not minutes - but the
790/// janitor cannot tell "still mid-setup" from "orphaned" by any other signal
791/// for a run that never registers with `daemon::Status` at all (a `magi
792/// review` invocation, for one), so this is generous on purpose rather than
793/// tuned to the observed case.
794const MIN_ORPHAN_AGE_SECS: u64 = 5 * 60;
795
796/// Resolve an id or prefix against an explicit runs directory, exactly the way
797/// [`crate::run::resolve_id`] does against the global home.
798fn resolve_id_path(runs: &Path, prefix: &str) -> Result<String> {
799    // Keyed on the directory, not on a readable state file: the record this
800    // route exists to remove may be a lone `run.json.tmp` from a save that
801    // ran out of disk, and that is precisely the one a human needs a way to
802    // clear (see `crate::run::list_ids`).
803    if runs.join(prefix).is_dir() && crate::run::is_run_id(prefix) {
804        return Ok(prefix.to_owned());
805    }
806    let mut hits: Vec<String> = Vec::new();
807    for e in std::fs::read_dir(runs).into_iter().flatten().flatten() {
808        if !e.path().is_dir() {
809            continue;
810        }
811        let id = e.file_name().to_string_lossy().into_owned();
812        if crate::run::is_run_id(&id) && (id.starts_with(prefix) || id.ends_with(prefix)) {
813            hits.push(id);
814        }
815    }
816    match hits.len() {
817        1 => Ok(hits.into_iter().next().expect("exactly one hit")),
818        0 => bail!("no run matches `{prefix}`"),
819        _ => bail!(
820            "`{prefix}` matches {} runs: {}",
821            hits.len(),
822            hits.join(", ")
823        ),
824    }
825}
826
827/// `magi fold`'s recovery path for a run whose worktrees are already gone —
828/// so [`crate::graph::fold_run`] removed nothing — but whose `run.json` still
829/// lists active seats nobody is left to answer for: no live daemon claims the
830/// run, and every one of those seats has overrun its own timeout (see
831/// [`RunState::active_all_overrun`]). Clearing them and failing the run is
832/// what lets it be deleted afterward — [`RunState::ensure_can_delete`] only
833/// ever checks whether a live daemon is working on the run and whether its
834/// candidates are folded, not `status`, but a run stuck `implementing`
835/// forever with an empty worktree still reads as unresolved everywhere else
836/// (`magi show`, the deck, the phone) until this runs.
837///
838/// Returns `false` without changing anything when a live daemon still claims
839/// the run, or when some active seat has not actually overrun its budget yet
840/// — a run that is merely between waves must never be guessed at.
841pub fn clear_abandoned_active(state: &mut RunState, home: &Path, now: Timestamp) -> Result<bool> {
842    if crate::daemon::is_working_on(home, &state.id, now) || !state.active_all_overrun(now) {
843        return Ok(false);
844    }
845    state.abandon("fold");
846    state.save_under(home)?;
847    // The seat that asked is gone for good now - the same door
848    // `graph::Runner::settle_questions` closes the moment `status` lands
849    // somewhere non-resumable, see that method's own doc. Without this, an
850    // open question the abandoned seat left behind would keep badging the
851    // operator until the next daemon startup's `abandon_settled_questions`
852    // pass happened to notice it, or forever if nothing is running `magi
853    // serve` at all.
854    if let Err(e) = Questions::at(home.join("questions")).settle_run(&state.id, state.status) {
855        tracing::warn!("abandon questions for {}: {e:#}", state.id);
856    }
857    Ok(true)
858}
859
860/// Delete files from the shared build cache until it fits its cap — but only
861/// while nobody live is registered as using it. [`crate::cache::maintenance_prune`]
862/// takes out the same lease a build would, so a prune can never race a
863/// compile in flight (this run's own, another run's, or a human's `magi
864/// review`) into deleting a file that build still needs. `Ok(None)` when the
865/// cache is in use right now; the next pass catches it once the borrower
866/// releases it, the same way a cap of `0` or a missing `CARGO_TARGET_DIR`
867/// already meant "nothing to do this time" here.
868pub fn prune_cache(home: &Path, cache: &Path, limit_bytes: u64) -> Result<Option<Prune>> {
869    crate::cache::maintenance_prune(home, cache, limit_bytes)
870}
871
872/// [`prune_cache`], but resolving the operator's opt-out and missing
873/// `CARGO_TARGET_DIR` first — the same two checks [`housekeep`]'s idle pass
874/// makes before ever measuring the cache, factored out so
875/// [`crate::daemon`]'s between-runs check (see the module's own doc for why
876/// congestion can make "idle" arrive too rarely to matter) makes them
877/// identically rather than growing its own copy that could drift. `Ok(None)`
878/// covers a cap of `0` (see the module docs on `cache_limit_bytes`), a config
879/// that renders no `CARGO_TARGET_DIR` to aggregate at all, and a cache
880/// currently in use (see [`prune_cache`]).
881pub fn prune_cache_if_over_limit(
882    cfg: &crate::config::Config,
883    home: &Path,
884) -> Result<Option<Prune>> {
885    if cfg.disk.cache_limit_bytes == 0 {
886        return Ok(None);
887    }
888    let Some(cache) = cfg.cache_dir() else {
889        return Ok(None);
890    };
891    let limit = crate::disk::free_bytes(&cache)
892        .ok()
893        .map_or(cfg.disk.cache_limit_bytes, |free| {
894            crate::disk::pressure_limit(
895                dir_size(&cache),
896                cfg.disk.cache_limit_bytes,
897                cfg.disk.min_free_bytes,
898                free,
899            )
900        });
901    prune_cache(home, &cache, limit)
902}
903
904/// The cache's path, size and cap, for `magi cache show` and the health view.
905/// `None` when the config declares no `CARGO_TARGET_DIR` to aggregate.
906///
907/// A cap of `0` means the operator opted out of pruning; the size is then
908/// reported but never acted on.
909pub fn cache_report(cfg: &crate::config::Config) -> Option<(PathBuf, u64, u64)> {
910    let cache = cfg.cache_dir()?;
911    Some((
912        cache.clone(),
913        cache_size(&cache),
914        cfg.disk.cache_limit_bytes,
915    ))
916}
917
918/// Size in bytes of the shared build cache.
919pub fn cache_size(cache: &Path) -> u64 {
920    dir_size(cache)
921}
922
923#[cfg(test)]
924mod tests {
925
926    async fn sh(cwd: &Path, args: &[&str]) {
927        let out = crate::git::git_raw(cwd, args).await.expect("spawn git");
928        assert!(out.ok(), "git {args:?}: {}", out.stderr);
929    }
930
931    async fn commit(cwd: &Path, file: &str, body: &str) {
932        std::fs::write(cwd.join(file), body).unwrap();
933        sh(cwd, &["add", file]).await;
934        sh(
935            cwd,
936            &[
937                "-c",
938                "user.name=t",
939                "-c",
940                "user.email=t@example.com",
941                "commit",
942                "-q",
943                "-m",
944                body,
945            ],
946        )
947        .await;
948    }
949
950    const LONG: std::time::Duration = std::time::Duration::from_secs(30);
951
952    #[tokio::test]
953    async fn fetch_advances_origin_main_and_leaves_the_checkout_alone() {
954        let tmp = tempfile::tempdir().unwrap();
955        let t = tmp.path();
956        let bare = t.join("remote.git");
957        sh(
958            t,
959            &["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
960        )
961        .await;
962        let dir = t.join("root/h/o/r");
963        std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
964        sh(
965            t,
966            &["clone", "-q", bare.to_str().unwrap(), dir.to_str().unwrap()],
967        )
968        .await;
969        sh(&dir, &["checkout", "-q", "-b", "main"]).await;
970        commit(&dir, "a.txt", "one").await;
971        sh(&dir, &["push", "-q", "origin", "main"]).await;
972        sh(&dir, &["checkout", "-q", "--detach"]).await;
973        std::fs::write(dir.join("a.txt"), "dirty").unwrap();
974
975        let other = t.join("other");
976        sh(
977            t,
978            &[
979                "clone",
980                "-q",
981                bare.to_str().unwrap(),
982                other.to_str().unwrap(),
983            ],
984        )
985        .await;
986        commit(&other, "b.txt", "two").await;
987        sh(&other, &["push", "-q", "origin", "HEAD:main"]).await;
988
989        let rev = |d: PathBuf, r: &'static str| async move {
990            crate::git::git(&d, &["rev-parse", r]).await.unwrap()
991        };
992        let head = rev(dir.clone(), "HEAD").await;
993        let before = rev(dir.clone(), "origin/main").await;
994        let status = crate::git::git(&dir, &["status", "--porcelain"])
995            .await
996            .unwrap();
997
998        let r = fetch_origins(&[t.join("root")], LONG, || false).await;
999        assert_eq!(
1000            r,
1001            FetchReport {
1002                fetched: 1,
1003                no_origin: 0,
1004                failed: 0
1005            }
1006        );
1007
1008        assert_ne!(rev(dir.clone(), "origin/main").await, before);
1009        assert_eq!(rev(dir.clone(), "HEAD").await, head);
1010        assert_eq!(
1011            crate::git::git(&dir, &["status", "--porcelain"])
1012                .await
1013                .unwrap(),
1014            status
1015        );
1016        assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "dirty");
1017    }
1018
1019    #[tokio::test]
1020    async fn fetch_never_writes_a_local_branch_whatever_the_configured_refspec() {
1021        let tmp = tempfile::tempdir().unwrap();
1022        let t = tmp.path();
1023        let bare = t.join("remote.git");
1024        sh(
1025            t,
1026            &["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
1027        )
1028        .await;
1029        let dir = t.join("root/h/o/r");
1030        std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
1031        sh(
1032            t,
1033            &["clone", "-q", bare.to_str().unwrap(), dir.to_str().unwrap()],
1034        )
1035        .await;
1036        sh(&dir, &["checkout", "-q", "-b", "main"]).await;
1037        commit(&dir, "a.txt", "one").await;
1038        sh(&dir, &["push", "-q", "origin", "main"]).await;
1039        sh(&dir, &["checkout", "-q", "--detach"]).await;
1040        // A hostile mapping onto the local branch, and no origin/* at all.
1041        sh(
1042            &dir,
1043            &[
1044                "config",
1045                "remote.origin.fetch",
1046                "+refs/heads/main:refs/heads/main",
1047            ],
1048        )
1049        .await;
1050        let _ = crate::git::git_raw(&dir, &["update-ref", "-d", "refs/remotes/origin/main"]).await;
1051
1052        let other = t.join("other");
1053        sh(
1054            t,
1055            &[
1056                "clone",
1057                "-q",
1058                bare.to_str().unwrap(),
1059                other.to_str().unwrap(),
1060            ],
1061        )
1062        .await;
1063        commit(&other, "b.txt", "two").await;
1064        sh(&other, &["push", "-q", "origin", "HEAD:main"]).await;
1065        let remote_tip = crate::git::git(&other, &["rev-parse", "HEAD"])
1066            .await
1067            .unwrap();
1068        let local_main = crate::git::git(&dir, &["rev-parse", "refs/heads/main"])
1069            .await
1070            .unwrap();
1071
1072        let r = fetch_origins(&[t.join("root")], LONG, || false).await;
1073        assert_eq!(r.fetched, 1);
1074        assert_eq!(
1075            crate::git::git(&dir, &["rev-parse", "refs/remotes/origin/main"])
1076                .await
1077                .unwrap(),
1078            remote_tip
1079        );
1080        assert_eq!(
1081            crate::git::git(&dir, &["rev-parse", "refs/heads/main"])
1082                .await
1083                .unwrap(),
1084            local_main
1085        );
1086    }
1087
1088    #[tokio::test]
1089    async fn fetch_skips_no_origin_and_survives_an_unreachable_origin() {
1090        let tmp = tempfile::tempdir().unwrap();
1091        let t = tmp.path();
1092        let root = t.join("root");
1093        let bare = t.join("remote.git");
1094        sh(t, &["init", "-q", "--bare", bare.to_str().unwrap()]).await;
1095
1096        let none = root.join("h/o/none");
1097        let dead = root.join("h/o/dead");
1098        let good = root.join("h/o/good");
1099        for d in [&none, &dead, &good] {
1100            std::fs::create_dir_all(d).unwrap();
1101            sh(d, &["init", "-q"]).await;
1102        }
1103        sh(
1104            &dead,
1105            &[
1106                "remote",
1107                "add",
1108                "origin",
1109                t.join("missing").to_str().unwrap(),
1110            ],
1111        )
1112        .await;
1113        sh(&good, &["remote", "add", "origin", bare.to_str().unwrap()]).await;
1114
1115        let r = fetch_origins(&[root], LONG, || false).await;
1116        assert_eq!(
1117            r,
1118            FetchReport {
1119                fetched: 1,
1120                no_origin: 1,
1121                failed: 1
1122            }
1123        );
1124    }
1125    use super::*;
1126    use crate::config::Disk;
1127    use std::fs;
1128
1129    fn ts(s: &str) -> Timestamp {
1130        s.parse().expect("rfc3339")
1131    }
1132
1133    fn block_on<F: std::future::Future>(f: F) -> F::Output {
1134        tokio::runtime::Runtime::new().expect("runtime").block_on(f)
1135    }
1136
1137    #[test]
1138    fn a_run_is_due_after_its_grace_and_not_before() {
1139        let now = ts("2026-09-05T00:00:00Z");
1140        let grace = 600;
1141        let old = now - SignedDuration::new(601, 0);
1142        let fresh = now - SignedDuration::new(599, 0);
1143        assert!(due(now, old, grace));
1144        assert!(!due(now, fresh, grace));
1145        // Exactly at the edge: not yet due.
1146        let edge = now - SignedDuration::new(600, 0);
1147        assert!(!due(now, edge, grace));
1148        // A zero grace folds everything, ever.
1149        assert!(due(now, old, 0));
1150    }
1151
1152    #[test]
1153    fn rotate_from_moves_the_starting_point_as_the_cursor_advances() {
1154        let ids: Vec<String> = ["a", "b", "c", "d", "e"]
1155            .iter()
1156            .map(|s| s.to_string())
1157            .collect();
1158
1159        // A cursor of 0 starts at the front, same as an unrotated list.
1160        assert_eq!(rotate_from(&ids, 0), ids);
1161
1162        // A cursor of 2 starts two ids further along, with the skipped
1163        // front wrapping to the tail rather than being dropped.
1164        let at_2 = rotate_from(&ids, 2);
1165        assert_eq!(at_2, vec!["c", "d", "e", "a", "b"]);
1166        for id in &ids {
1167            assert!(at_2.contains(id));
1168        }
1169
1170        // A cursor past the list's own length wraps rather than panicking -
1171        // the caller never has to reduce it modulo anything itself.
1172        assert_eq!(rotate_from(&ids, 7), rotate_from(&ids, 2));
1173
1174        // An empty list has no start point to compute and must not panic.
1175        assert_eq!(rotate_from(&[], 0), Vec::<String>::new());
1176    }
1177
1178    /// A fleet with more eligible runs than one pass can check must not park
1179    /// the same handful at the front of the window forever: advancing the
1180    /// cursor by exactly how many ids one pass actually looked at - the
1181    /// arithmetic [`reconcile_external_merges`] itself does - walks every id
1182    /// to the front within one full rotation, with no reliance on how often
1183    /// or how regularly passes happen to run.
1184    #[test]
1185    fn advancing_the_cursor_by_the_window_size_gives_every_id_a_turn() {
1186        let ids: Vec<String> = (0..12).map(|n| format!("run-{n}")).collect();
1187        let cap = 5usize;
1188        let mut cursor = 0usize;
1189        let mut ever_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1190        for _ in 0..ids.len() {
1191            let window: Vec<String> = rotate_from(&ids, cursor).into_iter().take(cap).collect();
1192            ever_seen.extend(window.iter().cloned());
1193            cursor = (cursor + window.len()) % ids.len();
1194        }
1195        assert_eq!(
1196            ever_seen.len(),
1197            ids.len(),
1198            "every id must be checked at least once across a full rotation, whatever \
1199             the cadence between passes"
1200        );
1201    }
1202
1203    #[test]
1204    fn the_external_merge_cursor_round_trips_through_a_files_absence() {
1205        let dir = tempfile::tempdir().unwrap();
1206        let home = dir.path();
1207
1208        // Nothing written yet: starts at 0, not an error.
1209        assert_eq!(read_external_merge_cursor(home), 0);
1210
1211        write_external_merge_cursor(home, 7);
1212        assert_eq!(read_external_merge_cursor(home), 7);
1213
1214        // Anything unreadable as a plain number falls back to 0 rather than
1215        // wedging the sweep on a corrupt file.
1216        std::fs::write(home.join(EXTERNAL_MERGE_CURSOR_FILE), "not a number").unwrap();
1217        assert_eq!(read_external_merge_cursor(home), 0);
1218    }
1219
1220    #[test]
1221    fn a_run_qualifies_for_an_external_merge_check_only_when_blocked_unmerged_and_due() {
1222        let now = ts("2026-09-05T00:00:00Z");
1223        let grace = 600;
1224        let old = now - SignedDuration::new(601, 0);
1225        let fresh = now - SignedDuration::new(599, 0);
1226
1227        assert!(
1228            eligible_for_external_merge_check(RunStatus::Blocked, false, old, now, grace),
1229            "blocked, unmerged, and past its grace period is exactly the run this exists for"
1230        );
1231        assert!(
1232            !eligible_for_external_merge_check(RunStatus::Blocked, false, fresh, now, grace),
1233            "an operator mid-fix deserves the same grace window `fold_due` gives before \
1234             the janitor starts asking GitHub about it"
1235        );
1236        assert!(
1237            !eligible_for_external_merge_check(RunStatus::Blocked, true, old, now, grace),
1238            "a run `land::land` already recorded a merge outcome for has its own answer \
1239             already; this check is only for a run with nothing recorded at all"
1240        );
1241        assert!(
1242            !eligible_for_external_merge_check(RunStatus::Stalled, false, old, now, grace),
1243            "stalled is not blocked - it means the tally never reached quorum, which a \
1244             pull request cannot fix"
1245        );
1246        assert!(
1247            !eligible_for_external_merge_check(RunStatus::Ready, false, old, now, grace),
1248            "ready has nothing to correct - it was never landed by design"
1249        );
1250        assert!(
1251            !eligible_for_external_merge_check(RunStatus::Superseded, false, old, now, grace),
1252            "a superseded run's task already has its answer from a later attempt; \
1253             there is nothing left for GitHub to confirm here"
1254        );
1255    }
1256
1257    /// A `Blocked` run with a decided winner, so `land::find_external_merge`
1258    /// has a branch to ask `gh` about instead of returning early for lack of
1259    /// one - which is what makes an unreachable `/nonexistent/repo` fail
1260    /// loudly (an `Err`, raising a notice) rather than silently (an early
1261    /// `Ok(None)`, raising nothing) when this run's sweep is exercised.
1262    fn write_blocked_run(runs: &Path, id: &str, updated_at: Timestamp) {
1263        use crate::run::{Candidate, Tally};
1264        use std::collections::BTreeMap;
1265
1266        let mut state = RunState::new(
1267            PathBuf::from("/nonexistent/repo"),
1268            "main".to_owned(),
1269            "0000000000000000000000000000000000000000".to_owned(),
1270            String::new(),
1271            crate::config::Config::default(),
1272        );
1273        state.id = id.to_owned();
1274        state.status = RunStatus::Blocked;
1275        state.updated_at = updated_at;
1276        state.candidates.push(Candidate {
1277            index: 0,
1278            label: 'A',
1279            agent: "agent".to_owned(),
1280            branch: format!("magi/{id}/A"),
1281            worktree: PathBuf::from("/nonexistent/repo"),
1282            summary: String::new(),
1283            stat: String::new(),
1284            files: 0,
1285            commits: 0,
1286            empty: false,
1287            failed: None,
1288            verified_noop: None,
1289            duration_ms: 0,
1290            folded: false,
1291        });
1292        state.tally = Some(Tally {
1293            first_choice: BTreeMap::from([('A', 1)]),
1294            borda: BTreeMap::new(),
1295            winner: 'A',
1296            rankings: 1,
1297            unanimous_initial: true,
1298            deliberated: false,
1299            changed_votes: 0,
1300            unanimous_final: true,
1301            tie_break: None,
1302            judges: 1,
1303            present: 1,
1304            quorum: 1,
1305            met_quorum: true,
1306            uncontested: None,
1307        });
1308        std::fs::create_dir_all(runs.join(id)).unwrap();
1309        std::fs::write(
1310            runs.join(id).join("run.json"),
1311            serde_json::to_string_pretty(&state).unwrap(),
1312        )
1313        .unwrap();
1314    }
1315
1316    /// No run here can be confirmed merged - one has no repository `gh` can
1317    /// even ask about, one has not sat blocked long enough, and one is
1318    /// claimed by a live daemon - so the sweep must leave every one of them
1319    /// exactly as it found them and never panic on the failures in between.
1320    #[tokio::test]
1321    async fn reconcile_external_merges_leaves_ineligible_and_unconfirmable_runs_alone() {
1322        let dir = tempfile::tempdir().unwrap();
1323        let runs = dir.path().join("runs");
1324        let home = dir.path().to_path_buf();
1325        std::fs::create_dir_all(&runs).unwrap();
1326
1327        let now = ts("2026-09-05T00:00:00Z");
1328        let disk = Disk {
1329            fold_grace_secs: 600,
1330            ..Disk::default()
1331        };
1332
1333        let due_id = "20260905-000000-blkd";
1334        write_blocked_run(&runs, due_id, ts("2026-08-01T00:00:00Z"));
1335
1336        let fresh_id = "20260905-000000-fres";
1337        write_blocked_run(&runs, fresh_id, now);
1338
1339        let live_id = "20260905-000000-live";
1340        write_blocked_run(&runs, live_id, ts("2026-08-01T00:00:00Z"));
1341        let mut status = crate::daemon::Status::new();
1342        status.current = vec![crate::daemon::Current {
1343            worker: None,
1344            task: "20260905-000000-task".to_owned(),
1345            run: live_id.to_owned(),
1346        }];
1347        status.updated_at = now;
1348        crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
1349
1350        let reconciled = reconcile_external_merges(&runs, &home, &disk, now).await;
1351        assert_eq!(
1352            reconciled, 0,
1353            "an unreachable repository can never be confirmed merged"
1354        );
1355        for id in [due_id, fresh_id, live_id] {
1356            assert_eq!(
1357                read_meta(&runs, id).unwrap().status,
1358                RunStatus::Blocked,
1359                "{id} must be left exactly as it was found"
1360            );
1361        }
1362        let notices = crate::notices::Notices::at(home.join("notifications")).list();
1363        let due_notice = notices
1364            .iter()
1365            .find(|n| n.key == format!("merged-unrecorded:{due_id}"))
1366            .expect("notice for due_id must be raised");
1367        assert_eq!(
1368            due_notice.link,
1369            Some(crate::notices::Link::Run {
1370                id: due_id.to_owned(),
1371            })
1372        );
1373    }
1374
1375    /// The bug this exists to pin: a first version rotated on
1376    /// `now.as_second() % len`, which is fully determined by wall-clock time
1377    /// and nothing else. Two passes seconds apart - as they would be when a
1378    /// short poll interval and a fast pass alias onto the same handful of
1379    /// `now.as_second()` residues - landed on the *same* starting point and
1380    /// never covered a fleet bigger than the per-pass cap, however many
1381    /// times housekeeping ran. Persisting a cursor makes forward progress a
1382    /// function of how many ids got looked at, not of when the clock reads
1383    /// this pass happened to run.
1384    #[tokio::test]
1385    async fn reconcile_external_merges_covers_a_larger_fleet_across_repeated_passes_at_one_instant()
1386    {
1387        let dir = tempfile::tempdir().unwrap();
1388        let runs = dir.path().join("runs");
1389        let home = dir.path().to_path_buf();
1390        std::fs::create_dir_all(&runs).unwrap();
1391
1392        let now = ts("2026-09-05T00:00:00Z");
1393        let disk = Disk {
1394            fold_grace_secs: 600,
1395            ..Disk::default()
1396        };
1397        let old = ts("2026-08-01T00:00:00Z");
1398
1399        let ids: Vec<String> = (0..8).map(|n| format!("20260905-000000-r{n:03}")).collect();
1400        for id in &ids {
1401            write_blocked_run(&runs, id, old);
1402        }
1403
1404        let notices = crate::notices::Notices::at(home.join("notifications"));
1405
1406        // Two passes at the exact same `now`, exactly what a fast pass on a
1407        // short poll interval looks like. Each of the 8 unreachable-repo
1408        // runs raises its own notice the first time it is looked at, so the
1409        // count of distinct notices is a direct readout of how many distinct
1410        // ids have been checked so far.
1411        reconcile_external_merges(&runs, &home, &disk, now).await;
1412        let after_first = notices.list().len();
1413        assert_eq!(
1414            after_first, MAX_EXTERNAL_MERGE_CHECKS_PER_PASS,
1415            "the first pass checks exactly one cap's worth"
1416        );
1417
1418        reconcile_external_merges(&runs, &home, &disk, now).await;
1419        let after_second = notices.list().len();
1420        assert_eq!(
1421            after_second,
1422            ids.len(),
1423            "a second pass at the same instant must still reach every id the \
1424             first pass had no room for, not repeat the same cap's worth"
1425        );
1426    }
1427
1428    #[test]
1429    fn the_meta_reader_is_tolerant_of_everything_except_the_deciders() {
1430        let dir = tempfile::tempdir().unwrap();
1431        let runs = dir.path().join("runs");
1432        let id = "20260905-000000-abcd";
1433        std::fs::create_dir_all(runs.join(id)).unwrap();
1434        std::fs::write(
1435            runs.join(id).join("run.json"),
1436            r#"{"schema": 99, "id": "20260905-000000-abcd", "updated_at": "2026-09-05T00:00:00Z", "status": "ready", "junk_from_another_build": [1, 2, 3]}"#,
1437        )
1438        .unwrap();
1439        let meta = read_meta(&runs, id).expect("readable");
1440        assert_eq!(meta.status, RunStatus::Ready);
1441        assert_eq!(meta.updated_at, ts("2026-09-05T00:00:00Z"));
1442        assert!(read_meta(&runs, "nope").is_err(), "missing file unreadable");
1443        std::fs::write(runs.join(id).join("run.json"), "not json at all").unwrap();
1444        assert!(read_meta(&runs, id).is_err(), "garbage unreadable");
1445    }
1446
1447    #[test]
1448    fn fold_unreadable_preserves_unknown_content_and_recovery_artifacts() {
1449        let dir = tempfile::tempdir().unwrap();
1450        let runs = dir.path().join("runs");
1451        let wt = dir.path().join("wt");
1452        let id = "20260905-000000-abcd";
1453        std::fs::create_dir_all(runs.join(id).join("artifacts")).unwrap();
1454        std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
1455        std::fs::create_dir_all(wt.join("abcd")).unwrap();
1456        std::fs::write(wt.join("abcd/leftover"), "owned edits").unwrap();
1457        assert!(block_on(fold_unreadable(&runs, &wt, id)).is_err());
1458        assert!(runs.join(id).join("artifacts").exists());
1459        assert_eq!(
1460            std::fs::read_to_string(wt.join("abcd/leftover")).unwrap(),
1461            "owned edits"
1462        );
1463    }
1464
1465    #[test]
1466    fn fold_unreadable_releases_run_dir_and_worktrees() {
1467        let dir = tempfile::tempdir().unwrap();
1468        let runs = dir.path().join("runs");
1469        let wt = dir.path().join("wt");
1470        let id = "20260905-000000-abcd";
1471        std::fs::create_dir_all(runs.join(id)).unwrap();
1472        std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
1473        std::fs::create_dir_all(wt.join("abcd")).unwrap();
1474
1475        let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold");
1476        assert_eq!(removed.len(), 2);
1477        assert!(!runs.join(id).exists(), "run dir gone");
1478        assert!(!wt.join("abcd").exists(), "worktrees gone");
1479
1480        // A prefix resolves like `run::resolve_id` does.
1481        std::fs::create_dir_all(runs.join(id)).unwrap();
1482        std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
1483        std::fs::create_dir_all(wt.join("abcd")).unwrap();
1484        let removed = block_on(fold_unreadable(&runs, &wt, "20260905")).expect("by prefix");
1485        assert_eq!(removed.len(), 2);
1486        // Once gone, `id` cannot be resolved at all - same as `run::resolve_id`
1487        // on an id nothing on disk matches - so a repeat pass errors rather
1488        // than silently reporting nothing removed.
1489        assert!(
1490            block_on(fold_unreadable(&runs, &wt, id)).is_err(),
1491            "a run already gone cannot be resolved again"
1492        );
1493    }
1494
1495    #[test]
1496    fn prune_cache_sheds_the_oldest_generation_until_it_fits() {
1497        let home = tempfile::tempdir().unwrap();
1498        let dir = tempfile::tempdir().unwrap();
1499        // Same size, different age: only the age decides, and the newest
1500        // generation - the one the next build reuses - is what survives.
1501        fs::write(dir.path().join("old"), b"xx").unwrap();
1502        fs::write(dir.path().join("new"), b"yy").unwrap();
1503        touch(&dir.path().join("old"), 1_000_000);
1504        touch(&dir.path().join("new"), 2_000_000);
1505
1506        let out = prune_cache(home.path(), dir.path(), 2)
1507            .expect("prune")
1508            .expect("the cache is free");
1509        assert_eq!(out.files, 1, "one deletion is enough to reach the cap");
1510        assert_eq!(out.remaining, 2);
1511        assert!(!dir.path().join("old").exists(), "the older file went");
1512        assert!(dir.path().join("new").exists(), "the newer one stayed");
1513
1514        // A whole generation shares one timestamp tick, so the tie has to be
1515        // decided too: largest first, which reaches the cap in the fewest
1516        // deletions. Left to `read_dir` and an unstable sort this deleted
1517        // both files on Linux and one on Windows.
1518        let tied = tempfile::tempdir().unwrap();
1519        fs::write(tied.path().join("big"), b"xxxx").unwrap();
1520        fs::write(tied.path().join("small"), b"yy").unwrap();
1521        touch(&tied.path().join("big"), 1_000_000);
1522        touch(&tied.path().join("small"), 1_000_000);
1523        let out = prune_cache(home.path(), tied.path(), 2)
1524            .expect("prune")
1525            .expect("the cache is free");
1526        assert_eq!(out.files, 1, "the big one alone gets under the cap");
1527        assert_eq!(out.remaining, 2);
1528        assert!(tied.path().join("small").exists());
1529    }
1530
1531    #[test]
1532    fn prune_cache_if_over_limit_resolves_the_opt_outs_before_ever_measuring() {
1533        let home = tempfile::tempdir().unwrap();
1534        let dir = tempfile::tempdir().unwrap();
1535        fs::write(dir.path().join("big"), vec![0u8; 10]).unwrap();
1536
1537        let mut cfg = crate::config::Config::default();
1538        cfg.verify.gate = vec![format!(
1539            "CARGO_TARGET_DIR={} cargo make check",
1540            dir.path().display()
1541        )];
1542
1543        // A cap of `0` is the operator's opt-out: never measured, never
1544        // pruned, regardless of what is actually on disk.
1545        cfg.disk.cache_limit_bytes = 0;
1546        assert_eq!(
1547            prune_cache_if_over_limit(&cfg, home.path()).unwrap(),
1548            None,
1549            "a zero cap must not even look at the directory"
1550        );
1551        assert!(dir.path().join("big").exists());
1552
1553        // No `CARGO_TARGET_DIR` in either verify command: nothing to
1554        // aggregate, so there is nothing to prune either.
1555        let mut no_cache = crate::config::Config::default();
1556        no_cache.disk.cache_limit_bytes = 1;
1557        assert_eq!(
1558            prune_cache_if_over_limit(&no_cache, home.path()).unwrap(),
1559            None
1560        );
1561
1562        // Over the cap and configured: pruned exactly like `prune_cache`
1563        // itself would.
1564        cfg.disk.cache_limit_bytes = 1;
1565        let pruned = prune_cache_if_over_limit(&cfg, home.path())
1566            .unwrap()
1567            .expect("a real cache dir over its cap prunes");
1568        assert_eq!(pruned.files, 1);
1569        assert!(!dir.path().join("big").exists());
1570    }
1571
1572    /// Pin a file's mtime, so a test asserts the policy and not the runner's
1573    /// timestamp granularity.
1574    fn touch(path: &Path, secs: u64) {
1575        let f = fs::File::options().write(true).open(path).unwrap();
1576        f.set_times(fs::FileTimes::new().set_modified(
1577            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1578        ))
1579        .unwrap();
1580    }
1581
1582    /// The disk-full casualty: a run whose first save left `run.json.tmp` and
1583    /// nothing else. It has to be clearable, or the record is permanent.
1584    #[test]
1585    fn fold_unreadable_clears_a_run_whose_state_never_landed() {
1586        let dir = tempfile::tempdir().unwrap();
1587        let runs = dir.path().join("runs");
1588        let wt = dir.path().join("wt");
1589        let id = "20260904-014540-88c0";
1590        std::fs::create_dir_all(runs.join(id)).unwrap();
1591        std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
1592
1593        let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold by id");
1594        assert_eq!(removed, vec![format!("runs/{id}")]);
1595        assert!(!runs.join(id).exists(), "record gone");
1596
1597        // And by prefix, the way the deck and the phone address a run.
1598        std::fs::create_dir_all(runs.join(id)).unwrap();
1599        std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
1600        assert!(
1601            block_on(fold_unreadable(&runs, &wt, "88c0")).is_ok(),
1602            "by prefix"
1603        );
1604
1605        // A directory under `runs` that is not a run is never a fold target.
1606        std::fs::create_dir_all(runs.join("scratch")).unwrap();
1607        assert!(
1608            block_on(fold_unreadable(&runs, &wt, "scratch")).is_err(),
1609            "a stray directory is not a run"
1610        );
1611    }
1612
1613    #[test]
1614    fn fold_due_folds_terminal_runs_of_any_schema_but_leaves_genuinely_unreadable_ones() {
1615        let dir = tempfile::tempdir().unwrap();
1616        let runs = dir.path().join("runs");
1617        let wt = dir.path().join("wt");
1618        let home = dir.path().to_path_buf();
1619        let disk = Disk::default();
1620        let now = ts("2026-09-05T00:00:00Z");
1621
1622        // 1. Runnable (judging): never folded, however old.
1623        let judging = "20260801-000000-0001";
1624        write_meta(&runs, judging, "judging", "2026-08-01T00:00:00Z");
1625
1626        // 2. Finished but fresh: grace not elapsed. Within the default 6h
1627        //    grace of `now`, so `fold_due` must stop at the freshness check
1628        //    and never even reach `read_state` - `write_meta`'s minimal JSON
1629        //    would fail that full parse anyway, and this case exists to
1630        //    prove freshness is why the run survives, not an accident of the
1631        //    fixture being unparseable as a whole `RunState`.
1632        let ready_fresh = "20260904-220000-0002";
1633        write_meta(&runs, ready_fresh, "ready", "2026-09-04T22:00:00Z");
1634
1635        // 3. Genuinely unreadable: broken JSON, not merely an unfamiliar
1636        //    schema number. Left alone and counted - this is the one case
1637        //    automatic housekeeping must never touch (see `fold_due`'s docs);
1638        //    discarding it is an explicit operator action, not something a
1639        //    background pass does.
1640        let garbage = "20260901-000000-0004";
1641        std::fs::create_dir_all(runs.join(garbage)).unwrap();
1642        std::fs::write(runs.join(garbage).join("run.json"), "not json").unwrap();
1643        std::fs::create_dir_all(wt.join("0004")).unwrap();
1644
1645        // 4. Finished, well past grace, current schema: the ordinary case
1646        //    `fold_due` has always acted on.
1647        let due_ready = due_run(&runs, &wt, "20260801-000000-ffff", SCHEMA);
1648
1649        // 5. Finished, well past grace, but written by a schema number this
1650        //    build no longer matches - the defect this task exists to fix.
1651        //    It still parses cleanly, so only the version number differs, and
1652        //    that alone must not block folding.
1653        let due_old_schema = due_run(&runs, &wt, "20260801-000000-eeee", SCHEMA - 1);
1654
1655        let (folded, unreadable) =
1656            block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
1657        assert_eq!(
1658            folded, 2,
1659            "both due, parseable runs fold regardless of their schema number"
1660        );
1661        assert_eq!(
1662            unreadable, 1,
1663            "only the run with broken JSON counts as unreadable"
1664        );
1665        assert!(runs.join(judging).exists(), "runnable never folded");
1666        assert!(runs.join(ready_fresh).exists(), "fresh never folded");
1667        assert!(runs.join(garbage).exists(), "unreadable record kept");
1668        assert!(wt.join("0004").exists(), "unreadable worktree kept");
1669        assert!(
1670            runs.join(&due_ready).exists(),
1671            "folding drops worktrees, not the record"
1672        );
1673        assert!(
1674            runs.join(&due_old_schema).exists(),
1675            "an old-schema record survives its fold exactly like a current one"
1676        );
1677        // `graph::fold_run` saves the state it just folded back to disk. That
1678        // write must land under this test's own `runs` - the argument it
1679        // passed to `fold_due`, not the process-global `run::home` - or a
1680        // fold that landed somewhere else entirely would still be counted
1681        // above as one of the two `folded` runs.
1682        for id in [&due_ready, &due_old_schema] {
1683            let saved = read_meta(&runs, id).expect("folded run still parses");
1684            assert_ne!(
1685                saved.updated_at,
1686                ts("2026-08-01T00:00:00Z"),
1687                "fold_run must have saved the updated state back through the \
1688                 `runs` directory this test passed to fold_due"
1689            );
1690        }
1691    }
1692
1693    /// `[disk] auto_fold = false` must leave the janitor's fold-and-reclaim
1694    /// passes completely inert - a due run's worktree and record both
1695    /// survive exactly as if `housekeep` had never run at all. Cache pruning
1696    /// is a separate opt-out (`cache_limit_bytes`) and stays disabled here
1697    /// too, so this test is only ever about `auto_fold`.
1698    #[tokio::test]
1699    async fn housekeep_leaves_everything_alone_when_auto_fold_is_disabled() {
1700        let dir = tempfile::tempdir().unwrap();
1701        let runs = dir.path().join("runs");
1702        let wt = dir.path().join("wt");
1703        let home = dir.path().to_path_buf();
1704        crate::run::set_home(dir.path().to_path_buf());
1705
1706        let due_id = due_run(&runs, &wt, "20260801-000000-abcd", SCHEMA);
1707        std::fs::create_dir_all(wt.join("orphan").join("cand-A")).unwrap();
1708
1709        let mut cfg = crate::config::Config::default();
1710        cfg.disk.auto_fold = false;
1711        cfg.disk.cache_limit_bytes = 0;
1712
1713        let out = housekeep(&cfg, &home, &wt, &dir.path().join("repo"), Timestamp::now()).await;
1714
1715        assert_eq!(out.folded, 0);
1716        assert_eq!(out.unreadable, 0);
1717        assert_eq!(out.orphaned_worktrees, 0);
1718        assert!(
1719            runs.join(&due_id).exists(),
1720            "a due run's record survives untouched"
1721        );
1722        assert!(
1723            wt.join("orphan").exists(),
1724            "an orphaned worktree survives untouched: the reclaim pass never ran"
1725        );
1726    }
1727
1728    #[test]
1729    fn fold_orphaned_worktrees_removes_only_worktrees_no_run_claims_and_none_in_flight() {
1730        let dir = tempfile::tempdir().unwrap();
1731        let runs = dir.path().join("runs");
1732        let wt = dir.path().join("wt");
1733        let home = dir.path().to_path_buf();
1734
1735        // A run record exists for this one: its worktree is claimed, not
1736        // orphaned, however old the record.
1737        write_meta(
1738            &runs,
1739            "20260801-000000-aaaa",
1740            "ready",
1741            "2026-08-01T00:00:00Z",
1742        );
1743        std::fs::create_dir_all(wt.join("aaaa").join("cand-A")).unwrap();
1744
1745        // No run record at all, and nobody is working on it: this is the
1746        // leftover `fold_due` can never see, because it only ever walks
1747        // `runs/`.
1748        std::fs::create_dir_all(wt.join("bbbb").join("cand-A")).unwrap();
1749
1750        // No run record either, but a live daemon status names a run with
1751        // this short id - the save-timing gap between the daemon claiming a
1752        // task and `RunState::new` writing its first `run.json`. Must survive
1753        // untouched.
1754        std::fs::create_dir_all(wt.join("cccc")).unwrap();
1755
1756        // Not shaped like a run's short id at all - a scratch directory an
1757        // operator or another tool left in the same bay - so it is never a
1758        // reclaim target regardless of what runs claim it or not.
1759        std::fs::create_dir_all(wt.join("scratch")).unwrap();
1760
1761        // `now` pushed comfortably past `MIN_ORPHAN_AGE_SECS`, so a zero
1762        // grace - the same "always due" escape hatch `due` itself documents
1763        // - still reclaims once a worktree is genuinely old, without faking
1764        // an mtime: real directory creation just above is already in the
1765        // past relative to this `now`, by design rather than by timing.
1766        let now = Timestamp::now() + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
1767        let mut status = crate::daemon::Status::new();
1768        status.current = vec![crate::daemon::Current {
1769            worker: None,
1770            task: "20260905-000000-t111".to_owned(),
1771            run: "20260905-000000-cccc".to_owned(),
1772        }];
1773        status.updated_at = now;
1774        crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
1775
1776        let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
1777        assert_eq!(
1778            folded, 1,
1779            "only the truly orphaned, idle, bay-shaped worktree is removed"
1780        );
1781        assert!(wt.join("aaaa").exists(), "claimed by a run record");
1782        assert!(!wt.join("bbbb").exists(), "orphaned and idle: reclaimed");
1783        assert!(wt.join("cccc").exists(), "a run in flight is never touched");
1784        assert!(
1785            wt.join("scratch").exists(),
1786            "not shaped like a worktree bay, so never a reclaim target"
1787        );
1788    }
1789
1790    /// The gap this closes: `Runner::review` (`magi review`) creates the
1791    /// worktree with `git worktree add` before `RunState::save` ever writes a
1792    /// `run.json`, and that path never runs through the daemon's own `poll`
1793    /// loop at all, so `daemon::Status` never names it either. Without a
1794    /// grace window, a janitor pass landing in that gap would read the
1795    /// worktree as an orphan nothing is waiting on and delete a review still
1796    /// being set up.
1797    #[test]
1798    fn fold_orphaned_worktrees_leaves_a_freshly_created_bay_alone() {
1799        let dir = tempfile::tempdir().unwrap();
1800        let runs = dir.path().join("runs");
1801        let wt = dir.path().join("wt");
1802        let home = dir.path().to_path_buf();
1803
1804        std::fs::create_dir_all(wt.join("dddd").join("under-review")).unwrap();
1805
1806        let now = Timestamp::now();
1807        let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 6 * 60 * 60, now));
1808        assert_eq!(
1809            folded, 0,
1810            "too fresh to tell apart from a run still being set up"
1811        );
1812        assert!(wt.join("dddd").exists());
1813    }
1814
1815    /// A fresh open question on `run`, stored and handed back for assertions.
1816    fn open_question(store: &Questions, run: &str) -> crate::ask::Question {
1817        let mut q = crate::ask::Question::new(
1818            run.to_owned(),
1819            "implement".to_owned(),
1820            "impl-A".to_owned(),
1821            "Which storage backend should the cache use?".to_owned(),
1822            String::new(),
1823            vec!["SQLite".to_owned(), "Redis".to_owned()],
1824        );
1825        store.put(&mut q).unwrap();
1826        q
1827    }
1828
1829    /// The exact ghost the phone showed: a run that already finished, with a
1830    /// question its dead seat asked still sitting `open` because it reached
1831    /// that status before `graph::Runner::settle_questions` existed (or
1832    /// missed it in the crash window `daemon::reclaim_orphaned_running`
1833    /// covers). This sweep is the second door to the same fact.
1834    #[test]
1835    fn a_finished_runs_open_question_is_swept_up() {
1836        let dir = tempfile::tempdir().unwrap();
1837        let runs = dir.path().join("runs");
1838        let store = Questions::at(dir.path().join("questions"));
1839
1840        let failed = "20260908-205802-c9eb";
1841        write_meta(&runs, failed, "failed", "2026-09-08T20:58:02Z");
1842        let failed_q = open_question(&store, failed);
1843
1844        let merged = "20260908-205501-ca67";
1845        write_meta(&runs, merged, "merged", "2026-09-08T20:55:01Z");
1846        let merged_q = open_question(&store, merged);
1847
1848        let n = abandon_settled_questions(&store, &runs);
1849        assert_eq!(n, 2, "both dead runs' questions are swept in one pass");
1850
1851        for (id, run) in [(&failed_q.id, failed), (&merged_q.id, merged)] {
1852            let back = store.get(id).unwrap();
1853            assert!(!back.status.open(), "{run} is done; nobody reads an answer");
1854            assert!(back.detail.contains(run), "{}", back.detail);
1855        }
1856    }
1857
1858    #[test]
1859    fn a_still_alive_runs_open_question_survives_the_sweep() {
1860        let dir = tempfile::tempdir().unwrap();
1861        let runs = dir.path().join("runs");
1862        let store = Questions::at(dir.path().join("questions"));
1863
1864        // `Blocked` and `Stalled` are `RunStatus::resumable`: the run can
1865        // still be picked back up, so its question may yet get a real
1866        // answer. A run still mid-competition is even more obviously alive.
1867        for (id, status) in [
1868            ("20260908-000000-b10c", "blocked"),
1869            ("20260908-000000-5ta1", "stalled"),
1870            ("20260908-000000-jud6", "judging"),
1871        ] {
1872            write_meta(&runs, id, status, "2026-09-08T00:00:00Z");
1873            let q = open_question(&store, id);
1874
1875            let n = abandon_settled_questions(&store, &runs);
1876            assert_eq!(n, 0, "{status} run is not done; nothing to sweep");
1877            assert!(
1878                store.get(&q.id).unwrap().status.open(),
1879                "{status} run's question must still be waiting"
1880            );
1881        }
1882    }
1883
1884    #[test]
1885    fn the_sweep_leaves_an_answered_question_and_an_unreadable_run_alone() {
1886        let dir = tempfile::tempdir().unwrap();
1887        let runs = dir.path().join("runs");
1888        let store = Questions::at(dir.path().join("questions"));
1889
1890        // Already decided: a sweep must never revisit it, whatever the run
1891        // that asked went on to become.
1892        let done = "20260908-000000-answ";
1893        write_meta(&runs, done, "failed", "2026-09-08T00:00:00Z");
1894        let mut answered = open_question(&store, done);
1895        answered
1896            .answer(crate::ask::Answer::Choice("SQLite".to_owned()))
1897            .unwrap();
1898        store.put(&mut answered).unwrap();
1899
1900        // No `run.json` at all for this one - deleted, or never landed.
1901        let gone = "20260908-000000-gone";
1902        let orphan = open_question(&store, gone);
1903
1904        assert_eq!(abandon_settled_questions(&store, &runs), 0);
1905        assert_eq!(
1906            store.get(&answered.id).unwrap().status,
1907            crate::ask::QuestionStatus::Answered,
1908            "a real answer is never overwritten by a sweep"
1909        );
1910        assert!(
1911            store.get(&orphan.id).unwrap().status.open(),
1912            "a run this sweep cannot read is left exactly as it was, not guessed at"
1913        );
1914    }
1915
1916    /// A grace of `0` is a legitimate, documented value for the operator's
1917    /// own `Disk::fold_grace_secs` - `due`'s "always due" case - but the
1918    /// freshness check this guards is not that policy, and must not collapse
1919    /// to it: a `0` handed straight through would reclaim a worktree the
1920    /// instant it exists, exactly the race `fold_orphaned_worktrees_leaves_a_
1921    /// freshly_created_bay_alone` exists to rule out, just with the operator
1922    /// having turned the other grace off instead of leaving it at its
1923    /// default.
1924    #[test]
1925    fn fold_orphaned_worktrees_floors_a_zero_grace_at_the_race_safe_minimum() {
1926        let dir = tempfile::tempdir().unwrap();
1927        let runs = dir.path().join("runs");
1928        let wt = dir.path().join("wt");
1929        let home = dir.path().to_path_buf();
1930
1931        std::fs::create_dir_all(wt.join("eeee").join("under-review")).unwrap();
1932
1933        // Too fresh, even with the grace argument at zero.
1934        let now = Timestamp::now();
1935        let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
1936        assert_eq!(
1937            folded, 0,
1938            "a zero grace must not defeat the race-safety floor"
1939        );
1940        assert!(wt.join("eeee").exists());
1941
1942        // Once genuinely past the floor, a zero grace reclaims it - the
1943        // floor is a minimum, not a replacement policy that never fires.
1944        let later = now + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
1945        let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, later));
1946        assert_eq!(folded, 1, "old enough now, regardless of the zero grace");
1947        assert!(!wt.join("eeee").exists());
1948    }
1949
1950    #[test]
1951    fn clear_abandoned_active_only_acts_once_dead_and_overrun() {
1952        let dir = tempfile::tempdir().unwrap();
1953        // Harmless if another test in this binary already pinned the global
1954        // home first (see `run::set_home`'s own doc): this test only checks
1955        // the in-memory mutation `clear_abandoned_active` makes, never a
1956        // write that landed under this exact directory.
1957        crate::run::set_home(dir.path().to_path_buf());
1958        let home = dir.path().to_path_buf();
1959        let now = ts("2026-09-14T12:00:00Z");
1960        let overrun_seat = || crate::run::ActiveSeat {
1961            node: "implement".to_owned(),
1962            started_at: now - SignedDuration::new(21_000, 0),
1963            timeout_secs: 3_600,
1964            attempt: 0,
1965            task: None,
1966            command: None,
1967            index: None,
1968            total: None,
1969        };
1970
1971        let mut state = RunState::new(
1972            PathBuf::from("/repo"),
1973            "main".to_owned(),
1974            "abc1234".to_owned(),
1975            "fixture".to_owned(),
1976            crate::config::Config::default(),
1977        );
1978        state.status = RunStatus::Implementing;
1979        state.active.insert("impl-A".to_owned(), overrun_seat());
1980
1981        // A seat still within its own budget: not provably dead yet, so this
1982        // must change nothing.
1983        let mut fresh = state.clone();
1984        fresh.active.insert(
1985            "impl-B".to_owned(),
1986            crate::run::ActiveSeat {
1987                node: "implement".to_owned(),
1988                started_at: now,
1989                timeout_secs: 3_600,
1990                attempt: 0,
1991                task: None,
1992                command: None,
1993                index: None,
1994                total: None,
1995            },
1996        );
1997        assert!(!clear_abandoned_active(&mut fresh, &home, now).unwrap());
1998        assert!(!fresh.active.is_empty());
1999        assert_eq!(fresh.status, RunStatus::Implementing);
2000
2001        let store = Questions::at(home.join("questions"));
2002        let q = open_question(&store, &state.id);
2003
2004        assert!(clear_abandoned_active(&mut state, &home, now).unwrap());
2005        assert!(state.active.is_empty());
2006        assert_eq!(state.status, RunStatus::Failed);
2007        assert!(
2008            !store.get(&q.id).unwrap().status.open(),
2009            "the abandoned seat's own open question must not keep badging the \
2010             operator until some later daemon startup notices it"
2011        );
2012    }
2013
2014    /// Write a whole `run.json` that magi can read, over the given state.
2015    fn write_meta(runs: &Path, id: &str, status: &str, updated_at: &str) {
2016        let day = &updated_at[..10];
2017        std::fs::create_dir_all(runs.join(id)).unwrap();
2018        let body = format!(
2019            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}}"#
2020        );
2021        std::fs::write(runs.join(id).join("run.json"), body).unwrap();
2022    }
2023
2024    /// Write a fully-formed, `Ready`, well-past-grace `run.json` tagged with
2025    /// an arbitrary schema number - so a test can write one this build's own
2026    /// `RunState::new` could never produce on its own. Returns the id.
2027    ///
2028    /// `wt` becomes this run's `graph.worktree_root`: left at the config
2029    /// default, `RunState::worktree_root` falls through to
2030    /// `run::default_worktree_root` - the operator's real `~/wt/magi` - and
2031    /// `graph::fold_run`'s second sweep would then `read_dir` and remove
2032    /// worktrees there instead of anything this test owns.
2033    fn due_run(runs: &Path, wt: &Path, id: &str, schema: u32) -> String {
2034        due_run_with_status(runs, wt, id, schema, RunStatus::Ready)
2035    }
2036
2037    /// [`due_run`], with the terminal status a caller wants instead of the
2038    /// `Ready` every other caller here happens to want.
2039    fn due_run_with_status(
2040        runs: &Path,
2041        wt: &Path,
2042        id: &str,
2043        schema: u32,
2044        status: RunStatus,
2045    ) -> String {
2046        let mut config = crate::config::Config::default();
2047        config.graph.worktree_root = Some(wt.to_path_buf());
2048        let mut state = RunState::new(
2049            PathBuf::from("/nonexistent/repo"),
2050            "main".to_owned(),
2051            "0000000000000000000000000000000000000000".to_owned(),
2052            String::new(),
2053            config,
2054        );
2055        state.id = id.to_owned();
2056        state.status = status;
2057        state.updated_at = ts("2026-08-01T00:00:00Z");
2058        let mut value = serde_json::to_value(&state).unwrap();
2059        value["schema"] = serde_json::json!(schema);
2060        std::fs::create_dir_all(runs.join(id)).unwrap();
2061        std::fs::write(
2062            runs.join(id).join("run.json"),
2063            serde_json::to_string_pretty(&value).unwrap(),
2064        )
2065        .unwrap();
2066        id.to_owned()
2067    }
2068
2069    #[test]
2070    fn fold_due_folds_a_superseded_run_same_as_any_other_terminal_one() {
2071        let dir = tempfile::tempdir().unwrap();
2072        let runs = dir.path().join("runs");
2073        let wt = dir.path().join("wt");
2074        let home = dir.path().to_path_buf();
2075        let disk = Disk::default();
2076        let now = ts("2026-09-05T00:00:00Z");
2077
2078        let superseded = due_run_with_status(
2079            &runs,
2080            &wt,
2081            "20260801-000000-cccc",
2082            SCHEMA,
2083            RunStatus::Superseded,
2084        );
2085
2086        let (folded, unreadable) =
2087            block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
2088        assert_eq!(
2089            folded, 1,
2090            "a superseded run has nothing left for a human to check, so it folds \
2091             exactly like a merged one"
2092        );
2093        assert_eq!(unreadable, 0);
2094        assert!(
2095            read_meta(&runs, &superseded).is_ok(),
2096            "folding drops the worktree, not the record"
2097        );
2098    }
2099
2100    /// A throwaway repo with one commit on `main`, for a test that needs a
2101    /// real winner worktree `fold_run` can actually remove.
2102    fn init_repo(dir: &Path) {
2103        use crate::proc::Quiet as _;
2104        let run = |args: &[&str]| {
2105            let out = std::process::Command::new("git")
2106                .args(args)
2107                .current_dir(dir)
2108                .quiet()
2109                .output()
2110                .expect("spawn git");
2111            assert!(
2112                out.status.success(),
2113                "git {args:?} failed: {}",
2114                String::from_utf8_lossy(&out.stderr)
2115            );
2116        };
2117        run(&["init", "-b", "main"]);
2118        run(&["config", "user.name", "magi test"]);
2119        run(&["config", "user.email", "magi@example.com"]);
2120        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
2121        run(&["add", "-A"]);
2122        run(&["commit", "-m", "init"]);
2123    }
2124
2125    /// A due, terminal run with a decided winner that still has a real
2126    /// worktree and branch on disk - so a test can tell whether folding it
2127    /// actually removed the winner or only left it registered as folded.
2128    async fn due_run_with_winner_worktree(
2129        runs: &Path,
2130        wt: &Path,
2131        repo: &Path,
2132        id: &str,
2133        status: RunStatus,
2134    ) -> (String, PathBuf) {
2135        use crate::run::{Candidate, Tally};
2136        use std::collections::BTreeMap;
2137
2138        let mut config = crate::config::Config::default();
2139        config.graph.worktree_root = Some(wt.to_path_buf());
2140        let mut state = RunState::new(
2141            repo.to_path_buf(),
2142            "main".to_owned(),
2143            "0000000000000000000000000000000000000000".to_owned(),
2144            String::new(),
2145            config,
2146        );
2147        state.id = id.to_owned();
2148        state.status = status;
2149        state.updated_at = ts("2026-08-01T00:00:00Z");
2150
2151        let winner_wt = state.worktree_root().join("cand-A");
2152        crate::git::worktree_add_branch(repo, &winner_wt, &format!("magi/{id}/A"), "main")
2153            .await
2154            .expect("winner worktree");
2155
2156        state.candidates.push(Candidate {
2157            index: 0,
2158            label: 'A',
2159            agent: "agent".to_owned(),
2160            branch: format!("magi/{id}/A"),
2161            worktree: winner_wt.clone(),
2162            summary: String::new(),
2163            stat: String::new(),
2164            files: 0,
2165            commits: 0,
2166            empty: false,
2167            failed: None,
2168            verified_noop: None,
2169            duration_ms: 0,
2170            folded: false,
2171        });
2172        state.tally = Some(Tally {
2173            first_choice: BTreeMap::from([('A', 1)]),
2174            borda: BTreeMap::new(),
2175            winner: 'A',
2176            rankings: 1,
2177            unanimous_initial: true,
2178            deliberated: false,
2179            changed_votes: 0,
2180            unanimous_final: true,
2181            tie_break: None,
2182            judges: 1,
2183            present: 1,
2184            quorum: 1,
2185            met_quorum: true,
2186            uncontested: None,
2187        });
2188        std::fs::create_dir_all(runs.join(id)).unwrap();
2189        std::fs::write(
2190            runs.join(id).join("run.json"),
2191            serde_json::to_string_pretty(&state).unwrap(),
2192        )
2193        .unwrap();
2194        (id.to_owned(), winner_wt)
2195    }
2196
2197    #[tokio::test]
2198    async fn completed_task_retains_independent_historical_sources_without_a_merge_proof() {
2199        let dir = tempfile::tempdir().unwrap();
2200        let home = dir.path();
2201        let runs = home.join("runs");
2202        let wt = home.join("wt");
2203        let repo = home.join("repo");
2204        std::fs::create_dir_all(&repo).unwrap();
2205        init_repo(&repo);
2206        let queue = crate::queue::Queue::at(home.join("queue"));
2207        let mut task = crate::queue::Task::new(
2208            "completed".into(),
2209            "retain independent historical changes".into(),
2210            repo.clone(),
2211            crate::queue::Source::Human,
2212        );
2213        let mut sources = Vec::new();
2214        for (id, status) in [
2215            ("20260801-000000-old1", RunStatus::Superseded),
2216            ("20260801-000000-old2", RunStatus::Failed),
2217        ] {
2218            let (id, worktree) = due_run_with_winner_worktree(&runs, &wt, &repo, id, status).await;
2219            std::fs::write(worktree.join("independent.txt"), &id).unwrap();
2220            crate::git::git(&worktree, &["add", "independent.txt"])
2221                .await
2222                .unwrap();
2223            crate::git::git(
2224                &worktree,
2225                &["commit", "-m", "Independent historical change"],
2226            )
2227            .await
2228            .unwrap();
2229            let head = crate::git::rev_parse(&worktree, "HEAD").await.unwrap();
2230            let mut state = RunState::load_under(&id, home).unwrap();
2231            state.driver_exited = true;
2232            if status == RunStatus::Failed {
2233                state.tally = None;
2234            }
2235            state.save_under(home).unwrap();
2236            task.link_run(&id);
2237            sources.push((id, worktree, head));
2238        }
2239        // A newer empty attempt and task completion are no proof that either
2240        // independent historical implementation was incorporated.
2241        let mut latest = RunState::new(
2242            repo.clone(),
2243            "main".into(),
2244            crate::git::rev_parse(&repo, "main").await.unwrap(),
2245            task.instruction.clone(),
2246            crate::config::Config::default(),
2247        );
2248        latest.status = RunStatus::Ready;
2249        latest.driver_exited = true;
2250        latest.save_under(home).unwrap();
2251        task.start(latest.id.clone());
2252        task.succeed();
2253        queue.put(&mut task).unwrap();
2254
2255        let (folded, unreadable) = fold_due(
2256            &runs,
2257            home,
2258            &wt,
2259            &Disk::default(),
2260            ts("2026-09-05T00:00:00Z"),
2261        )
2262        .await
2263        .unwrap();
2264        assert_eq!((folded, unreadable), (0, 0));
2265        for (id, worktree, head) in sources {
2266            assert!(worktree.exists());
2267            assert_eq!(
2268                crate::git::rev_parse(&worktree, "HEAD").await.unwrap(),
2269                head
2270            );
2271            let mut state = RunState::load_under(&id, home).unwrap();
2272            assert!(!state.candidates[0].folded);
2273            let error = crate::graph::fold_run(&mut state, true, home)
2274                .await
2275                .unwrap_err();
2276            assert!(
2277                error
2278                    .to_string()
2279                    .contains(&format!("source of task {}", task.id))
2280            );
2281            assert_eq!(
2282                crate::git::rev_parse(&repo, &state.candidates[0].branch)
2283                    .await
2284                    .unwrap(),
2285                head
2286            );
2287            assert!(worktree.exists());
2288            assert!(runs.join(&id).join("run.json").exists());
2289        }
2290    }
2291
2292    #[tokio::test]
2293    async fn recovery_retains_historical_sources_after_success_and_automatic_cleanup() {
2294        let dir = tempfile::tempdir().unwrap();
2295        let home = dir.path();
2296        let runs = home.join("runs");
2297        let wt = home.join("wt");
2298        let repo = home.join("repo");
2299        std::fs::create_dir_all(&repo).unwrap();
2300        init_repo(&repo);
2301        let mut task = crate::queue::Task::new(
2302            "recover".into(),
2303            "keep measured behavior".into(),
2304            repo.clone(),
2305            crate::queue::Source::Human,
2306        );
2307        let mut sources = Vec::new();
2308        for (id, status) in [
2309            ("20260801-000000-old1", RunStatus::Blocked),
2310            ("20260801-000000-old2", RunStatus::Stalled),
2311        ] {
2312            let (id, worktree) = due_run_with_winner_worktree(&runs, &wt, &repo, id, status).await;
2313            let mut state = RunState::load_under(&id, home).unwrap();
2314            state.driver_exited = true;
2315            state.save_under(home).unwrap();
2316            task.link_run(&id);
2317            sources.push((id, worktree));
2318        }
2319        crate::pr_recovery::retain_sources(&task, home).unwrap();
2320        let mut recovered = RunState::new(
2321            repo.clone(),
2322            "main".into(),
2323            "base".into(),
2324            task.instruction.clone(),
2325            crate::config::Config::default(),
2326        );
2327        recovered.config.graph.worktree_root = Some(wt.clone());
2328        recovered.status = RunStatus::Ready;
2329        recovered.driver_exited = true;
2330        recovered.save_under(home).unwrap();
2331        task.start(recovered.id.clone());
2332        task.succeed();
2333        crate::daemon::supersede_prior_runs(&task, home);
2334        for (id, _) in &sources {
2335            assert_eq!(
2336                RunState::load_under(id, home).unwrap().status,
2337                RunStatus::Superseded
2338            );
2339        }
2340        let disk = Disk::default();
2341        let now = Timestamp::now()
2342            .checked_add(SignedDuration::from_secs(
2343                i64::try_from(disk.fold_grace_secs + 1).unwrap(),
2344            ))
2345            .unwrap();
2346        let (_, unreadable) = fold_due(&runs, home, &wt, &disk, now).await.unwrap();
2347        assert_eq!(unreadable, 0);
2348        for (id, worktree) in sources {
2349            assert!(worktree.exists());
2350            assert!(
2351                crate::git::branch_exists(&repo, &format!("magi/{id}/A"))
2352                    .await
2353                    .unwrap()
2354            );
2355            let mut historical = RunState::load_under(&id, home).unwrap();
2356            assert!(!historical.candidates[0].folded);
2357            let error = crate::graph::fold_run(&mut historical, true, home)
2358                .await
2359                .unwrap_err();
2360            assert!(
2361                error
2362                    .to_string()
2363                    .contains("explicitly retained recovery source")
2364            );
2365            assert!(!historical.candidates[0].folded);
2366            assert!(
2367                worktree.exists(),
2368                "every automatic fold entry preserves retained sources"
2369            );
2370        }
2371    }
2372
2373    #[tokio::test]
2374    async fn fold_due_drops_a_superseded_runs_own_winner_worktree_but_keeps_a_readys() {
2375        let dir = tempfile::tempdir().unwrap();
2376        let runs = dir.path().join("runs");
2377        let wt = dir.path().join("wt");
2378        let repo = dir.path().join("repo");
2379        let home = dir.path().to_path_buf();
2380        let disk = Disk::default();
2381        let now = ts("2026-09-05T00:00:00Z");
2382        std::fs::create_dir_all(&repo).unwrap();
2383        init_repo(&repo);
2384
2385        let (superseded_id, superseded_wt) = due_run_with_winner_worktree(
2386            &runs,
2387            &wt,
2388            &repo,
2389            "20260801-000000-supw",
2390            RunStatus::Superseded,
2391        )
2392        .await;
2393        // `Ready` is auto-folded exactly like `Superseded` (both terminal and
2394        // non-resumable), but never landed anywhere - its own winner is
2395        // deliberately kept, which is what makes it the right contrast here.
2396        // `Blocked`/`Stalled` are `resumable()` and so never even reach
2397        // `fold_run` in the first place; they would not exercise the
2398        // `drop_winner` choice this test is about.
2399        let (ready_id, ready_wt) = due_run_with_winner_worktree(
2400            &runs,
2401            &wt,
2402            &repo,
2403            "20260801-000000-rdyw",
2404            RunStatus::Ready,
2405        )
2406        .await;
2407
2408        let (folded, unreadable) = fold_due(&runs, &home, &wt, &disk, now)
2409            .await
2410            .expect("fold_due");
2411        assert_eq!(folded, 2);
2412        assert_eq!(unreadable, 0);
2413
2414        assert!(
2415            !superseded_wt.exists(),
2416            "a superseded run's own winner never lands anywhere else, so its worktree \
2417             must be dropped exactly like a merged run's"
2418        );
2419        assert!(
2420            ready_wt.exists(),
2421            "a still-ready run's winner may yet be merged by hand - folding must not \
2422             touch it"
2423        );
2424
2425        // Both records survive the fold; only the worktrees differ.
2426        assert!(read_meta(&runs, &superseded_id).is_ok());
2427        assert!(read_meta(&runs, &ready_id).is_ok());
2428    }
2429}