1use std::collections::BTreeSet;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context as _, Result, bail};
15use jiff::{SignedDuration, Timestamp};
16use serde::Deserialize;
17
18use crate::ask::Questions;
19use crate::config::Disk;
20use crate::run::{RunState, RunStatus, SCHEMA, short_of};
21
22use crate::disk::{Prune, dir_size, prune_dir};
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub struct Housekeeping {
27 pub folded: usize,
29 pub unreadable: usize,
35 pub cache_files: usize,
37 pub cache_freed: u64,
39 pub questions_abandoned: usize,
42}
43
44pub async fn housekeep(
51 cfg: &crate::config::Config,
52 home: &Path,
53 worktrees_root: &Path,
54 now: Timestamp,
55) -> Housekeeping {
56 let mut out = Housekeeping::default();
57 if cfg.disk.auto_fold {
58 match fold_due(&home.join("runs"), home, worktrees_root, &cfg.disk, now).await {
59 Ok(folded) => out.folded = folded,
60 Err(e) => tracing::warn!("housekeep: fold due runs: {e:#}"),
61 }
62 }
63 if cfg.disk.cache_limit_bytes > 0 {
69 if let Some(cache) = cfg.cache_dir() {
70 match prune_cache(&cache, cfg.disk.cache_limit_bytes) {
71 Ok(pruned) => {
72 out.cache_files = pruned.files;
73 out.cache_freed = pruned.freed;
74 }
75 Err(e) => tracing::warn!("housekeep: prune cache: {e:#}"),
76 }
77 }
78 }
79 out.questions_abandoned =
87 abandon_settled_questions(&Questions::at(home.join("questions")), &home.join("runs"));
88 out
89}
90
91pub fn abandon_settled_questions(store: &Questions, runs: &Path) -> usize {
102 let waiting_on: BTreeSet<String> = store
103 .list()
104 .into_iter()
105 .filter(|q| q.status.open())
106 .map(|q| q.run)
107 .collect();
108 let mut abandoned = 0;
109 for run in waiting_on {
110 let Ok(meta) = read_meta(runs, &run) else {
111 continue;
112 };
113 match store.settle_run(&run, meta.status) {
114 Ok(n) => abandoned += n,
115 Err(e) => tracing::warn!("housekeep: abandon questions for {run}: {e:#}"),
116 }
117 }
118 abandoned
119}
120
121pub async fn fold_due(
138 runs: &Path,
139 home: &Path,
140 _worktrees_root: &Path,
141 disk: &Disk,
142 now: Timestamp,
143) -> Result<usize> {
144 let mut folded = 0usize;
145 let mut ids: Vec<String> = std::fs::read_dir(runs)
146 .into_iter()
147 .flatten()
148 .flatten()
149 .filter(|e| e.path().join("run.json").is_file())
150 .map(|e| e.file_name().to_string_lossy().into_owned())
151 .collect();
152 ids.sort_unstable();
153 for id in ids {
154 if crate::daemon::is_working_on(home, &id, now) {
155 continue;
156 }
157 let Ok(meta) = read_meta(runs, &id) else {
158 continue;
159 };
160 if meta.status.resumable() || !due(now, meta.updated_at, disk.fold_grace_secs) {
161 continue;
162 }
163 let Ok(mut state) = read_state(runs, &id) else {
168 continue;
169 };
170 let drop_winner = state.status == RunStatus::Merged;
171 match crate::graph::fold_run(&mut state, drop_winner).await {
178 Ok(_) => folded += 1,
179 Err(e) => tracing::warn!("housekeep: fold {id}: {e:#}"),
180 }
181 }
182 Ok(folded)
183}
184
185pub fn due(now: Timestamp, updated: Timestamp, grace_secs: u64) -> bool {
191 now.duration_since(updated) > SignedDuration::new(grace_secs as i64, 0)
192}
193
194#[derive(Deserialize)]
197struct Meta {
198 status: RunStatus,
199 updated_at: Timestamp,
200}
201
202fn read_meta(runs: &Path, id: &str) -> Result<Meta> {
206 let path = runs.join(id).join("run.json");
207 let body =
208 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
209 let meta: Meta =
210 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
211 Ok(meta)
212}
213
214fn read_state(runs: &Path, id: &str) -> Result<RunState> {
219 let path = runs.join(id).join("run.json");
220 let body =
221 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
222 let state: RunState =
223 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
224 if state.schema != SCHEMA {
225 bail!(
226 "run {} was written by a different magi (schema {}, this build \
227 speaks {SCHEMA})",
228 state.id,
229 state.schema
230 );
231 }
232 Ok(state)
233}
234
235pub async fn fold_unreadable(runs: &Path, worktrees_root: &Path, id: &str) -> Result<Vec<String>> {
251 let resolved = resolve_id_path(runs, id)?;
252 let mut removed = Vec::new();
253 let run_dir = runs.join(&resolved);
254 if run_dir.exists() {
255 std::fs::remove_dir_all(&run_dir)
256 .with_context(|| format!("remove {}", run_dir.display()))?;
257 removed.push(format!("runs/{resolved}"));
258 }
259 let wt = worktrees_root.join(short_of(&resolved));
260 if wt.exists() {
261 crate::git::remove_worktree_from_linked(&wt).await;
262 for e in std::fs::read_dir(&wt).into_iter().flatten().flatten() {
263 crate::git::remove_worktree_from_linked(&e.path()).await;
264 }
265 std::fs::remove_dir_all(&wt).with_context(|| format!("remove {}", wt.display()))?;
266 removed.push(wt.to_string_lossy().into_owned());
267 }
268 Ok(removed)
269}
270
271fn resolve_id_path(runs: &Path, prefix: &str) -> Result<String> {
274 if runs.join(prefix).is_dir() && crate::run::is_run_id(prefix) {
279 return Ok(prefix.to_owned());
280 }
281 let mut hits: Vec<String> = Vec::new();
282 for e in std::fs::read_dir(runs).into_iter().flatten().flatten() {
283 if !e.path().is_dir() {
284 continue;
285 }
286 let id = e.file_name().to_string_lossy().into_owned();
287 if crate::run::is_run_id(&id) && (id.starts_with(prefix) || id.ends_with(prefix)) {
288 hits.push(id);
289 }
290 }
291 match hits.len() {
292 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
293 0 => bail!("no run matches `{prefix}`"),
294 _ => bail!(
295 "`{prefix}` matches {} runs: {}",
296 hits.len(),
297 hits.join(", ")
298 ),
299 }
300}
301
302pub fn prune_cache(cache: &Path, limit_bytes: u64) -> Result<Prune> {
306 prune_dir(cache, limit_bytes)
307}
308
309pub fn cache_report(cfg: &crate::config::Config) -> Option<(PathBuf, u64, u64)> {
315 let cache = cfg.cache_dir()?;
316 Some((
317 cache.clone(),
318 cache_size(&cache),
319 cfg.disk.cache_limit_bytes,
320 ))
321}
322
323pub fn cache_size(cache: &Path) -> u64 {
325 dir_size(cache)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::config::Disk;
332 use std::fs;
333
334 fn ts(s: &str) -> Timestamp {
335 s.parse().expect("rfc3339")
336 }
337
338 fn block_on<F: std::future::Future>(f: F) -> F::Output {
339 tokio::runtime::Runtime::new().expect("runtime").block_on(f)
340 }
341
342 #[test]
343 fn a_run_is_due_after_its_grace_and_not_before() {
344 let now = ts("2026-09-05T00:00:00Z");
345 let grace = 600;
346 let old = now - SignedDuration::new(601, 0);
347 let fresh = now - SignedDuration::new(599, 0);
348 assert!(due(now, old, grace));
349 assert!(!due(now, fresh, grace));
350 let edge = now - SignedDuration::new(600, 0);
352 assert!(!due(now, edge, grace));
353 assert!(due(now, old, 0));
355 }
356
357 #[test]
358 fn the_meta_reader_is_tolerant_of_everything_except_the_deciders() {
359 let dir = tempfile::tempdir().unwrap();
360 let runs = dir.path().join("runs");
361 let id = "20260905-000000-abcd";
362 std::fs::create_dir_all(runs.join(id)).unwrap();
363 std::fs::write(
364 runs.join(id).join("run.json"),
365 r#"{"schema": 99, "id": "20260905-000000-abcd", "updated_at": "2026-09-05T00:00:00Z", "status": "ready", "junk_from_another_build": [1, 2, 3]}"#,
366 )
367 .unwrap();
368 let meta = read_meta(&runs, id).expect("readable");
369 assert_eq!(meta.status, RunStatus::Ready);
370 assert_eq!(meta.updated_at, ts("2026-09-05T00:00:00Z"));
371 assert!(read_meta(&runs, "nope").is_err(), "missing file unreadable");
372 std::fs::write(runs.join(id).join("run.json"), "not json at all").unwrap();
373 assert!(read_meta(&runs, id).is_err(), "garbage unreadable");
374 }
375
376 #[test]
377 fn fold_unreadable_releases_run_dir_and_worktrees() {
378 let dir = tempfile::tempdir().unwrap();
379 let runs = dir.path().join("runs");
380 let wt = dir.path().join("wt");
381 let id = "20260905-000000-abcd";
382 std::fs::create_dir_all(runs.join(id)).unwrap();
383 std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
384 std::fs::create_dir_all(wt.join("abcd")).unwrap();
385 std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
386
387 let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold");
388 assert_eq!(removed.len(), 2);
389 assert!(!runs.join(id).exists(), "run dir gone");
390 assert!(!wt.join("abcd").exists(), "worktrees gone");
391
392 std::fs::create_dir_all(runs.join(id)).unwrap();
394 std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
395 std::fs::create_dir_all(wt.join("abcd")).unwrap();
396 std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
397 let removed = block_on(fold_unreadable(&runs, &wt, "20260905")).expect("by prefix");
398 assert_eq!(removed.len(), 2);
399 assert!(
403 block_on(fold_unreadable(&runs, &wt, id)).is_err(),
404 "a run already gone cannot be resolved again"
405 );
406 }
407
408 #[test]
409 fn prune_cache_sheds_the_oldest_generation_until_it_fits() {
410 let dir = tempfile::tempdir().unwrap();
411 fs::write(dir.path().join("old"), b"xx").unwrap();
414 fs::write(dir.path().join("new"), b"yy").unwrap();
415 touch(&dir.path().join("old"), 1_000_000);
416 touch(&dir.path().join("new"), 2_000_000);
417
418 let out = prune_cache(dir.path(), 2).expect("prune");
419 assert_eq!(out.files, 1, "one deletion is enough to reach the cap");
420 assert_eq!(out.remaining, 2);
421 assert!(!dir.path().join("old").exists(), "the older file went");
422 assert!(dir.path().join("new").exists(), "the newer one stayed");
423
424 let tied = tempfile::tempdir().unwrap();
429 fs::write(tied.path().join("big"), b"xxxx").unwrap();
430 fs::write(tied.path().join("small"), b"yy").unwrap();
431 touch(&tied.path().join("big"), 1_000_000);
432 touch(&tied.path().join("small"), 1_000_000);
433 let out = prune_cache(tied.path(), 2).expect("prune");
434 assert_eq!(out.files, 1, "the big one alone gets under the cap");
435 assert_eq!(out.remaining, 2);
436 assert!(tied.path().join("small").exists());
437 }
438
439 fn touch(path: &Path, secs: u64) {
442 let f = fs::File::options().write(true).open(path).unwrap();
443 f.set_times(fs::FileTimes::new().set_modified(
444 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs),
445 ))
446 .unwrap();
447 }
448
449 #[test]
452 fn fold_unreadable_clears_a_run_whose_state_never_landed() {
453 let dir = tempfile::tempdir().unwrap();
454 let runs = dir.path().join("runs");
455 let wt = dir.path().join("wt");
456 let id = "20260904-014540-88c0";
457 std::fs::create_dir_all(runs.join(id)).unwrap();
458 std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
459
460 let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold by id");
461 assert_eq!(removed, vec![format!("runs/{id}")]);
462 assert!(!runs.join(id).exists(), "record gone");
463
464 std::fs::create_dir_all(runs.join(id)).unwrap();
466 std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
467 assert!(
468 block_on(fold_unreadable(&runs, &wt, "88c0")).is_ok(),
469 "by prefix"
470 );
471
472 std::fs::create_dir_all(runs.join("scratch")).unwrap();
474 assert!(
475 block_on(fold_unreadable(&runs, &wt, "scratch")).is_err(),
476 "a stray directory is not a run"
477 );
478 }
479
480 #[test]
481 fn fold_due_skips_fresh_runnable_and_unreadable_but_folds_a_due_terminal_run() {
482 let dir = tempfile::tempdir().unwrap();
483 let runs = dir.path().join("runs");
484 let wt = dir.path().join("wt");
485 let home = dir.path().to_path_buf();
486 let disk = Disk::default();
487 let now = ts("2026-09-05T00:00:00Z");
488 crate::run::set_home(dir.path().to_path_buf());
494
495 let judging = "20260801-000000-0001";
497 write_meta(&runs, judging, "judging", "2026-08-01T00:00:00Z");
498
499 let ready_fresh = "20260904-000000-0002";
501 write_meta(&runs, ready_fresh, "ready", "2026-09-04T00:00:00Z");
502
503 let garbage = "20260901-000000-0004";
507 std::fs::create_dir_all(runs.join(garbage)).unwrap();
508 std::fs::write(runs.join(garbage).join("run.json"), "not json").unwrap();
509 std::fs::create_dir_all(wt.join("0004")).unwrap();
510
511 let due_ready = "20260801-000000-ffff";
514 let mut ready_state = RunState::new(
515 PathBuf::from("/nonexistent/repo"),
516 "main".to_owned(),
517 "0000000000000000000000000000000000000000".to_owned(),
518 String::new(),
519 crate::config::Config::default(),
520 );
521 ready_state.id = due_ready.to_owned();
522 ready_state.status = RunStatus::Ready;
523 ready_state.updated_at = ts("2026-08-01T00:00:00Z");
524 std::fs::create_dir_all(runs.join(due_ready)).unwrap();
525 std::fs::write(
526 runs.join(due_ready).join("run.json"),
527 serde_json::to_string_pretty(&ready_state).unwrap(),
528 )
529 .unwrap();
530
531 let folded = block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
532 assert_eq!(folded, 1, "only the due, readable run");
533 assert!(runs.join(judging).exists(), "runnable never folded");
534 assert!(runs.join(ready_fresh).exists(), "fresh never folded");
535 assert!(runs.join(garbage).exists(), "unreadable record kept");
536 assert!(wt.join("0004").exists(), "unreadable worktree kept");
537 assert!(
538 runs.join(due_ready).exists(),
539 "folding drops worktrees, not the record"
540 );
541 }
542
543 fn open_question(store: &Questions, run: &str) -> crate::ask::Question {
545 let mut q = crate::ask::Question::new(
546 run.to_owned(),
547 "implement".to_owned(),
548 "impl-A".to_owned(),
549 "Which storage backend should the cache use?".to_owned(),
550 String::new(),
551 vec!["SQLite".to_owned(), "Redis".to_owned()],
552 );
553 store.put(&mut q).unwrap();
554 q
555 }
556
557 #[test]
563 fn a_finished_runs_open_question_is_swept_up() {
564 let dir = tempfile::tempdir().unwrap();
565 let runs = dir.path().join("runs");
566 let store = Questions::at(dir.path().join("questions"));
567
568 let failed = "20260908-205802-c9eb";
569 write_meta(&runs, failed, "failed", "2026-09-08T20:58:02Z");
570 let failed_q = open_question(&store, failed);
571
572 let merged = "20260908-205501-ca67";
573 write_meta(&runs, merged, "merged", "2026-09-08T20:55:01Z");
574 let merged_q = open_question(&store, merged);
575
576 let n = abandon_settled_questions(&store, &runs);
577 assert_eq!(n, 2, "both dead runs' questions are swept in one pass");
578
579 for (id, run) in [(&failed_q.id, failed), (&merged_q.id, merged)] {
580 let back = store.get(id).unwrap();
581 assert!(!back.status.open(), "{run} is done; nobody reads an answer");
582 assert!(back.detail.contains(run), "{}", back.detail);
583 }
584 }
585
586 #[test]
587 fn a_still_alive_runs_open_question_survives_the_sweep() {
588 let dir = tempfile::tempdir().unwrap();
589 let runs = dir.path().join("runs");
590 let store = Questions::at(dir.path().join("questions"));
591
592 for (id, status) in [
596 ("20260908-000000-b10c", "blocked"),
597 ("20260908-000000-5ta1", "stalled"),
598 ("20260908-000000-jud6", "judging"),
599 ] {
600 write_meta(&runs, id, status, "2026-09-08T00:00:00Z");
601 let q = open_question(&store, id);
602
603 let n = abandon_settled_questions(&store, &runs);
604 assert_eq!(n, 0, "{status} run is not done; nothing to sweep");
605 assert!(
606 store.get(&q.id).unwrap().status.open(),
607 "{status} run's question must still be waiting"
608 );
609 }
610 }
611
612 #[test]
613 fn the_sweep_leaves_an_answered_question_and_an_unreadable_run_alone() {
614 let dir = tempfile::tempdir().unwrap();
615 let runs = dir.path().join("runs");
616 let store = Questions::at(dir.path().join("questions"));
617
618 let done = "20260908-000000-answ";
621 write_meta(&runs, done, "failed", "2026-09-08T00:00:00Z");
622 let mut answered = open_question(&store, done);
623 answered
624 .answer(crate::ask::Answer::Choice("SQLite".to_owned()))
625 .unwrap();
626 store.put(&mut answered).unwrap();
627
628 let gone = "20260908-000000-gone";
630 let orphan = open_question(&store, gone);
631
632 assert_eq!(abandon_settled_questions(&store, &runs), 0);
633 assert_eq!(
634 store.get(&answered.id).unwrap().status,
635 crate::ask::QuestionStatus::Answered,
636 "a real answer is never overwritten by a sweep"
637 );
638 assert!(
639 store.get(&orphan.id).unwrap().status.open(),
640 "a run this sweep cannot read is left exactly as it was, not guessed at"
641 );
642 }
643
644 fn write_meta(runs: &Path, id: &str, status: &str, updated_at: &str) {
646 let day = &updated_at[..10];
647 std::fs::create_dir_all(runs.join(id)).unwrap();
648 let body = format!(
649 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}}"#
650 );
651 std::fs::write(runs.join(id).join("run.json"), body).unwrap();
652 }
653}