Skip to main content

magi/
clean.rs

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