use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use jiff::{SignedDuration, Timestamp};
use serde::Deserialize;
use crate::config::Disk;
use crate::run::{RunState, RunStatus, SCHEMA, short_of};
use crate::disk::{Prune, dir_size, prune_dir};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Housekeeping {
pub folded: usize,
pub unreadable: usize,
pub cache_files: usize,
pub cache_freed: u64,
}
pub async fn housekeep(
cfg: &crate::config::Config,
home: &Path,
worktrees_root: &Path,
now: Timestamp,
) -> Housekeeping {
let mut out = Housekeeping::default();
if cfg.disk.auto_fold {
match fold_due(&home.join("runs"), home, worktrees_root, &cfg.disk, now).await {
Ok(folded) => out.folded = folded,
Err(e) => tracing::warn!("housekeep: fold due runs: {e:#}"),
}
}
if cfg.disk.cache_limit_bytes > 0 {
if let Some(cache) = cfg.cache_dir() {
match prune_cache(&cache, cfg.disk.cache_limit_bytes) {
Ok(pruned) => {
out.cache_files = pruned.files;
out.cache_freed = pruned.freed;
}
Err(e) => tracing::warn!("housekeep: prune cache: {e:#}"),
}
}
}
out
}
pub async fn fold_due(
runs: &Path,
home: &Path,
_worktrees_root: &Path,
disk: &Disk,
now: Timestamp,
) -> Result<usize> {
let mut folded = 0usize;
let mut ids: Vec<String> = std::fs::read_dir(runs)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().join("run.json").is_file())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
ids.sort_unstable();
for id in ids {
if crate::daemon::is_working_on(home, &id, now) {
continue;
}
let Ok(meta) = read_meta(runs, &id) else {
continue;
};
if meta.status.resumable() || !due(now, meta.updated_at, disk.fold_grace_secs) {
continue;
}
let Ok(mut state) = read_state(runs, &id) else {
continue;
};
let drop_winner = state.status == RunStatus::Merged;
match crate::graph::fold_run(&mut state, drop_winner).await {
Ok(_) => folded += 1,
Err(e) => tracing::warn!("housekeep: fold {id}: {e:#}"),
}
}
Ok(folded)
}
pub fn due(now: Timestamp, updated: Timestamp, grace_secs: u64) -> bool {
now.duration_since(updated) > SignedDuration::new(grace_secs as i64, 0)
}
#[derive(Deserialize)]
struct Meta {
status: RunStatus,
updated_at: Timestamp,
}
fn read_meta(runs: &Path, id: &str) -> Result<Meta> {
let path = runs.join(id).join("run.json");
let body =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let meta: Meta =
serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
Ok(meta)
}
fn read_state(runs: &Path, id: &str) -> Result<RunState> {
let path = runs.join(id).join("run.json");
let body =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let state: RunState =
serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
if state.schema != SCHEMA {
bail!(
"run {} was written by a different magi (schema {}, this build \
speaks {SCHEMA})",
state.id,
state.schema
);
}
Ok(state)
}
pub async fn fold_unreadable(runs: &Path, worktrees_root: &Path, id: &str) -> Result<Vec<String>> {
let resolved = resolve_id_path(runs, id)?;
let mut removed = Vec::new();
let run_dir = runs.join(&resolved);
if run_dir.exists() {
std::fs::remove_dir_all(&run_dir)
.with_context(|| format!("remove {}", run_dir.display()))?;
removed.push(format!("runs/{resolved}"));
}
let wt = worktrees_root.join(short_of(&resolved));
if wt.exists() {
crate::git::remove_worktree_from_linked(&wt).await;
for e in std::fs::read_dir(&wt).into_iter().flatten().flatten() {
crate::git::remove_worktree_from_linked(&e.path()).await;
}
std::fs::remove_dir_all(&wt).with_context(|| format!("remove {}", wt.display()))?;
removed.push(wt.to_string_lossy().into_owned());
}
Ok(removed)
}
fn resolve_id_path(runs: &Path, prefix: &str) -> Result<String> {
if runs.join(prefix).is_dir() && crate::run::is_run_id(prefix) {
return Ok(prefix.to_owned());
}
let mut hits: Vec<String> = Vec::new();
for e in std::fs::read_dir(runs).into_iter().flatten().flatten() {
if !e.path().is_dir() {
continue;
}
let id = e.file_name().to_string_lossy().into_owned();
if crate::run::is_run_id(&id) && (id.starts_with(prefix) || id.ends_with(prefix)) {
hits.push(id);
}
}
match hits.len() {
1 => Ok(hits.into_iter().next().expect("exactly one hit")),
0 => bail!("no run matches `{prefix}`"),
_ => bail!(
"`{prefix}` matches {} runs: {}",
hits.len(),
hits.join(", ")
),
}
}
pub fn prune_cache(cache: &Path, limit_bytes: u64) -> Result<Prune> {
prune_dir(cache, limit_bytes)
}
pub fn cache_report(cfg: &crate::config::Config) -> Option<(PathBuf, u64, u64)> {
let cache = cfg.cache_dir()?;
Some((
cache.clone(),
cache_size(&cache),
cfg.disk.cache_limit_bytes,
))
}
pub fn cache_size(cache: &Path) -> u64 {
dir_size(cache)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Disk;
use std::fs;
fn ts(s: &str) -> Timestamp {
s.parse().expect("rfc3339")
}
fn block_on<F: std::future::Future>(f: F) -> F::Output {
tokio::runtime::Runtime::new().expect("runtime").block_on(f)
}
#[test]
fn a_run_is_due_after_its_grace_and_not_before() {
let now = ts("2026-09-05T00:00:00Z");
let grace = 600;
let old = now - SignedDuration::new(601, 0);
let fresh = now - SignedDuration::new(599, 0);
assert!(due(now, old, grace));
assert!(!due(now, fresh, grace));
let edge = now - SignedDuration::new(600, 0);
assert!(!due(now, edge, grace));
assert!(due(now, old, 0));
}
#[test]
fn the_meta_reader_is_tolerant_of_everything_except_the_deciders() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let id = "20260905-000000-abcd";
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(
runs.join(id).join("run.json"),
r#"{"schema": 99, "id": "20260905-000000-abcd", "updated_at": "2026-09-05T00:00:00Z", "status": "ready", "junk_from_another_build": [1, 2, 3]}"#,
)
.unwrap();
let meta = read_meta(&runs, id).expect("readable");
assert_eq!(meta.status, RunStatus::Ready);
assert_eq!(meta.updated_at, ts("2026-09-05T00:00:00Z"));
assert!(read_meta(&runs, "nope").is_err(), "missing file unreadable");
std::fs::write(runs.join(id).join("run.json"), "not json at all").unwrap();
assert!(read_meta(&runs, id).is_err(), "garbage unreadable");
}
#[test]
fn fold_unreadable_releases_run_dir_and_worktrees() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let wt = dir.path().join("wt");
let id = "20260905-000000-abcd";
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
std::fs::create_dir_all(wt.join("abcd")).unwrap();
std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold");
assert_eq!(removed.len(), 2);
assert!(!runs.join(id).exists(), "run dir gone");
assert!(!wt.join("abcd").exists(), "worktrees gone");
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
std::fs::create_dir_all(wt.join("abcd")).unwrap();
std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
let removed = block_on(fold_unreadable(&runs, &wt, "20260905")).expect("by prefix");
assert_eq!(removed.len(), 2);
assert!(
block_on(fold_unreadable(&runs, &wt, id)).is_err(),
"a run already gone cannot be resolved again"
);
}
#[test]
fn prune_cache_sheds_the_oldest_generation_until_it_fits() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("old"), b"xx").unwrap();
fs::write(dir.path().join("new"), b"yy").unwrap();
touch(&dir.path().join("old"), 1_000_000);
touch(&dir.path().join("new"), 2_000_000);
let out = prune_cache(dir.path(), 2).expect("prune");
assert_eq!(out.files, 1, "one deletion is enough to reach the cap");
assert_eq!(out.remaining, 2);
assert!(!dir.path().join("old").exists(), "the older file went");
assert!(dir.path().join("new").exists(), "the newer one stayed");
let tied = tempfile::tempdir().unwrap();
fs::write(tied.path().join("big"), b"xxxx").unwrap();
fs::write(tied.path().join("small"), b"yy").unwrap();
touch(&tied.path().join("big"), 1_000_000);
touch(&tied.path().join("small"), 1_000_000);
let out = prune_cache(tied.path(), 2).expect("prune");
assert_eq!(out.files, 1, "the big one alone gets under the cap");
assert_eq!(out.remaining, 2);
assert!(tied.path().join("small").exists());
}
fn touch(path: &Path, secs: u64) {
let f = fs::File::options().write(true).open(path).unwrap();
f.set_times(fs::FileTimes::new().set_modified(
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs),
))
.unwrap();
}
#[test]
fn fold_unreadable_clears_a_run_whose_state_never_landed() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let wt = dir.path().join("wt");
let id = "20260904-014540-88c0";
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold by id");
assert_eq!(removed, vec![format!("runs/{id}")]);
assert!(!runs.join(id).exists(), "record gone");
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
assert!(
block_on(fold_unreadable(&runs, &wt, "88c0")).is_ok(),
"by prefix"
);
std::fs::create_dir_all(runs.join("scratch")).unwrap();
assert!(
block_on(fold_unreadable(&runs, &wt, "scratch")).is_err(),
"a stray directory is not a run"
);
}
#[test]
fn fold_due_skips_fresh_runnable_and_unreadable_but_folds_a_due_terminal_run() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let wt = dir.path().join("wt");
let home = dir.path().to_path_buf();
let disk = Disk::default();
let now = ts("2026-09-05T00:00:00Z");
crate::run::set_home(dir.path().to_path_buf());
let judging = "20260801-000000-0001";
write_meta(&runs, judging, "judging", "2026-08-01T00:00:00Z");
let ready_fresh = "20260904-000000-0002";
write_meta(&runs, ready_fresh, "ready", "2026-09-04T00:00:00Z");
let garbage = "20260901-000000-0004";
std::fs::create_dir_all(runs.join(garbage)).unwrap();
std::fs::write(runs.join(garbage).join("run.json"), "not json").unwrap();
std::fs::create_dir_all(wt.join("0004")).unwrap();
let due_ready = "20260801-000000-ffff";
let mut ready_state = RunState::new(
PathBuf::from("/nonexistent/repo"),
"main".to_owned(),
"0000000000000000000000000000000000000000".to_owned(),
String::new(),
crate::config::Config::default(),
);
ready_state.id = due_ready.to_owned();
ready_state.status = RunStatus::Ready;
ready_state.updated_at = ts("2026-08-01T00:00:00Z");
std::fs::create_dir_all(runs.join(due_ready)).unwrap();
std::fs::write(
runs.join(due_ready).join("run.json"),
serde_json::to_string_pretty(&ready_state).unwrap(),
)
.unwrap();
let folded = block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
assert_eq!(folded, 1, "only the due, readable run");
assert!(runs.join(judging).exists(), "runnable never folded");
assert!(runs.join(ready_fresh).exists(), "fresh never folded");
assert!(runs.join(garbage).exists(), "unreadable record kept");
assert!(wt.join("0004").exists(), "unreadable worktree kept");
assert!(
runs.join(due_ready).exists(),
"folding drops worktrees, not the record"
);
}
fn write_meta(runs: &Path, id: &str, status: &str, updated_at: &str) {
let day = &updated_at[..10];
std::fs::create_dir_all(runs.join(id)).unwrap();
let body = format!(
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}}"#
);
std::fs::write(runs.join(id).join("run.json"), body).unwrap();
}
}