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, prune_dir};
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}
63
64/// Run the janitor: fold due runs, reclaim orphaned worktrees, prune stale
65/// worktree registrations, then prune the cache if it is over its cap.
66///
67/// Every part is best-effort; a jammed cache lock or a run whose worktree
68/// another borrower holds must not stop the rest. Errors are reported through
69/// `tracing::warn` - this is housekeeping, and the daemon keeps serving
70/// either way.
71pub async fn housekeep(
72 cfg: &crate::config::Config,
73 home: &Path,
74 worktrees_root: &Path,
75 repo: &Path,
76 now: Timestamp,
77) -> Housekeeping {
78 let mut out = Housekeeping::default();
79 if cfg.disk.auto_fold {
80 let runs = home.join("runs");
81 match fold_due(&runs, home, worktrees_root, &cfg.disk, now).await {
82 Ok((folded, unreadable)) => {
83 out.folded = folded;
84 out.unreadable = unreadable;
85 }
86 Err(e) => tracing::warn!("housekeep: fold due runs: {e:#}"),
87 }
88 out.orphaned_worktrees =
89 fold_orphaned_worktrees(&runs, worktrees_root, home, cfg.disk.fold_grace_secs, now)
90 .await;
91 // Best-effort in the same sense as everything else here: a repository
92 // this janitor pass has nothing to do with (or none at all, in a unit
93 // test) must not turn a `warn` into a reason to skip the rest.
94 if let Err(e) = crate::git::worktree_prune(repo).await {
95 tracing::warn!("housekeep: prune worktree registrations: {e:#}");
96 }
97 }
98 // A cap of `0` is the operator's opt-out (see `Disk::cache_limit_bytes`);
99 // `prune_dir`'s `over_limit` cannot distinguish "cap of zero" from "cache
100 // must be emptied", so the opt-out is handled here, before the cache is
101 // ever measured - the same place `disk_gate` handles a zero
102 // `min_free_bytes`.
103 if cfg.disk.cache_limit_bytes > 0 {
104 if let Some(cache) = cfg.cache_dir() {
105 match prune_cache(&cache, cfg.disk.cache_limit_bytes) {
106 Ok(pruned) => {
107 out.cache_files = pruned.files;
108 out.cache_freed = pruned.freed;
109 }
110 Err(e) => tracing::warn!("housekeep: prune cache: {e:#}"),
111 }
112 }
113 }
114 // Unconditional, unlike the two passes above: this is not a disk policy
115 // with a cap or an opt-out, it is closing a gap `graph::Runner` itself
116 // cannot - a run that reached `Merged`/`Ready`/`Failed` before this
117 // cleanup existed, or whose process died between saving that status and
118 // abandoning the question it leaves behind (see `Runner::settle_questions`).
119 // Left alone, that question sits `open` forever: the owner's badge,
120 // banner and title all keep counting a decision nobody is left to read.
121 out.questions_abandoned =
122 abandon_settled_questions(&Questions::at(home.join("questions")), &home.join("runs"));
123 out
124}
125
126/// Abandon every open question whose run has already settled into a status
127/// nothing comes back from, worded with what the run became - the same
128/// cleanup `graph::Runner::settle_questions` runs the moment `status` lands
129/// there, for questions that missed it.
130///
131/// Scans questions rather than runs: the open list is normally short, and a
132/// run that never asked anything costs nothing here. A run this cannot read,
133/// deleted or written by a schema this build does not speak, is left alone
134/// the same as everywhere else in this module; the question stays open
135/// rather than guessed at.
136pub fn abandon_settled_questions(store: &Questions, runs: &Path) -> usize {
137 let waiting_on: BTreeSet<String> = store
138 .list()
139 .into_iter()
140 .filter(|q| q.status.open())
141 .map(|q| q.run)
142 .collect();
143 let mut abandoned = 0;
144 for run in waiting_on {
145 let Ok(meta) = read_meta(runs, &run) else {
146 continue;
147 };
148 match store.settle_run(&run, meta.status) {
149 Ok(n) => abandoned += n,
150 Err(e) => tracing::warn!("housekeep: abandon questions for {run}: {e:#}"),
151 }
152 }
153 abandoned
154}
155
156/// Fold every run that is finished, older than the grace period, and not being
157/// worked on; return `(folded, unreadable)`.
158///
159/// A run whose `run.json` genuinely cannot be parsed — missing fields, broken
160/// JSON, a schema newer than this build has ever heard of — is left exactly
161/// as it is. Automatic housekeeping cannot tell a mid-write file from one that
162/// will never parse again, and `<home>/runs/<id>/` is the evidence `magi
163/// stats` and the deck read; when unsure whether it is safe to touch, the
164/// janitor keeps rather than deletes (see the module docs). Discarding a
165/// record this unreadable is an explicit operator action (`magi fold`, or the
166/// equivalent phone route), never something that happens unattended. Every
167/// such skip is counted in the returned `unreadable` and logged through
168/// `tracing::warn` with the parse failure that caused it - silence here is
169/// exactly the failure mode that let 90 of 93 runs sit unfolded with nothing
170/// to show for it.
171///
172/// A run merely written by a *different* schema number is not unreadable: as
173/// long as `run.json` still parses, it folds like any other terminal run (see
174/// the module docs for why the two are different questions).
175///
176/// Runnable statuses and runs newer than the grace period are also left
177/// alone; folding them would throw away work that is still the answer to
178/// somebody's question. `Merged` runs forget their winner's worktree (the
179/// merge already landed it); `Ready` and `Failed` runs keep it.
180pub async fn fold_due(
181 runs: &Path,
182 home: &Path,
183 _worktrees_root: &Path,
184 disk: &Disk,
185 now: Timestamp,
186) -> Result<(usize, usize)> {
187 let mut folded = 0usize;
188 let mut unreadable = 0usize;
189 let mut ids: Vec<String> = std::fs::read_dir(runs)
190 .into_iter()
191 .flatten()
192 .flatten()
193 .filter(|e| e.path().join("run.json").is_file())
194 .map(|e| e.file_name().to_string_lossy().into_owned())
195 .collect();
196 ids.sort_unstable();
197 for id in ids {
198 if crate::daemon::is_working_on(home, &id, now) {
199 continue;
200 }
201 let meta = match read_meta(runs, &id) {
202 Ok(meta) => meta,
203 Err(e) => {
204 unreadable += 1;
205 tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
206 continue;
207 }
208 };
209 if meta.status.resumable() || !due(now, meta.updated_at, disk.fold_grace_secs) {
210 continue;
211 }
212 // `read_meta` already proved the file parses; `read_state` asks for
213 // the rest of the fields `graph::fold_run` needs (worktree paths,
214 // candidates, tally). A schema mismatch alone does not fail this -
215 // see the module docs - so reaching `Err` here means the JSON itself
216 // is broken in a way `read_meta` did not exercise, which is rare but
217 // not impossible (a body truncated between the two fields it reads
218 // and the rest). That must not cost every other run its turn through
219 // this loop, so it is a skip, not a `?`.
220 let mut state = match read_state(runs, &id) {
221 Ok(state) => state,
222 Err(e) => {
223 unreadable += 1;
224 tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
225 continue;
226 }
227 };
228 if state.schema != SCHEMA {
229 tracing::info!(
230 "housekeep: run {id} was written by schema {} (this build speaks {SCHEMA}); \
231 folding it anyway",
232 state.schema
233 );
234 }
235 let drop_winner = state.status == RunStatus::Merged;
236 // One run's fold must not cost every later run its turn. A worktree
237 // another borrower holds, a branch git refuses to delete, a repository
238 // that has since moved: each is a reason this run cannot be folded
239 // now, and none is a reason to stop the pass. Left unfolded, it is
240 // simply due again next time; a `?` here stopped automatic folding
241 // permanently at the first such run (finding R3-1-1 of run 51a3).
242 match crate::graph::fold_run(&mut state, drop_winner).await {
243 Ok(_) => folded += 1,
244 Err(e) => tracing::warn!("housekeep: fold {id}: {e:#}"),
245 }
246 }
247 Ok((folded, unreadable))
248}
249
250/// Is `updated` old enough, measured against `now`, that the run may fold?
251///
252/// Pure; the janitor compares against wallclock, tests inject both sides. The
253/// comparison is strict, so a run exactly at the edge of its grace period is
254/// left alone one more pass — the same convention as [`crate::disk::over_limit`].
255pub fn due(now: Timestamp, updated: Timestamp, grace_secs: u64) -> bool {
256 now.duration_since(updated) > SignedDuration::new(grace_secs as i64, 0)
257}
258
259/// The two fields the janitor decides on, read with a serde that tolerates
260/// everything else about the run being unreadable.
261#[derive(Deserialize)]
262struct Meta {
263 status: RunStatus,
264 updated_at: Timestamp,
265}
266
267/// Read `status` and `updated_at` straight off the state file, asking for
268/// nothing else. `Err` when the file is missing, not parseable, or a status in
269/// a version this build does not speak - all of which mean "unreadable".
270fn read_meta(runs: &Path, id: &str) -> Result<Meta> {
271 let path = runs.join(id).join("run.json");
272 let body =
273 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
274 let meta: Meta =
275 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
276 Ok(meta)
277}
278
279/// Read a whole run state from a runs directory, for folding only.
280///
281/// Deliberately more permissive than [`RunState::load`], which this does not
282/// call: `load` backs `--resume` and every hand-driven command, where a
283/// schema this build disagrees with the *meaning* of must refuse outright
284/// rather than resume a review round or a tally against stale semantics
285/// (`RunState::SCHEMA`'s own docs list what has changed meaning at each
286/// bump). Folding recomputes nothing - it only reads worktree paths, branch
287/// names and a tally winner off the struct to remove them - so an old
288/// schema's values are exactly as good here as a current one's; every schema
289/// bump so far has only ever added a field or a variant, never repurposed an
290/// existing one, and serde already fills an added field's default when an
291/// older record has nothing to say about it. What this cannot tolerate, and
292/// what still surfaces as an `Err`, is `run.json` failing to parse at all.
293fn read_state(runs: &Path, id: &str) -> Result<RunState> {
294 let path = runs.join(id).join("run.json");
295 let body =
296 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
297 let state: RunState =
298 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
299 Ok(state)
300}
301
302/// Remove a run that cannot be read: its state directory under `runs` and its
303/// worktree directory under `worktrees_root`.
304///
305/// The state file is the only record of a run's repository and branches, so a
306/// run this unreadable is discarded at the filesystem level - there is no
307/// candidate list to fold first. The worktrees live under
308/// [`crate::run::default_worktree_root`] unless the run's config relocated
309/// them, which an unreadable run cannot tell us; the default location is
310/// removed, and anything the run placed elsewhere is a leftover for whoever
311/// knows where it went.
312///
313/// Deleting a worktree directory by hand leaves its registration in git, and a
314/// registered path cannot be re-`worktree add`-ed until it is pruned - so every
315/// worktree is unregistered from its repository first, best-effort, via the
316/// `gitdir:` link git keeps inside the directory.
317pub async fn fold_unreadable(runs: &Path, worktrees_root: &Path, id: &str) -> Result<Vec<String>> {
318 let resolved = resolve_id_path(runs, id)?;
319 let mut removed = Vec::new();
320 let run_dir = runs.join(&resolved);
321 if run_dir.exists() {
322 std::fs::remove_dir_all(&run_dir)
323 .with_context(|| format!("remove {}", run_dir.display()))?;
324 removed.push(format!("runs/{resolved}"));
325 }
326 let wt = worktrees_root.join(short_of(&resolved));
327 if wt.exists() {
328 crate::git::remove_worktree_from_linked(&wt).await;
329 for e in std::fs::read_dir(&wt).into_iter().flatten().flatten() {
330 crate::git::remove_worktree_from_linked(&e.path()).await;
331 }
332 std::fs::remove_dir_all(&wt).with_context(|| format!("remove {}", wt.display()))?;
333 removed.push(wt.to_string_lossy().into_owned());
334 }
335 Ok(removed)
336}
337
338/// Reclaim worktrees under `worktrees_root` that no run record in `runs`
339/// claims anymore, and return how many were removed.
340///
341/// [`fold_due`] only ever sees a worktree by walking `runs/` first, so a
342/// worktree whose run record is already gone — `magi run rm`, or a record
343/// deleted before its worktree — never enters that loop at all: nothing there
344/// is looking for it. This walks the worktree bay directly instead, and
345/// removes any `<short>` directory that no run id maps to.
346///
347/// Two things must never happen, and this checks both before ever touching a
348/// directory:
349///
350/// - **A worktree bay is never the only kind of thing under `worktrees_root`,
351/// and this must not assume it is.** A hand-placed scratch directory, or
352/// anything else an operator or another tool left in the same bay, has the
353/// same "no run claims it" shape as a genuine orphan but is not one -
354/// [`looks_like_a_worktree_bay`] is the same tag shape [`crate::run::is_run_id`]
355/// already requires of a real run's short id, and anything else is left
356/// alone regardless of what else is true about it.
357/// - **A worktree that was only just created might not have a `run.json` yet
358/// for a reason that has nothing to do with being orphaned.** `Runner::start`
359/// and `Runner::review` both create the worktree before the first
360/// `RunState::save` lands, and that gap - several `git` subprocesses wide -
361/// is invisible to [`crate::daemon::is_working_on_short`] whenever the run
362/// is not being driven through this daemon's own `poll` loop at all (a
363/// `magi review` invocation, for one). A directory whose own modification
364/// time is within `grace_secs` of `now` is left alone on that basis alone,
365/// the same margin [`fold_due`] gives a run before treating it as truly
366/// finished - long enough that no realistic gap between a `worktree add`
367/// and its `run.json` could ever be mistaken for one.
368///
369/// The one failure this must never cause is deleting the worktree of a run
370/// that is genuinely in flight. [`crate::daemon::is_working_on_short`] is the
371/// same liveness check [`fold_due`] trusts everywhere else in this module,
372/// checked by short id because there is no full id to compare here; when it
373/// cannot tell, this leaves the directory alone. Best-effort like the rest of
374/// housekeeping: one directory git or the filesystem refuses to give up is a
375/// `tracing::warn`, not a reason to abandon the rest of the pass.
376pub async fn fold_orphaned_worktrees(
377 runs: &Path,
378 worktrees_root: &Path,
379 home: &Path,
380 grace_secs: u64,
381 now: Timestamp,
382) -> usize {
383 let known: std::collections::HashSet<String> = std::fs::read_dir(runs)
384 .into_iter()
385 .flatten()
386 .flatten()
387 .map(|e| e.file_name().to_string_lossy().into_owned())
388 .filter(|name| crate::run::is_run_id(name))
389 .map(|id| short_of(&id).to_owned())
390 .collect();
391
392 let mut folded = 0usize;
393 for entry in std::fs::read_dir(worktrees_root)
394 .into_iter()
395 .flatten()
396 .flatten()
397 {
398 if !entry.path().is_dir() {
399 continue;
400 }
401 let short = entry.file_name().to_string_lossy().into_owned();
402 if !looks_like_a_worktree_bay(&short) {
403 continue;
404 }
405 if known.contains(&short) || crate::daemon::is_working_on_short(home, &short, now) {
406 continue;
407 }
408 let wt = entry.path();
409 if !stale_enough(&wt, grace_secs, now) {
410 continue;
411 }
412 crate::git::remove_worktree_from_linked(&wt).await;
413 for e in std::fs::read_dir(&wt).into_iter().flatten().flatten() {
414 crate::git::remove_worktree_from_linked(&e.path()).await;
415 }
416 match std::fs::remove_dir_all(&wt) {
417 Ok(()) => folded += 1,
418 Err(e) => tracing::warn!(
419 "housekeep: remove orphaned worktree {}: {e:#}",
420 wt.display()
421 ),
422 }
423 }
424 folded
425}
426
427/// Does `name` have the shape a run's own worktree bay is named with: the
428/// same 4-character alphanumeric tag [`crate::run::is_run_id`] requires of a
429/// full id's trailing block (see [`short_of`])?
430///
431/// Anything else under `worktrees_root` is not a bay this function reclaims
432/// at all, claimed or not - answering "does a run claim this?" about a
433/// directory that was never a run's worktree in the first place is exactly
434/// the wrong question to ask before deleting it.
435fn looks_like_a_worktree_bay(name: &str) -> bool {
436 name.len() == 4 && name.bytes().all(|b| b.is_ascii_alphanumeric())
437}
438
439/// Is `dir`'s own modification time old enough, against `grace_secs` and
440/// `now`, that its emptiness of a run record can be trusted rather than
441/// caught mid-creation?
442///
443/// A directory this pass cannot stat at all - a race with its own removal, a
444/// permission error - is treated as not yet stale: unreadable metadata is not
445/// evidence of anything, and the janitor already keeps rather than deletes
446/// whenever it cannot tell (see the module docs).
447///
448/// `grace_secs` is floored at [`MIN_ORPHAN_AGE_SECS`] regardless of what the
449/// caller passes: `0` is a documented, legitimate value for
450/// [`crate::config::Disk::fold_grace_secs`] (`due`'s own "always due" case),
451/// because that grace answers a policy question the operator owns - how long
452/// a *known, finished* run's worktree lingers before cleanup. Whether an
453/// orphan worktree is actually a race with `Runner::review`'s `git worktree
454/// add` landing before its `run.json` is not a policy question, and must not
455/// collapse to zero just because the operator turned the other grace off -
456/// that would defeat the very check meant to catch it.
457fn stale_enough(dir: &Path, grace_secs: u64, now: Timestamp) -> bool {
458 let Ok(modified) = std::fs::metadata(dir).and_then(|m| m.modified()) else {
459 return false;
460 };
461 let Ok(ts) = Timestamp::try_from(modified) else {
462 return false;
463 };
464 due(now, ts, grace_secs.max(MIN_ORPHAN_AGE_SECS))
465}
466
467/// The floor under [`stale_enough`]'s grace, independent of
468/// [`crate::config::Disk::fold_grace_secs`].
469///
470/// Ample next to the race it guards: the gap between `Runner::start` or
471/// `Runner::review` creating a worktree and the first `RunState::save`
472/// landing is a handful of `git` subprocess calls, not minutes - but the
473/// janitor cannot tell "still mid-setup" from "orphaned" by any other signal
474/// for a run that never registers with `daemon::Status` at all (a `magi
475/// review` invocation, for one), so this is generous on purpose rather than
476/// tuned to the observed case.
477const MIN_ORPHAN_AGE_SECS: u64 = 5 * 60;
478
479/// Resolve an id or prefix against an explicit runs directory, exactly the way
480/// [`crate::run::resolve_id`] does against the global home.
481fn resolve_id_path(runs: &Path, prefix: &str) -> Result<String> {
482 // Keyed on the directory, not on a readable state file: the record this
483 // route exists to remove may be a lone `run.json.tmp` from a save that
484 // ran out of disk, and that is precisely the one a human needs a way to
485 // clear (see `crate::run::list_ids`).
486 if runs.join(prefix).is_dir() && crate::run::is_run_id(prefix) {
487 return Ok(prefix.to_owned());
488 }
489 let mut hits: Vec<String> = Vec::new();
490 for e in std::fs::read_dir(runs).into_iter().flatten().flatten() {
491 if !e.path().is_dir() {
492 continue;
493 }
494 let id = e.file_name().to_string_lossy().into_owned();
495 if crate::run::is_run_id(&id) && (id.starts_with(prefix) || id.ends_with(prefix)) {
496 hits.push(id);
497 }
498 }
499 match hits.len() {
500 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
501 0 => bail!("no run matches `{prefix}`"),
502 _ => bail!(
503 "`{prefix}` matches {} runs: {}",
504 hits.len(),
505 hits.join(", ")
506 ),
507 }
508}
509
510/// Delete files from the shared build cache until it fits its cap.
511///
512/// See [`crate::disk::prune_dir`] for the oldest-first policy.
513pub fn prune_cache(cache: &Path, limit_bytes: u64) -> Result<Prune> {
514 prune_dir(cache, limit_bytes)
515}
516
517/// The cache's path, size and cap, for `magi cache show` and the health view.
518/// `None` when the config declares no `CARGO_TARGET_DIR` to aggregate.
519///
520/// A cap of `0` means the operator opted out of pruning; the size is then
521/// reported but never acted on.
522pub fn cache_report(cfg: &crate::config::Config) -> Option<(PathBuf, u64, u64)> {
523 let cache = cfg.cache_dir()?;
524 Some((
525 cache.clone(),
526 cache_size(&cache),
527 cfg.disk.cache_limit_bytes,
528 ))
529}
530
531/// Size in bytes of the shared build cache.
532pub fn cache_size(cache: &Path) -> u64 {
533 dir_size(cache)
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::config::Disk;
540 use std::fs;
541
542 fn ts(s: &str) -> Timestamp {
543 s.parse().expect("rfc3339")
544 }
545
546 fn block_on<F: std::future::Future>(f: F) -> F::Output {
547 tokio::runtime::Runtime::new().expect("runtime").block_on(f)
548 }
549
550 #[test]
551 fn a_run_is_due_after_its_grace_and_not_before() {
552 let now = ts("2026-09-05T00:00:00Z");
553 let grace = 600;
554 let old = now - SignedDuration::new(601, 0);
555 let fresh = now - SignedDuration::new(599, 0);
556 assert!(due(now, old, grace));
557 assert!(!due(now, fresh, grace));
558 // Exactly at the edge: not yet due.
559 let edge = now - SignedDuration::new(600, 0);
560 assert!(!due(now, edge, grace));
561 // A zero grace folds everything, ever.
562 assert!(due(now, old, 0));
563 }
564
565 #[test]
566 fn the_meta_reader_is_tolerant_of_everything_except_the_deciders() {
567 let dir = tempfile::tempdir().unwrap();
568 let runs = dir.path().join("runs");
569 let id = "20260905-000000-abcd";
570 std::fs::create_dir_all(runs.join(id)).unwrap();
571 std::fs::write(
572 runs.join(id).join("run.json"),
573 r#"{"schema": 99, "id": "20260905-000000-abcd", "updated_at": "2026-09-05T00:00:00Z", "status": "ready", "junk_from_another_build": [1, 2, 3]}"#,
574 )
575 .unwrap();
576 let meta = read_meta(&runs, id).expect("readable");
577 assert_eq!(meta.status, RunStatus::Ready);
578 assert_eq!(meta.updated_at, ts("2026-09-05T00:00:00Z"));
579 assert!(read_meta(&runs, "nope").is_err(), "missing file unreadable");
580 std::fs::write(runs.join(id).join("run.json"), "not json at all").unwrap();
581 assert!(read_meta(&runs, id).is_err(), "garbage unreadable");
582 }
583
584 #[test]
585 fn fold_unreadable_releases_run_dir_and_worktrees() {
586 let dir = tempfile::tempdir().unwrap();
587 let runs = dir.path().join("runs");
588 let wt = dir.path().join("wt");
589 let id = "20260905-000000-abcd";
590 std::fs::create_dir_all(runs.join(id)).unwrap();
591 std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
592 std::fs::create_dir_all(wt.join("abcd")).unwrap();
593 std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
594
595 let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold");
596 assert_eq!(removed.len(), 2);
597 assert!(!runs.join(id).exists(), "run dir gone");
598 assert!(!wt.join("abcd").exists(), "worktrees gone");
599
600 // A prefix resolves like `run::resolve_id` does.
601 std::fs::create_dir_all(runs.join(id)).unwrap();
602 std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
603 std::fs::create_dir_all(wt.join("abcd")).unwrap();
604 std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
605 let removed = block_on(fold_unreadable(&runs, &wt, "20260905")).expect("by prefix");
606 assert_eq!(removed.len(), 2);
607 // Once gone, `id` cannot be resolved at all - same as `run::resolve_id`
608 // on an id nothing on disk matches - so a repeat pass errors rather
609 // than silently reporting nothing removed.
610 assert!(
611 block_on(fold_unreadable(&runs, &wt, id)).is_err(),
612 "a run already gone cannot be resolved again"
613 );
614 }
615
616 #[test]
617 fn prune_cache_sheds_the_oldest_generation_until_it_fits() {
618 let dir = tempfile::tempdir().unwrap();
619 // Same size, different age: only the age decides, and the newest
620 // generation - the one the next build reuses - is what survives.
621 fs::write(dir.path().join("old"), b"xx").unwrap();
622 fs::write(dir.path().join("new"), b"yy").unwrap();
623 touch(&dir.path().join("old"), 1_000_000);
624 touch(&dir.path().join("new"), 2_000_000);
625
626 let out = prune_cache(dir.path(), 2).expect("prune");
627 assert_eq!(out.files, 1, "one deletion is enough to reach the cap");
628 assert_eq!(out.remaining, 2);
629 assert!(!dir.path().join("old").exists(), "the older file went");
630 assert!(dir.path().join("new").exists(), "the newer one stayed");
631
632 // A whole generation shares one timestamp tick, so the tie has to be
633 // decided too: largest first, which reaches the cap in the fewest
634 // deletions. Left to `read_dir` and an unstable sort this deleted
635 // both files on Linux and one on Windows.
636 let tied = tempfile::tempdir().unwrap();
637 fs::write(tied.path().join("big"), b"xxxx").unwrap();
638 fs::write(tied.path().join("small"), b"yy").unwrap();
639 touch(&tied.path().join("big"), 1_000_000);
640 touch(&tied.path().join("small"), 1_000_000);
641 let out = prune_cache(tied.path(), 2).expect("prune");
642 assert_eq!(out.files, 1, "the big one alone gets under the cap");
643 assert_eq!(out.remaining, 2);
644 assert!(tied.path().join("small").exists());
645 }
646
647 /// Pin a file's mtime, so a test asserts the policy and not the runner's
648 /// timestamp granularity.
649 fn touch(path: &Path, secs: u64) {
650 let f = fs::File::options().write(true).open(path).unwrap();
651 f.set_times(fs::FileTimes::new().set_modified(
652 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs),
653 ))
654 .unwrap();
655 }
656
657 /// The disk-full casualty: a run whose first save left `run.json.tmp` and
658 /// nothing else. It has to be clearable, or the record is permanent.
659 #[test]
660 fn fold_unreadable_clears_a_run_whose_state_never_landed() {
661 let dir = tempfile::tempdir().unwrap();
662 let runs = dir.path().join("runs");
663 let wt = dir.path().join("wt");
664 let id = "20260904-014540-88c0";
665 std::fs::create_dir_all(runs.join(id)).unwrap();
666 std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
667
668 let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold by id");
669 assert_eq!(removed, vec![format!("runs/{id}")]);
670 assert!(!runs.join(id).exists(), "record gone");
671
672 // And by prefix, the way the deck and the phone address a run.
673 std::fs::create_dir_all(runs.join(id)).unwrap();
674 std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
675 assert!(
676 block_on(fold_unreadable(&runs, &wt, "88c0")).is_ok(),
677 "by prefix"
678 );
679
680 // A directory under `runs` that is not a run is never a fold target.
681 std::fs::create_dir_all(runs.join("scratch")).unwrap();
682 assert!(
683 block_on(fold_unreadable(&runs, &wt, "scratch")).is_err(),
684 "a stray directory is not a run"
685 );
686 }
687
688 #[test]
689 fn fold_due_folds_terminal_runs_of_any_schema_but_leaves_genuinely_unreadable_ones() {
690 let dir = tempfile::tempdir().unwrap();
691 let runs = dir.path().join("runs");
692 let wt = dir.path().join("wt");
693 let home = dir.path().to_path_buf();
694 let disk = Disk::default();
695 let now = ts("2026-09-05T00:00:00Z");
696 // `graph::fold_run` (invoked below for the due, readable runs) saves
697 // through the process-global home; pinning it to this test's own
698 // directory is what keeps that write off the operator's real one (see
699 // `run::home`'s doc). Harmless if another test already pinned it
700 // first - this test never reads that global value back.
701 crate::run::set_home(dir.path().to_path_buf());
702
703 // 1. Runnable (judging): never folded, however old.
704 let judging = "20260801-000000-0001";
705 write_meta(&runs, judging, "judging", "2026-08-01T00:00:00Z");
706
707 // 2. Finished but fresh: grace not elapsed. Within the default 6h
708 // grace of `now`, so `fold_due` must stop at the freshness check
709 // and never even reach `read_state` - `write_meta`'s minimal JSON
710 // would fail that full parse anyway, and this case exists to
711 // prove freshness is why the run survives, not an accident of the
712 // fixture being unparseable as a whole `RunState`.
713 let ready_fresh = "20260904-220000-0002";
714 write_meta(&runs, ready_fresh, "ready", "2026-09-04T22:00:00Z");
715
716 // 3. Genuinely unreadable: broken JSON, not merely an unfamiliar
717 // schema number. Left alone and counted - this is the one case
718 // automatic housekeeping must never touch (see `fold_due`'s docs);
719 // discarding it is an explicit operator action, not something a
720 // background pass does.
721 let garbage = "20260901-000000-0004";
722 std::fs::create_dir_all(runs.join(garbage)).unwrap();
723 std::fs::write(runs.join(garbage).join("run.json"), "not json").unwrap();
724 std::fs::create_dir_all(wt.join("0004")).unwrap();
725
726 // 4. Finished, well past grace, current schema: the ordinary case
727 // `fold_due` has always acted on.
728 let due_ready = due_run(&runs, "20260801-000000-ffff", SCHEMA);
729
730 // 5. Finished, well past grace, but written by a schema number this
731 // build no longer matches - the defect this task exists to fix.
732 // It still parses cleanly, so only the version number differs, and
733 // that alone must not block folding.
734 let due_old_schema = due_run(&runs, "20260801-000000-eeee", SCHEMA - 1);
735
736 let (folded, unreadable) =
737 block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
738 assert_eq!(
739 folded, 2,
740 "both due, parseable runs fold regardless of their schema number"
741 );
742 assert_eq!(
743 unreadable, 1,
744 "only the run with broken JSON counts as unreadable"
745 );
746 assert!(runs.join(judging).exists(), "runnable never folded");
747 assert!(runs.join(ready_fresh).exists(), "fresh never folded");
748 assert!(runs.join(garbage).exists(), "unreadable record kept");
749 assert!(wt.join("0004").exists(), "unreadable worktree kept");
750 assert!(
751 runs.join(&due_ready).exists(),
752 "folding drops worktrees, not the record"
753 );
754 assert!(
755 runs.join(&due_old_schema).exists(),
756 "an old-schema record survives its fold exactly like a current one"
757 );
758 }
759
760 #[test]
761 fn fold_orphaned_worktrees_removes_only_worktrees_no_run_claims_and_none_in_flight() {
762 let dir = tempfile::tempdir().unwrap();
763 let runs = dir.path().join("runs");
764 let wt = dir.path().join("wt");
765 let home = dir.path().to_path_buf();
766
767 // A run record exists for this one: its worktree is claimed, not
768 // orphaned, however old the record.
769 write_meta(
770 &runs,
771 "20260801-000000-aaaa",
772 "ready",
773 "2026-08-01T00:00:00Z",
774 );
775 std::fs::create_dir_all(wt.join("aaaa").join("cand-A")).unwrap();
776
777 // No run record at all, and nobody is working on it: this is the
778 // leftover `fold_due` can never see, because it only ever walks
779 // `runs/`.
780 std::fs::create_dir_all(wt.join("bbbb").join("cand-A")).unwrap();
781
782 // No run record either, but a live daemon status names a run with
783 // this short id - the save-timing gap between the daemon claiming a
784 // task and `RunState::new` writing its first `run.json`. Must survive
785 // untouched.
786 std::fs::create_dir_all(wt.join("cccc")).unwrap();
787
788 // Not shaped like a run's short id at all - a scratch directory an
789 // operator or another tool left in the same bay - so it is never a
790 // reclaim target regardless of what runs claim it or not.
791 std::fs::create_dir_all(wt.join("scratch")).unwrap();
792
793 // `now` pushed comfortably past `MIN_ORPHAN_AGE_SECS`, so a zero
794 // grace - the same "always due" escape hatch `due` itself documents
795 // - still reclaims once a worktree is genuinely old, without faking
796 // an mtime: real directory creation just above is already in the
797 // past relative to this `now`, by design rather than by timing.
798 let now = Timestamp::now() + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
799 let mut status = crate::daemon::Status::new();
800 status.current = vec![crate::daemon::Current {
801 task: "20260905-000000-t111".to_owned(),
802 run: "20260905-000000-cccc".to_owned(),
803 }];
804 status.updated_at = now;
805 crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
806
807 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
808 assert_eq!(
809 folded, 1,
810 "only the truly orphaned, idle, bay-shaped worktree is removed"
811 );
812 assert!(wt.join("aaaa").exists(), "claimed by a run record");
813 assert!(!wt.join("bbbb").exists(), "orphaned and idle: reclaimed");
814 assert!(wt.join("cccc").exists(), "a run in flight is never touched");
815 assert!(
816 wt.join("scratch").exists(),
817 "not shaped like a worktree bay, so never a reclaim target"
818 );
819 }
820
821 /// The gap this closes: `Runner::review` (`magi review`) creates the
822 /// worktree with `git worktree add` before `RunState::save` ever writes a
823 /// `run.json`, and that path never runs through the daemon's own `poll`
824 /// loop at all, so `daemon::Status` never names it either. Without a
825 /// grace window, a janitor pass landing in that gap would read the
826 /// worktree as an orphan nothing is waiting on and delete a review still
827 /// being set up.
828 #[test]
829 fn fold_orphaned_worktrees_leaves_a_freshly_created_bay_alone() {
830 let dir = tempfile::tempdir().unwrap();
831 let runs = dir.path().join("runs");
832 let wt = dir.path().join("wt");
833 let home = dir.path().to_path_buf();
834
835 std::fs::create_dir_all(wt.join("dddd").join("under-review")).unwrap();
836
837 let now = Timestamp::now();
838 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 6 * 60 * 60, now));
839 assert_eq!(
840 folded, 0,
841 "too fresh to tell apart from a run still being set up"
842 );
843 assert!(wt.join("dddd").exists());
844 }
845
846 /// A fresh open question on `run`, stored and handed back for assertions.
847 fn open_question(store: &Questions, run: &str) -> crate::ask::Question {
848 let mut q = crate::ask::Question::new(
849 run.to_owned(),
850 "implement".to_owned(),
851 "impl-A".to_owned(),
852 "Which storage backend should the cache use?".to_owned(),
853 String::new(),
854 vec!["SQLite".to_owned(), "Redis".to_owned()],
855 );
856 store.put(&mut q).unwrap();
857 q
858 }
859
860 /// The exact ghost the phone showed: a run that already finished, with a
861 /// question its dead seat asked still sitting `open` because it reached
862 /// that status before `graph::Runner::settle_questions` existed (or
863 /// missed it in the crash window `daemon::reclaim_orphaned_running`
864 /// covers). This sweep is the second door to the same fact.
865 #[test]
866 fn a_finished_runs_open_question_is_swept_up() {
867 let dir = tempfile::tempdir().unwrap();
868 let runs = dir.path().join("runs");
869 let store = Questions::at(dir.path().join("questions"));
870
871 let failed = "20260908-205802-c9eb";
872 write_meta(&runs, failed, "failed", "2026-09-08T20:58:02Z");
873 let failed_q = open_question(&store, failed);
874
875 let merged = "20260908-205501-ca67";
876 write_meta(&runs, merged, "merged", "2026-09-08T20:55:01Z");
877 let merged_q = open_question(&store, merged);
878
879 let n = abandon_settled_questions(&store, &runs);
880 assert_eq!(n, 2, "both dead runs' questions are swept in one pass");
881
882 for (id, run) in [(&failed_q.id, failed), (&merged_q.id, merged)] {
883 let back = store.get(id).unwrap();
884 assert!(!back.status.open(), "{run} is done; nobody reads an answer");
885 assert!(back.detail.contains(run), "{}", back.detail);
886 }
887 }
888
889 #[test]
890 fn a_still_alive_runs_open_question_survives_the_sweep() {
891 let dir = tempfile::tempdir().unwrap();
892 let runs = dir.path().join("runs");
893 let store = Questions::at(dir.path().join("questions"));
894
895 // `Blocked` and `Stalled` are `RunStatus::resumable`: the run can
896 // still be picked back up, so its question may yet get a real
897 // answer. A run still mid-competition is even more obviously alive.
898 for (id, status) in [
899 ("20260908-000000-b10c", "blocked"),
900 ("20260908-000000-5ta1", "stalled"),
901 ("20260908-000000-jud6", "judging"),
902 ] {
903 write_meta(&runs, id, status, "2026-09-08T00:00:00Z");
904 let q = open_question(&store, id);
905
906 let n = abandon_settled_questions(&store, &runs);
907 assert_eq!(n, 0, "{status} run is not done; nothing to sweep");
908 assert!(
909 store.get(&q.id).unwrap().status.open(),
910 "{status} run's question must still be waiting"
911 );
912 }
913 }
914
915 #[test]
916 fn the_sweep_leaves_an_answered_question_and_an_unreadable_run_alone() {
917 let dir = tempfile::tempdir().unwrap();
918 let runs = dir.path().join("runs");
919 let store = Questions::at(dir.path().join("questions"));
920
921 // Already decided: a sweep must never revisit it, whatever the run
922 // that asked went on to become.
923 let done = "20260908-000000-answ";
924 write_meta(&runs, done, "failed", "2026-09-08T00:00:00Z");
925 let mut answered = open_question(&store, done);
926 answered
927 .answer(crate::ask::Answer::Choice("SQLite".to_owned()))
928 .unwrap();
929 store.put(&mut answered).unwrap();
930
931 // No `run.json` at all for this one - deleted, or never landed.
932 let gone = "20260908-000000-gone";
933 let orphan = open_question(&store, gone);
934
935 assert_eq!(abandon_settled_questions(&store, &runs), 0);
936 assert_eq!(
937 store.get(&answered.id).unwrap().status,
938 crate::ask::QuestionStatus::Answered,
939 "a real answer is never overwritten by a sweep"
940 );
941 assert!(
942 store.get(&orphan.id).unwrap().status.open(),
943 "a run this sweep cannot read is left exactly as it was, not guessed at"
944 );
945 }
946
947 /// A grace of `0` is a legitimate, documented value for the operator's
948 /// own `Disk::fold_grace_secs` - `due`'s "always due" case - but the
949 /// freshness check this guards is not that policy, and must not collapse
950 /// to it: a `0` handed straight through would reclaim a worktree the
951 /// instant it exists, exactly the race `fold_orphaned_worktrees_leaves_a_
952 /// freshly_created_bay_alone` exists to rule out, just with the operator
953 /// having turned the other grace off instead of leaving it at its
954 /// default.
955 #[test]
956 fn fold_orphaned_worktrees_floors_a_zero_grace_at_the_race_safe_minimum() {
957 let dir = tempfile::tempdir().unwrap();
958 let runs = dir.path().join("runs");
959 let wt = dir.path().join("wt");
960 let home = dir.path().to_path_buf();
961
962 std::fs::create_dir_all(wt.join("eeee").join("under-review")).unwrap();
963
964 // Too fresh, even with the grace argument at zero.
965 let now = Timestamp::now();
966 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
967 assert_eq!(
968 folded, 0,
969 "a zero grace must not defeat the race-safety floor"
970 );
971 assert!(wt.join("eeee").exists());
972
973 // Once genuinely past the floor, a zero grace reclaims it - the
974 // floor is a minimum, not a replacement policy that never fires.
975 let later = now + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
976 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, later));
977 assert_eq!(folded, 1, "old enough now, regardless of the zero grace");
978 assert!(!wt.join("eeee").exists());
979 }
980
981 /// Write a whole `run.json` that magi can read, over the given state.
982 fn write_meta(runs: &Path, id: &str, status: &str, updated_at: &str) {
983 let day = &updated_at[..10];
984 std::fs::create_dir_all(runs.join(id)).unwrap();
985 let body = format!(
986 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}}"#
987 );
988 std::fs::write(runs.join(id).join("run.json"), body).unwrap();
989 }
990
991 /// Write a fully-formed, `Ready`, well-past-grace `run.json` tagged with
992 /// an arbitrary schema number - so a test can write one this build's own
993 /// `RunState::new` could never produce on its own. Returns the id.
994 fn due_run(runs: &Path, id: &str, schema: u32) -> String {
995 let mut state = RunState::new(
996 PathBuf::from("/nonexistent/repo"),
997 "main".to_owned(),
998 "0000000000000000000000000000000000000000".to_owned(),
999 String::new(),
1000 crate::config::Config::default(),
1001 );
1002 state.id = id.to_owned();
1003 state.status = RunStatus::Ready;
1004 state.updated_at = ts("2026-08-01T00:00:00Z");
1005 let mut value = serde_json::to_value(&state).unwrap();
1006 value["schema"] = serde_json::json!(schema);
1007 std::fs::create_dir_all(runs.join(id)).unwrap();
1008 std::fs::write(
1009 runs.join(id).join("run.json"),
1010 serde_json::to_string_pretty(&value).unwrap(),
1011 )
1012 .unwrap();
1013 id.to_owned()
1014 }
1015}