1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
24pub struct Housekeeping {
25 pub folded: usize,
27 pub unreadable: usize,
33 pub cache_files: usize,
35 pub cache_freed: u64,
37}
38
39pub 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 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
77pub 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 let Ok(mut state) = read_state(runs, &id) else {
124 continue;
125 };
126 let drop_winner = state.status == RunStatus::Merged;
127 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
141pub 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#[derive(Deserialize)]
153struct Meta {
154 status: RunStatus,
155 updated_at: Timestamp,
156}
157
158fn 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
170fn 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
191pub 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
227fn resolve_id_path(runs: &Path, prefix: &str) -> Result<String> {
230 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
258pub fn prune_cache(cache: &Path, limit_bytes: u64) -> Result<Prune> {
262 prune_dir(cache, limit_bytes)
263}
264
265pub 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
279pub 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 let edge = now - SignedDuration::new(600, 0);
308 assert!(!due(now, edge, grace));
309 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 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 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 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 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 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 #[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 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 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 crate::run::set_home(dir.path().to_path_buf());
450
451 let judging = "20260801-000000-0001";
453 write_meta(&runs, judging, "judging", "2026-08-01T00:00:00Z");
454
455 let ready_fresh = "20260904-000000-0002";
457 write_meta(&runs, ready_fresh, "ready", "2026-09-04T00:00:00Z");
458
459 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 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 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}