1use 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};
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub struct Housekeeping {
40 pub folded: usize,
42 pub unreadable: usize,
52 pub orphaned_worktrees: usize,
55 pub cache_files: usize,
57 pub cache_freed: u64,
59 pub questions_abandoned: usize,
62 pub external_merges_recorded: usize,
67 pub stale_pr_states_repaired: usize,
71}
72
73pub async fn housekeep(
81 cfg: &crate::config::Config,
82 home: &Path,
83 worktrees_root: &Path,
84 repo: &Path,
85 now: Timestamp,
86) -> Housekeeping {
87 let mut out = Housekeeping::default();
88 if cfg.disk.auto_fold {
89 let runs = home.join("runs");
90 match fold_due(&runs, home, worktrees_root, &cfg.disk, now).await {
91 Ok((folded, unreadable)) => {
92 out.folded = folded;
93 out.unreadable = unreadable;
94 }
95 Err(e) => {
96 tracing::warn!("housekeep: fold due runs: {e:#}");
97 crate::notices::raise_in(
100 home,
101 crate::notices::Notice::warn(
102 "housekeep:fold",
103 "Automatic cleanup of finished runs failed; disk usage may keep growing.",
104 ),
105 );
106 }
107 }
108 out.orphaned_worktrees =
109 fold_orphaned_worktrees(&runs, worktrees_root, home, cfg.disk.fold_grace_secs, now)
110 .await;
111 if let Err(e) = crate::git::worktree_prune(repo).await {
115 tracing::warn!("housekeep: prune worktree registrations: {e:#}");
116 }
117 out.external_merges_recorded = reconcile_external_merges(&runs, home, &cfg.disk, now).await;
118 out.stale_pr_states_repaired = crate::land::repair_stale_pr_states(home, 5).await.0;
121 }
122 match prune_cache_if_over_limit(cfg, home) {
123 Ok(Some(pruned)) => {
124 out.cache_files = pruned.files;
125 out.cache_freed = pruned.freed;
126 }
127 Ok(None) => {}
128 Err(e) => tracing::warn!("housekeep: prune cache: {e:#}"),
129 }
130 out.questions_abandoned =
138 abandon_settled_questions(&Questions::at(home.join("questions")), &home.join("runs"));
139 out
140}
141
142pub fn abandon_settled_questions(store: &Questions, runs: &Path) -> usize {
153 let waiting_on: BTreeSet<String> = store
154 .list()
155 .into_iter()
156 .filter(|q| q.status.open())
157 .map(|q| q.run)
158 .collect();
159 let mut abandoned = 0;
160 for run in waiting_on {
161 let Ok(meta) = read_meta(runs, &run) else {
162 continue;
163 };
164 match store.settle_run(&run, meta.status) {
165 Ok(n) => abandoned += n,
166 Err(e) => tracing::warn!("housekeep: abandon questions for {run}: {e:#}"),
167 }
168 }
169 abandoned
170}
171
172pub async fn fold_due(
197 runs: &Path,
198 home: &Path,
199 _worktrees_root: &Path,
200 disk: &Disk,
201 now: Timestamp,
202) -> Result<(usize, usize)> {
203 let mut folded = 0usize;
204 let mut unreadable = 0usize;
205 let mut ids: Vec<String> = std::fs::read_dir(runs)
206 .into_iter()
207 .flatten()
208 .flatten()
209 .filter(|e| e.path().join("run.json").is_file())
210 .map(|e| e.file_name().to_string_lossy().into_owned())
211 .collect();
212 ids.sort_unstable();
213 for id in ids {
214 if crate::daemon::is_working_on(home, &id, now) {
215 continue;
216 }
217 let meta = match read_meta(runs, &id) {
218 Ok(meta) => meta,
219 Err(e) => {
220 unreadable += 1;
221 tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
222 continue;
223 }
224 };
225 if meta.status.resumable() || !due(now, meta.updated_at, disk.fold_grace_secs) {
226 continue;
227 }
228 let mut state = match read_state(runs, &id) {
237 Ok(state) => state,
238 Err(e) => {
239 unreadable += 1;
240 tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
241 continue;
242 }
243 };
244 if state.schema != SCHEMA {
245 tracing::info!(
246 "housekeep: run {id} was written by schema {} (this build speaks {SCHEMA}); \
247 folding it anyway",
248 state.schema
249 );
250 }
251 let drop_winner = matches!(
261 state.status,
262 RunStatus::Merged | RunStatus::Superseded | RunStatus::AlreadyInBase
263 );
264 match crate::graph::fold_run(&mut state, drop_winner, home).await {
271 Ok(_) => folded += 1,
272 Err(e) => tracing::warn!("housekeep: fold {id}: {e:#}"),
273 }
274 }
275 Ok((folded, unreadable))
276}
277
278#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
280pub struct FetchReport {
281 pub fetched: usize,
283 pub no_origin: usize,
285 pub failed: usize,
287}
288
289pub async fn fetch_origins(
297 roots: &[PathBuf],
298 per_repo: std::time::Duration,
299 stop: impl Fn() -> bool,
300) -> FetchReport {
301 let mut report = FetchReport::default();
302 for repo in crate::repos::scan(roots) {
303 if stop() {
304 break;
305 }
306 match crate::git::git_raw(&repo.path, &["remote", "get-url", "origin"]).await {
307 Ok(out) if out.ok() => {}
308 _ => {
309 report.no_origin += 1;
310 continue;
311 }
312 }
313 match crate::git::fetch_origin(&repo.path, per_repo).await {
314 Ok(()) => report.fetched += 1,
315 Err(e) => {
316 tracing::warn!("fetch origin in {}: {e:#}", repo.name);
317 report.failed += 1;
318 }
319 }
320 }
321 report
322}
323
324pub fn due(now: Timestamp, updated: Timestamp, grace_secs: u64) -> bool {
330 now.duration_since(updated) > SignedDuration::new(grace_secs as i64, 0)
331}
332
333#[derive(Deserialize)]
336struct Meta {
337 status: RunStatus,
338 updated_at: Timestamp,
339}
340
341fn read_meta(runs: &Path, id: &str) -> Result<Meta> {
345 let path = runs.join(id).join("run.json");
346 let body =
347 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
348 let meta: Meta =
349 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
350 Ok(meta)
351}
352
353const MAX_EXTERNAL_MERGE_CHECKS_PER_PASS: usize = 5;
359
360#[derive(Deserialize)]
363struct ExternalMergeMeta {
364 status: RunStatus,
365 updated_at: Timestamp,
366 #[serde(default)]
367 merge: Option<crate::run::MergeOutcome>,
368}
369
370fn read_external_merge_meta(runs: &Path, id: &str) -> Result<ExternalMergeMeta> {
371 let path = runs.join(id).join("run.json");
372 let body =
373 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
374 let meta: ExternalMergeMeta =
375 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
376 Ok(meta)
377}
378
379pub fn eligible_for_external_merge_check(
388 status: RunStatus,
389 merge_recorded: bool,
390 updated_at: Timestamp,
391 now: Timestamp,
392 grace_secs: u64,
393) -> bool {
394 status == RunStatus::Blocked && !merge_recorded && due(now, updated_at, grace_secs)
395}
396
397fn rotate_from(ids: &[String], start: usize) -> Vec<String> {
415 if ids.is_empty() {
416 return Vec::new();
417 }
418 let start = start % ids.len();
419 ids[start..]
420 .iter()
421 .chain(ids[..start].iter())
422 .cloned()
423 .collect()
424}
425
426const EXTERNAL_MERGE_CURSOR_FILE: &str = "external-merge-cursor";
430
431fn read_external_merge_cursor(home: &Path) -> usize {
436 std::fs::read_to_string(home.join(EXTERNAL_MERGE_CURSOR_FILE))
437 .ok()
438 .and_then(|s| s.trim().parse().ok())
439 .unwrap_or(0)
440}
441
442fn write_external_merge_cursor(home: &Path, cursor: usize) {
447 if let Err(e) = std::fs::write(home.join(EXTERNAL_MERGE_CURSOR_FILE), cursor.to_string()) {
448 tracing::warn!("housekeep: persist the external-merge sweep's cursor: {e:#}");
449 }
450}
451
452async fn reconcile_external_merges(runs: &Path, home: &Path, disk: &Disk, now: Timestamp) -> usize {
473 let mut ids: Vec<String> = std::fs::read_dir(runs)
474 .into_iter()
475 .flatten()
476 .flatten()
477 .filter(|e| e.path().join("run.json").is_file())
478 .map(|e| e.file_name().to_string_lossy().into_owned())
479 .collect();
480 ids.sort_unstable();
481
482 let mut eligible: Vec<String> = Vec::new();
488 for id in ids {
489 if crate::daemon::is_working_on(home, &id, now) {
490 continue;
491 }
492 let meta = match read_external_merge_meta(runs, &id) {
493 Ok(meta) => meta,
494 Err(_) => continue,
497 };
498 if eligible_for_external_merge_check(
499 meta.status,
500 meta.merge.is_some(),
501 meta.updated_at,
502 now,
503 disk.fold_grace_secs,
504 ) {
505 eligible.push(id);
506 }
507 }
508
509 if eligible.is_empty() {
510 return 0;
511 }
512 let cursor = read_external_merge_cursor(home);
513 let window: Vec<String> = rotate_from(&eligible, cursor)
514 .into_iter()
515 .take(MAX_EXTERNAL_MERGE_CHECKS_PER_PASS)
516 .collect();
517 write_external_merge_cursor(home, (cursor + window.len()) % eligible.len());
522
523 let mut reconciled = 0usize;
524 for id in window {
525 let mut state = match read_state(runs, &id) {
526 Ok(state) => state,
527 Err(_) => continue,
528 };
529 match crate::land::find_external_merge(&state).await {
530 Ok(Some(found)) => {
531 match crate::land::correct_confirmed_external_merge(&mut state, &found.url).await {
532 Ok(_) => {
533 if let Err(e) = crate::graph::fold_run(&mut state, true, home).await {
534 tracing::warn!(
535 "housekeep: fold {id} after recording its external merge: {e:#}"
536 );
537 }
538 reconciled += 1;
539 }
540 Err(e) => tracing::warn!(
541 "housekeep: record external merge {} for {id}: {e:#}",
542 found.url
543 ),
544 }
545 }
546 Ok(None) => {}
547 Err(e) => {
548 tracing::warn!("housekeep: check external merge for {id}: {e:#}");
549 crate::notices::raise_in(
550 home,
551 crate::notices::Notice::warn(
552 &format!("merged-unrecorded:{id}"),
553 format!(
554 "Run {id} is blocked with no recorded merge, and checking GitHub \
555 for a merge failed; check by hand."
556 ),
557 ),
558 );
559 }
560 }
561 }
562 reconciled
563}
564
565fn read_state(runs: &Path, id: &str) -> Result<RunState> {
580 let path = runs.join(id).join("run.json");
581 let body =
582 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
583 let state: RunState =
584 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
585 Ok(state)
586}
587
588pub async fn fold_unreadable(runs: &Path, worktrees_root: &Path, id: &str) -> Result<Vec<String>> {
604 let resolved = resolve_id_path(runs, id)?;
605 let mut removed = Vec::new();
606 let run_dir = runs.join(&resolved);
607 if run_dir.exists() {
608 std::fs::remove_dir_all(&run_dir)
609 .with_context(|| format!("remove {}", run_dir.display()))?;
610 removed.push(format!("runs/{resolved}"));
611 }
612 let wt = worktrees_root.join(short_of(&resolved));
613 if wt.exists() {
614 crate::git::remove_worktree_from_linked(&wt).await;
615 for e in std::fs::read_dir(&wt).into_iter().flatten().flatten() {
616 crate::git::remove_worktree_from_linked(&e.path()).await;
617 }
618 std::fs::remove_dir_all(&wt).with_context(|| format!("remove {}", wt.display()))?;
619 removed.push(wt.to_string_lossy().into_owned());
620 }
621 Ok(removed)
622}
623
624pub async fn fold_orphaned_worktrees(
663 runs: &Path,
664 worktrees_root: &Path,
665 home: &Path,
666 grace_secs: u64,
667 now: Timestamp,
668) -> usize {
669 let known: std::collections::HashSet<String> = std::fs::read_dir(runs)
670 .into_iter()
671 .flatten()
672 .flatten()
673 .map(|e| e.file_name().to_string_lossy().into_owned())
674 .filter(|name| crate::run::is_run_id(name))
675 .map(|id| short_of(&id).to_owned())
676 .collect();
677
678 let mut folded = 0usize;
679 for entry in std::fs::read_dir(worktrees_root)
680 .into_iter()
681 .flatten()
682 .flatten()
683 {
684 if !entry.path().is_dir() {
685 continue;
686 }
687 let short = entry.file_name().to_string_lossy().into_owned();
688 if !looks_like_a_worktree_bay(&short) {
689 continue;
690 }
691 if known.contains(&short) || crate::daemon::is_working_on_short(home, &short, now) {
692 continue;
693 }
694 let wt = entry.path();
695 if !stale_enough(&wt, grace_secs, now) {
696 continue;
697 }
698 crate::git::remove_worktree_from_linked(&wt).await;
699 for e in std::fs::read_dir(&wt).into_iter().flatten().flatten() {
700 crate::git::remove_worktree_from_linked(&e.path()).await;
701 }
702 match std::fs::remove_dir_all(&wt) {
703 Ok(()) => folded += 1,
704 Err(e) => tracing::warn!(
705 "housekeep: remove orphaned worktree {}: {e:#}",
706 wt.display()
707 ),
708 }
709 }
710 folded
711}
712
713fn looks_like_a_worktree_bay(name: &str) -> bool {
722 name.len() == 4 && name.bytes().all(|b| b.is_ascii_alphanumeric())
723}
724
725fn stale_enough(dir: &Path, grace_secs: u64, now: Timestamp) -> bool {
744 let Ok(modified) = std::fs::metadata(dir).and_then(|m| m.modified()) else {
745 return false;
746 };
747 let Ok(ts) = Timestamp::try_from(modified) else {
748 return false;
749 };
750 due(now, ts, grace_secs.max(MIN_ORPHAN_AGE_SECS))
751}
752
753const MIN_ORPHAN_AGE_SECS: u64 = 5 * 60;
764
765fn resolve_id_path(runs: &Path, prefix: &str) -> Result<String> {
768 if runs.join(prefix).is_dir() && crate::run::is_run_id(prefix) {
773 return Ok(prefix.to_owned());
774 }
775 let mut hits: Vec<String> = Vec::new();
776 for e in std::fs::read_dir(runs).into_iter().flatten().flatten() {
777 if !e.path().is_dir() {
778 continue;
779 }
780 let id = e.file_name().to_string_lossy().into_owned();
781 if crate::run::is_run_id(&id) && (id.starts_with(prefix) || id.ends_with(prefix)) {
782 hits.push(id);
783 }
784 }
785 match hits.len() {
786 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
787 0 => bail!("no run matches `{prefix}`"),
788 _ => bail!(
789 "`{prefix}` matches {} runs: {}",
790 hits.len(),
791 hits.join(", ")
792 ),
793 }
794}
795
796pub fn clear_abandoned_active(state: &mut RunState, home: &Path, now: Timestamp) -> Result<bool> {
811 if crate::daemon::is_working_on(home, &state.id, now) || !state.active_all_overrun(now) {
812 return Ok(false);
813 }
814 state.abandon("fold");
815 state.save_under(home)?;
816 if let Err(e) = Questions::at(home.join("questions")).settle_run(&state.id, state.status) {
824 tracing::warn!("abandon questions for {}: {e:#}", state.id);
825 }
826 Ok(true)
827}
828
829pub fn prune_cache(home: &Path, cache: &Path, limit_bytes: u64) -> Result<Option<Prune>> {
838 crate::cache::maintenance_prune(home, cache, limit_bytes)
839}
840
841pub fn prune_cache_if_over_limit(
851 cfg: &crate::config::Config,
852 home: &Path,
853) -> Result<Option<Prune>> {
854 if cfg.disk.cache_limit_bytes == 0 {
855 return Ok(None);
856 }
857 let Some(cache) = cfg.cache_dir() else {
858 return Ok(None);
859 };
860 prune_cache(home, &cache, cfg.disk.cache_limit_bytes)
861}
862
863pub fn cache_report(cfg: &crate::config::Config) -> Option<(PathBuf, u64, u64)> {
869 let cache = cfg.cache_dir()?;
870 Some((
871 cache.clone(),
872 cache_size(&cache),
873 cfg.disk.cache_limit_bytes,
874 ))
875}
876
877pub fn cache_size(cache: &Path) -> u64 {
879 dir_size(cache)
880}
881
882#[cfg(test)]
883mod tests {
884
885 async fn sh(cwd: &Path, args: &[&str]) {
886 let out = crate::git::git_raw(cwd, args).await.expect("spawn git");
887 assert!(out.ok(), "git {args:?}: {}", out.stderr);
888 }
889
890 async fn commit(cwd: &Path, file: &str, body: &str) {
891 std::fs::write(cwd.join(file), body).unwrap();
892 sh(cwd, &["add", file]).await;
893 sh(
894 cwd,
895 &[
896 "-c",
897 "user.name=t",
898 "-c",
899 "user.email=t@example.com",
900 "commit",
901 "-q",
902 "-m",
903 body,
904 ],
905 )
906 .await;
907 }
908
909 const LONG: std::time::Duration = std::time::Duration::from_secs(30);
910
911 #[tokio::test]
912 async fn fetch_advances_origin_main_and_leaves_the_checkout_alone() {
913 let tmp = tempfile::tempdir().unwrap();
914 let t = tmp.path();
915 let bare = t.join("remote.git");
916 sh(
917 t,
918 &["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
919 )
920 .await;
921 let dir = t.join("root/h/o/r");
922 std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
923 sh(
924 t,
925 &["clone", "-q", bare.to_str().unwrap(), dir.to_str().unwrap()],
926 )
927 .await;
928 sh(&dir, &["checkout", "-q", "-b", "main"]).await;
929 commit(&dir, "a.txt", "one").await;
930 sh(&dir, &["push", "-q", "origin", "main"]).await;
931 sh(&dir, &["checkout", "-q", "--detach"]).await;
932 std::fs::write(dir.join("a.txt"), "dirty").unwrap();
933
934 let other = t.join("other");
935 sh(
936 t,
937 &[
938 "clone",
939 "-q",
940 bare.to_str().unwrap(),
941 other.to_str().unwrap(),
942 ],
943 )
944 .await;
945 commit(&other, "b.txt", "two").await;
946 sh(&other, &["push", "-q", "origin", "HEAD:main"]).await;
947
948 let rev = |d: PathBuf, r: &'static str| async move {
949 crate::git::git(&d, &["rev-parse", r]).await.unwrap()
950 };
951 let head = rev(dir.clone(), "HEAD").await;
952 let before = rev(dir.clone(), "origin/main").await;
953 let status = crate::git::git(&dir, &["status", "--porcelain"])
954 .await
955 .unwrap();
956
957 let r = fetch_origins(&[t.join("root")], LONG, || false).await;
958 assert_eq!(
959 r,
960 FetchReport {
961 fetched: 1,
962 no_origin: 0,
963 failed: 0
964 }
965 );
966
967 assert_ne!(rev(dir.clone(), "origin/main").await, before);
968 assert_eq!(rev(dir.clone(), "HEAD").await, head);
969 assert_eq!(
970 crate::git::git(&dir, &["status", "--porcelain"])
971 .await
972 .unwrap(),
973 status
974 );
975 assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "dirty");
976 }
977
978 #[tokio::test]
979 async fn fetch_never_writes_a_local_branch_whatever_the_configured_refspec() {
980 let tmp = tempfile::tempdir().unwrap();
981 let t = tmp.path();
982 let bare = t.join("remote.git");
983 sh(
984 t,
985 &["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
986 )
987 .await;
988 let dir = t.join("root/h/o/r");
989 std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
990 sh(
991 t,
992 &["clone", "-q", bare.to_str().unwrap(), dir.to_str().unwrap()],
993 )
994 .await;
995 sh(&dir, &["checkout", "-q", "-b", "main"]).await;
996 commit(&dir, "a.txt", "one").await;
997 sh(&dir, &["push", "-q", "origin", "main"]).await;
998 sh(&dir, &["checkout", "-q", "--detach"]).await;
999 sh(
1001 &dir,
1002 &[
1003 "config",
1004 "remote.origin.fetch",
1005 "+refs/heads/main:refs/heads/main",
1006 ],
1007 )
1008 .await;
1009 let _ = crate::git::git_raw(&dir, &["update-ref", "-d", "refs/remotes/origin/main"]).await;
1010
1011 let other = t.join("other");
1012 sh(
1013 t,
1014 &[
1015 "clone",
1016 "-q",
1017 bare.to_str().unwrap(),
1018 other.to_str().unwrap(),
1019 ],
1020 )
1021 .await;
1022 commit(&other, "b.txt", "two").await;
1023 sh(&other, &["push", "-q", "origin", "HEAD:main"]).await;
1024 let remote_tip = crate::git::git(&other, &["rev-parse", "HEAD"])
1025 .await
1026 .unwrap();
1027 let local_main = crate::git::git(&dir, &["rev-parse", "refs/heads/main"])
1028 .await
1029 .unwrap();
1030
1031 let r = fetch_origins(&[t.join("root")], LONG, || false).await;
1032 assert_eq!(r.fetched, 1);
1033 assert_eq!(
1034 crate::git::git(&dir, &["rev-parse", "refs/remotes/origin/main"])
1035 .await
1036 .unwrap(),
1037 remote_tip
1038 );
1039 assert_eq!(
1040 crate::git::git(&dir, &["rev-parse", "refs/heads/main"])
1041 .await
1042 .unwrap(),
1043 local_main
1044 );
1045 }
1046
1047 #[tokio::test]
1048 async fn fetch_skips_no_origin_and_survives_an_unreachable_origin() {
1049 let tmp = tempfile::tempdir().unwrap();
1050 let t = tmp.path();
1051 let root = t.join("root");
1052 let bare = t.join("remote.git");
1053 sh(t, &["init", "-q", "--bare", bare.to_str().unwrap()]).await;
1054
1055 let none = root.join("h/o/none");
1056 let dead = root.join("h/o/dead");
1057 let good = root.join("h/o/good");
1058 for d in [&none, &dead, &good] {
1059 std::fs::create_dir_all(d).unwrap();
1060 sh(d, &["init", "-q"]).await;
1061 }
1062 sh(
1063 &dead,
1064 &[
1065 "remote",
1066 "add",
1067 "origin",
1068 t.join("missing").to_str().unwrap(),
1069 ],
1070 )
1071 .await;
1072 sh(&good, &["remote", "add", "origin", bare.to_str().unwrap()]).await;
1073
1074 let r = fetch_origins(&[root], LONG, || false).await;
1075 assert_eq!(
1076 r,
1077 FetchReport {
1078 fetched: 1,
1079 no_origin: 1,
1080 failed: 1
1081 }
1082 );
1083 }
1084 use super::*;
1085 use crate::config::Disk;
1086 use std::fs;
1087
1088 fn ts(s: &str) -> Timestamp {
1089 s.parse().expect("rfc3339")
1090 }
1091
1092 fn block_on<F: std::future::Future>(f: F) -> F::Output {
1093 tokio::runtime::Runtime::new().expect("runtime").block_on(f)
1094 }
1095
1096 #[test]
1097 fn a_run_is_due_after_its_grace_and_not_before() {
1098 let now = ts("2026-09-05T00:00:00Z");
1099 let grace = 600;
1100 let old = now - SignedDuration::new(601, 0);
1101 let fresh = now - SignedDuration::new(599, 0);
1102 assert!(due(now, old, grace));
1103 assert!(!due(now, fresh, grace));
1104 let edge = now - SignedDuration::new(600, 0);
1106 assert!(!due(now, edge, grace));
1107 assert!(due(now, old, 0));
1109 }
1110
1111 #[test]
1112 fn rotate_from_moves_the_starting_point_as_the_cursor_advances() {
1113 let ids: Vec<String> = ["a", "b", "c", "d", "e"]
1114 .iter()
1115 .map(|s| s.to_string())
1116 .collect();
1117
1118 assert_eq!(rotate_from(&ids, 0), ids);
1120
1121 let at_2 = rotate_from(&ids, 2);
1124 assert_eq!(at_2, vec!["c", "d", "e", "a", "b"]);
1125 for id in &ids {
1126 assert!(at_2.contains(id));
1127 }
1128
1129 assert_eq!(rotate_from(&ids, 7), rotate_from(&ids, 2));
1132
1133 assert_eq!(rotate_from(&[], 0), Vec::<String>::new());
1135 }
1136
1137 #[test]
1144 fn advancing_the_cursor_by_the_window_size_gives_every_id_a_turn() {
1145 let ids: Vec<String> = (0..12).map(|n| format!("run-{n}")).collect();
1146 let cap = 5usize;
1147 let mut cursor = 0usize;
1148 let mut ever_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1149 for _ in 0..ids.len() {
1150 let window: Vec<String> = rotate_from(&ids, cursor).into_iter().take(cap).collect();
1151 ever_seen.extend(window.iter().cloned());
1152 cursor = (cursor + window.len()) % ids.len();
1153 }
1154 assert_eq!(
1155 ever_seen.len(),
1156 ids.len(),
1157 "every id must be checked at least once across a full rotation, whatever \
1158 the cadence between passes"
1159 );
1160 }
1161
1162 #[test]
1163 fn the_external_merge_cursor_round_trips_through_a_files_absence() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let home = dir.path();
1166
1167 assert_eq!(read_external_merge_cursor(home), 0);
1169
1170 write_external_merge_cursor(home, 7);
1171 assert_eq!(read_external_merge_cursor(home), 7);
1172
1173 std::fs::write(home.join(EXTERNAL_MERGE_CURSOR_FILE), "not a number").unwrap();
1176 assert_eq!(read_external_merge_cursor(home), 0);
1177 }
1178
1179 #[test]
1180 fn a_run_qualifies_for_an_external_merge_check_only_when_blocked_unmerged_and_due() {
1181 let now = ts("2026-09-05T00:00:00Z");
1182 let grace = 600;
1183 let old = now - SignedDuration::new(601, 0);
1184 let fresh = now - SignedDuration::new(599, 0);
1185
1186 assert!(
1187 eligible_for_external_merge_check(RunStatus::Blocked, false, old, now, grace),
1188 "blocked, unmerged, and past its grace period is exactly the run this exists for"
1189 );
1190 assert!(
1191 !eligible_for_external_merge_check(RunStatus::Blocked, false, fresh, now, grace),
1192 "an operator mid-fix deserves the same grace window `fold_due` gives before \
1193 the janitor starts asking GitHub about it"
1194 );
1195 assert!(
1196 !eligible_for_external_merge_check(RunStatus::Blocked, true, old, now, grace),
1197 "a run `land::land` already recorded a merge outcome for has its own answer \
1198 already; this check is only for a run with nothing recorded at all"
1199 );
1200 assert!(
1201 !eligible_for_external_merge_check(RunStatus::Stalled, false, old, now, grace),
1202 "stalled is not blocked - it means the tally never reached quorum, which a \
1203 pull request cannot fix"
1204 );
1205 assert!(
1206 !eligible_for_external_merge_check(RunStatus::Ready, false, old, now, grace),
1207 "ready has nothing to correct - it was never landed by design"
1208 );
1209 assert!(
1210 !eligible_for_external_merge_check(RunStatus::Superseded, false, old, now, grace),
1211 "a superseded run's task already has its answer from a later attempt; \
1212 there is nothing left for GitHub to confirm here"
1213 );
1214 }
1215
1216 fn write_blocked_run(runs: &Path, id: &str, updated_at: Timestamp) {
1222 use crate::run::{Candidate, Tally};
1223 use std::collections::BTreeMap;
1224
1225 let mut state = RunState::new(
1226 PathBuf::from("/nonexistent/repo"),
1227 "main".to_owned(),
1228 "0000000000000000000000000000000000000000".to_owned(),
1229 String::new(),
1230 crate::config::Config::default(),
1231 );
1232 state.id = id.to_owned();
1233 state.status = RunStatus::Blocked;
1234 state.updated_at = updated_at;
1235 state.candidates.push(Candidate {
1236 index: 0,
1237 label: 'A',
1238 agent: "agent".to_owned(),
1239 branch: format!("magi/{id}/A"),
1240 worktree: PathBuf::from("/nonexistent/repo"),
1241 summary: String::new(),
1242 stat: String::new(),
1243 files: 0,
1244 commits: 0,
1245 empty: false,
1246 failed: None,
1247 verified_noop: None,
1248 duration_ms: 0,
1249 folded: false,
1250 });
1251 state.tally = Some(Tally {
1252 first_choice: BTreeMap::from([('A', 1)]),
1253 borda: BTreeMap::new(),
1254 winner: 'A',
1255 rankings: 1,
1256 unanimous_initial: true,
1257 deliberated: false,
1258 changed_votes: 0,
1259 unanimous_final: true,
1260 tie_break: None,
1261 judges: 1,
1262 present: 1,
1263 quorum: 1,
1264 met_quorum: true,
1265 uncontested: None,
1266 });
1267 std::fs::create_dir_all(runs.join(id)).unwrap();
1268 std::fs::write(
1269 runs.join(id).join("run.json"),
1270 serde_json::to_string_pretty(&state).unwrap(),
1271 )
1272 .unwrap();
1273 }
1274
1275 #[tokio::test]
1280 async fn reconcile_external_merges_leaves_ineligible_and_unconfirmable_runs_alone() {
1281 let dir = tempfile::tempdir().unwrap();
1282 let runs = dir.path().join("runs");
1283 let home = dir.path().to_path_buf();
1284 std::fs::create_dir_all(&runs).unwrap();
1285
1286 let now = ts("2026-09-05T00:00:00Z");
1287 let disk = Disk {
1288 fold_grace_secs: 600,
1289 ..Disk::default()
1290 };
1291
1292 let due_id = "20260905-000000-blkd";
1293 write_blocked_run(&runs, due_id, ts("2026-08-01T00:00:00Z"));
1294
1295 let fresh_id = "20260905-000000-fres";
1296 write_blocked_run(&runs, fresh_id, now);
1297
1298 let live_id = "20260905-000000-live";
1299 write_blocked_run(&runs, live_id, ts("2026-08-01T00:00:00Z"));
1300 let mut status = crate::daemon::Status::new();
1301 status.current = vec![crate::daemon::Current {
1302 task: "20260905-000000-task".to_owned(),
1303 run: live_id.to_owned(),
1304 }];
1305 status.updated_at = now;
1306 crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
1307
1308 let reconciled = reconcile_external_merges(&runs, &home, &disk, now).await;
1309 assert_eq!(
1310 reconciled, 0,
1311 "an unreachable repository can never be confirmed merged"
1312 );
1313 for id in [due_id, fresh_id, live_id] {
1314 assert_eq!(
1315 read_meta(&runs, id).unwrap().status,
1316 RunStatus::Blocked,
1317 "{id} must be left exactly as it was found"
1318 );
1319 }
1320 }
1321
1322 #[tokio::test]
1332 async fn reconcile_external_merges_covers_a_larger_fleet_across_repeated_passes_at_one_instant()
1333 {
1334 let dir = tempfile::tempdir().unwrap();
1335 let runs = dir.path().join("runs");
1336 let home = dir.path().to_path_buf();
1337 std::fs::create_dir_all(&runs).unwrap();
1338
1339 let now = ts("2026-09-05T00:00:00Z");
1340 let disk = Disk {
1341 fold_grace_secs: 600,
1342 ..Disk::default()
1343 };
1344 let old = ts("2026-08-01T00:00:00Z");
1345
1346 let ids: Vec<String> = (0..8).map(|n| format!("20260905-000000-r{n:03}")).collect();
1347 for id in &ids {
1348 write_blocked_run(&runs, id, old);
1349 }
1350
1351 let notices = crate::notices::Notices::at(home.join("notifications"));
1352
1353 reconcile_external_merges(&runs, &home, &disk, now).await;
1359 let after_first = notices.list().len();
1360 assert_eq!(
1361 after_first, MAX_EXTERNAL_MERGE_CHECKS_PER_PASS,
1362 "the first pass checks exactly one cap's worth"
1363 );
1364
1365 reconcile_external_merges(&runs, &home, &disk, now).await;
1366 let after_second = notices.list().len();
1367 assert_eq!(
1368 after_second,
1369 ids.len(),
1370 "a second pass at the same instant must still reach every id the \
1371 first pass had no room for, not repeat the same cap's worth"
1372 );
1373 }
1374
1375 #[test]
1376 fn the_meta_reader_is_tolerant_of_everything_except_the_deciders() {
1377 let dir = tempfile::tempdir().unwrap();
1378 let runs = dir.path().join("runs");
1379 let id = "20260905-000000-abcd";
1380 std::fs::create_dir_all(runs.join(id)).unwrap();
1381 std::fs::write(
1382 runs.join(id).join("run.json"),
1383 r#"{"schema": 99, "id": "20260905-000000-abcd", "updated_at": "2026-09-05T00:00:00Z", "status": "ready", "junk_from_another_build": [1, 2, 3]}"#,
1384 )
1385 .unwrap();
1386 let meta = read_meta(&runs, id).expect("readable");
1387 assert_eq!(meta.status, RunStatus::Ready);
1388 assert_eq!(meta.updated_at, ts("2026-09-05T00:00:00Z"));
1389 assert!(read_meta(&runs, "nope").is_err(), "missing file unreadable");
1390 std::fs::write(runs.join(id).join("run.json"), "not json at all").unwrap();
1391 assert!(read_meta(&runs, id).is_err(), "garbage unreadable");
1392 }
1393
1394 #[test]
1395 fn fold_unreadable_releases_run_dir_and_worktrees() {
1396 let dir = tempfile::tempdir().unwrap();
1397 let runs = dir.path().join("runs");
1398 let wt = dir.path().join("wt");
1399 let id = "20260905-000000-abcd";
1400 std::fs::create_dir_all(runs.join(id)).unwrap();
1401 std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
1402 std::fs::create_dir_all(wt.join("abcd")).unwrap();
1403 std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
1404
1405 let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold");
1406 assert_eq!(removed.len(), 2);
1407 assert!(!runs.join(id).exists(), "run dir gone");
1408 assert!(!wt.join("abcd").exists(), "worktrees gone");
1409
1410 std::fs::create_dir_all(runs.join(id)).unwrap();
1412 std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
1413 std::fs::create_dir_all(wt.join("abcd")).unwrap();
1414 std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
1415 let removed = block_on(fold_unreadable(&runs, &wt, "20260905")).expect("by prefix");
1416 assert_eq!(removed.len(), 2);
1417 assert!(
1421 block_on(fold_unreadable(&runs, &wt, id)).is_err(),
1422 "a run already gone cannot be resolved again"
1423 );
1424 }
1425
1426 #[test]
1427 fn prune_cache_sheds_the_oldest_generation_until_it_fits() {
1428 let home = tempfile::tempdir().unwrap();
1429 let dir = tempfile::tempdir().unwrap();
1430 fs::write(dir.path().join("old"), b"xx").unwrap();
1433 fs::write(dir.path().join("new"), b"yy").unwrap();
1434 touch(&dir.path().join("old"), 1_000_000);
1435 touch(&dir.path().join("new"), 2_000_000);
1436
1437 let out = prune_cache(home.path(), dir.path(), 2)
1438 .expect("prune")
1439 .expect("the cache is free");
1440 assert_eq!(out.files, 1, "one deletion is enough to reach the cap");
1441 assert_eq!(out.remaining, 2);
1442 assert!(!dir.path().join("old").exists(), "the older file went");
1443 assert!(dir.path().join("new").exists(), "the newer one stayed");
1444
1445 let tied = tempfile::tempdir().unwrap();
1450 fs::write(tied.path().join("big"), b"xxxx").unwrap();
1451 fs::write(tied.path().join("small"), b"yy").unwrap();
1452 touch(&tied.path().join("big"), 1_000_000);
1453 touch(&tied.path().join("small"), 1_000_000);
1454 let out = prune_cache(home.path(), tied.path(), 2)
1455 .expect("prune")
1456 .expect("the cache is free");
1457 assert_eq!(out.files, 1, "the big one alone gets under the cap");
1458 assert_eq!(out.remaining, 2);
1459 assert!(tied.path().join("small").exists());
1460 }
1461
1462 #[test]
1463 fn prune_cache_if_over_limit_resolves_the_opt_outs_before_ever_measuring() {
1464 let home = tempfile::tempdir().unwrap();
1465 let dir = tempfile::tempdir().unwrap();
1466 fs::write(dir.path().join("big"), vec![0u8; 10]).unwrap();
1467
1468 let mut cfg = crate::config::Config::default();
1469 cfg.verify.gate = vec![format!(
1470 "CARGO_TARGET_DIR={} cargo make check",
1471 dir.path().display()
1472 )];
1473
1474 cfg.disk.cache_limit_bytes = 0;
1477 assert_eq!(
1478 prune_cache_if_over_limit(&cfg, home.path()).unwrap(),
1479 None,
1480 "a zero cap must not even look at the directory"
1481 );
1482 assert!(dir.path().join("big").exists());
1483
1484 let mut no_cache = crate::config::Config::default();
1487 no_cache.disk.cache_limit_bytes = 1;
1488 assert_eq!(
1489 prune_cache_if_over_limit(&no_cache, home.path()).unwrap(),
1490 None
1491 );
1492
1493 cfg.disk.cache_limit_bytes = 1;
1496 let pruned = prune_cache_if_over_limit(&cfg, home.path())
1497 .unwrap()
1498 .expect("a real cache dir over its cap prunes");
1499 assert_eq!(pruned.files, 1);
1500 assert!(!dir.path().join("big").exists());
1501 }
1502
1503 fn touch(path: &Path, secs: u64) {
1506 let f = fs::File::options().write(true).open(path).unwrap();
1507 f.set_times(fs::FileTimes::new().set_modified(
1508 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1509 ))
1510 .unwrap();
1511 }
1512
1513 #[test]
1516 fn fold_unreadable_clears_a_run_whose_state_never_landed() {
1517 let dir = tempfile::tempdir().unwrap();
1518 let runs = dir.path().join("runs");
1519 let wt = dir.path().join("wt");
1520 let id = "20260904-014540-88c0";
1521 std::fs::create_dir_all(runs.join(id)).unwrap();
1522 std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
1523
1524 let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold by id");
1525 assert_eq!(removed, vec![format!("runs/{id}")]);
1526 assert!(!runs.join(id).exists(), "record gone");
1527
1528 std::fs::create_dir_all(runs.join(id)).unwrap();
1530 std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
1531 assert!(
1532 block_on(fold_unreadable(&runs, &wt, "88c0")).is_ok(),
1533 "by prefix"
1534 );
1535
1536 std::fs::create_dir_all(runs.join("scratch")).unwrap();
1538 assert!(
1539 block_on(fold_unreadable(&runs, &wt, "scratch")).is_err(),
1540 "a stray directory is not a run"
1541 );
1542 }
1543
1544 #[test]
1545 fn fold_due_folds_terminal_runs_of_any_schema_but_leaves_genuinely_unreadable_ones() {
1546 let dir = tempfile::tempdir().unwrap();
1547 let runs = dir.path().join("runs");
1548 let wt = dir.path().join("wt");
1549 let home = dir.path().to_path_buf();
1550 let disk = Disk::default();
1551 let now = ts("2026-09-05T00:00:00Z");
1552
1553 let judging = "20260801-000000-0001";
1555 write_meta(&runs, judging, "judging", "2026-08-01T00:00:00Z");
1556
1557 let ready_fresh = "20260904-220000-0002";
1564 write_meta(&runs, ready_fresh, "ready", "2026-09-04T22:00:00Z");
1565
1566 let garbage = "20260901-000000-0004";
1572 std::fs::create_dir_all(runs.join(garbage)).unwrap();
1573 std::fs::write(runs.join(garbage).join("run.json"), "not json").unwrap();
1574 std::fs::create_dir_all(wt.join("0004")).unwrap();
1575
1576 let due_ready = due_run(&runs, &wt, "20260801-000000-ffff", SCHEMA);
1579
1580 let due_old_schema = due_run(&runs, &wt, "20260801-000000-eeee", SCHEMA - 1);
1585
1586 let (folded, unreadable) =
1587 block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
1588 assert_eq!(
1589 folded, 2,
1590 "both due, parseable runs fold regardless of their schema number"
1591 );
1592 assert_eq!(
1593 unreadable, 1,
1594 "only the run with broken JSON counts as unreadable"
1595 );
1596 assert!(runs.join(judging).exists(), "runnable never folded");
1597 assert!(runs.join(ready_fresh).exists(), "fresh never folded");
1598 assert!(runs.join(garbage).exists(), "unreadable record kept");
1599 assert!(wt.join("0004").exists(), "unreadable worktree kept");
1600 assert!(
1601 runs.join(&due_ready).exists(),
1602 "folding drops worktrees, not the record"
1603 );
1604 assert!(
1605 runs.join(&due_old_schema).exists(),
1606 "an old-schema record survives its fold exactly like a current one"
1607 );
1608 for id in [&due_ready, &due_old_schema] {
1614 let saved = read_meta(&runs, id).expect("folded run still parses");
1615 assert_ne!(
1616 saved.updated_at,
1617 ts("2026-08-01T00:00:00Z"),
1618 "fold_run must have saved the updated state back through the \
1619 `runs` directory this test passed to fold_due"
1620 );
1621 }
1622 }
1623
1624 #[tokio::test]
1630 async fn housekeep_leaves_everything_alone_when_auto_fold_is_disabled() {
1631 let dir = tempfile::tempdir().unwrap();
1632 let runs = dir.path().join("runs");
1633 let wt = dir.path().join("wt");
1634 let home = dir.path().to_path_buf();
1635 crate::run::set_home(dir.path().to_path_buf());
1636
1637 let due_id = due_run(&runs, &wt, "20260801-000000-abcd", SCHEMA);
1638 std::fs::create_dir_all(wt.join("orphan").join("cand-A")).unwrap();
1639
1640 let mut cfg = crate::config::Config::default();
1641 cfg.disk.auto_fold = false;
1642 cfg.disk.cache_limit_bytes = 0;
1643
1644 let out = housekeep(&cfg, &home, &wt, &dir.path().join("repo"), Timestamp::now()).await;
1645
1646 assert_eq!(out.folded, 0);
1647 assert_eq!(out.unreadable, 0);
1648 assert_eq!(out.orphaned_worktrees, 0);
1649 assert!(
1650 runs.join(&due_id).exists(),
1651 "a due run's record survives untouched"
1652 );
1653 assert!(
1654 wt.join("orphan").exists(),
1655 "an orphaned worktree survives untouched: the reclaim pass never ran"
1656 );
1657 }
1658
1659 #[test]
1660 fn fold_orphaned_worktrees_removes_only_worktrees_no_run_claims_and_none_in_flight() {
1661 let dir = tempfile::tempdir().unwrap();
1662 let runs = dir.path().join("runs");
1663 let wt = dir.path().join("wt");
1664 let home = dir.path().to_path_buf();
1665
1666 write_meta(
1669 &runs,
1670 "20260801-000000-aaaa",
1671 "ready",
1672 "2026-08-01T00:00:00Z",
1673 );
1674 std::fs::create_dir_all(wt.join("aaaa").join("cand-A")).unwrap();
1675
1676 std::fs::create_dir_all(wt.join("bbbb").join("cand-A")).unwrap();
1680
1681 std::fs::create_dir_all(wt.join("cccc")).unwrap();
1686
1687 std::fs::create_dir_all(wt.join("scratch")).unwrap();
1691
1692 let now = Timestamp::now() + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
1698 let mut status = crate::daemon::Status::new();
1699 status.current = vec![crate::daemon::Current {
1700 task: "20260905-000000-t111".to_owned(),
1701 run: "20260905-000000-cccc".to_owned(),
1702 }];
1703 status.updated_at = now;
1704 crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
1705
1706 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
1707 assert_eq!(
1708 folded, 1,
1709 "only the truly orphaned, idle, bay-shaped worktree is removed"
1710 );
1711 assert!(wt.join("aaaa").exists(), "claimed by a run record");
1712 assert!(!wt.join("bbbb").exists(), "orphaned and idle: reclaimed");
1713 assert!(wt.join("cccc").exists(), "a run in flight is never touched");
1714 assert!(
1715 wt.join("scratch").exists(),
1716 "not shaped like a worktree bay, so never a reclaim target"
1717 );
1718 }
1719
1720 #[test]
1728 fn fold_orphaned_worktrees_leaves_a_freshly_created_bay_alone() {
1729 let dir = tempfile::tempdir().unwrap();
1730 let runs = dir.path().join("runs");
1731 let wt = dir.path().join("wt");
1732 let home = dir.path().to_path_buf();
1733
1734 std::fs::create_dir_all(wt.join("dddd").join("under-review")).unwrap();
1735
1736 let now = Timestamp::now();
1737 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 6 * 60 * 60, now));
1738 assert_eq!(
1739 folded, 0,
1740 "too fresh to tell apart from a run still being set up"
1741 );
1742 assert!(wt.join("dddd").exists());
1743 }
1744
1745 fn open_question(store: &Questions, run: &str) -> crate::ask::Question {
1747 let mut q = crate::ask::Question::new(
1748 run.to_owned(),
1749 "implement".to_owned(),
1750 "impl-A".to_owned(),
1751 "Which storage backend should the cache use?".to_owned(),
1752 String::new(),
1753 vec!["SQLite".to_owned(), "Redis".to_owned()],
1754 );
1755 store.put(&mut q).unwrap();
1756 q
1757 }
1758
1759 #[test]
1765 fn a_finished_runs_open_question_is_swept_up() {
1766 let dir = tempfile::tempdir().unwrap();
1767 let runs = dir.path().join("runs");
1768 let store = Questions::at(dir.path().join("questions"));
1769
1770 let failed = "20260908-205802-c9eb";
1771 write_meta(&runs, failed, "failed", "2026-09-08T20:58:02Z");
1772 let failed_q = open_question(&store, failed);
1773
1774 let merged = "20260908-205501-ca67";
1775 write_meta(&runs, merged, "merged", "2026-09-08T20:55:01Z");
1776 let merged_q = open_question(&store, merged);
1777
1778 let n = abandon_settled_questions(&store, &runs);
1779 assert_eq!(n, 2, "both dead runs' questions are swept in one pass");
1780
1781 for (id, run) in [(&failed_q.id, failed), (&merged_q.id, merged)] {
1782 let back = store.get(id).unwrap();
1783 assert!(!back.status.open(), "{run} is done; nobody reads an answer");
1784 assert!(back.detail.contains(run), "{}", back.detail);
1785 }
1786 }
1787
1788 #[test]
1789 fn a_still_alive_runs_open_question_survives_the_sweep() {
1790 let dir = tempfile::tempdir().unwrap();
1791 let runs = dir.path().join("runs");
1792 let store = Questions::at(dir.path().join("questions"));
1793
1794 for (id, status) in [
1798 ("20260908-000000-b10c", "blocked"),
1799 ("20260908-000000-5ta1", "stalled"),
1800 ("20260908-000000-jud6", "judging"),
1801 ] {
1802 write_meta(&runs, id, status, "2026-09-08T00:00:00Z");
1803 let q = open_question(&store, id);
1804
1805 let n = abandon_settled_questions(&store, &runs);
1806 assert_eq!(n, 0, "{status} run is not done; nothing to sweep");
1807 assert!(
1808 store.get(&q.id).unwrap().status.open(),
1809 "{status} run's question must still be waiting"
1810 );
1811 }
1812 }
1813
1814 #[test]
1815 fn the_sweep_leaves_an_answered_question_and_an_unreadable_run_alone() {
1816 let dir = tempfile::tempdir().unwrap();
1817 let runs = dir.path().join("runs");
1818 let store = Questions::at(dir.path().join("questions"));
1819
1820 let done = "20260908-000000-answ";
1823 write_meta(&runs, done, "failed", "2026-09-08T00:00:00Z");
1824 let mut answered = open_question(&store, done);
1825 answered
1826 .answer(crate::ask::Answer::Choice("SQLite".to_owned()))
1827 .unwrap();
1828 store.put(&mut answered).unwrap();
1829
1830 let gone = "20260908-000000-gone";
1832 let orphan = open_question(&store, gone);
1833
1834 assert_eq!(abandon_settled_questions(&store, &runs), 0);
1835 assert_eq!(
1836 store.get(&answered.id).unwrap().status,
1837 crate::ask::QuestionStatus::Answered,
1838 "a real answer is never overwritten by a sweep"
1839 );
1840 assert!(
1841 store.get(&orphan.id).unwrap().status.open(),
1842 "a run this sweep cannot read is left exactly as it was, not guessed at"
1843 );
1844 }
1845
1846 #[test]
1855 fn fold_orphaned_worktrees_floors_a_zero_grace_at_the_race_safe_minimum() {
1856 let dir = tempfile::tempdir().unwrap();
1857 let runs = dir.path().join("runs");
1858 let wt = dir.path().join("wt");
1859 let home = dir.path().to_path_buf();
1860
1861 std::fs::create_dir_all(wt.join("eeee").join("under-review")).unwrap();
1862
1863 let now = Timestamp::now();
1865 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
1866 assert_eq!(
1867 folded, 0,
1868 "a zero grace must not defeat the race-safety floor"
1869 );
1870 assert!(wt.join("eeee").exists());
1871
1872 let later = now + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
1875 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, later));
1876 assert_eq!(folded, 1, "old enough now, regardless of the zero grace");
1877 assert!(!wt.join("eeee").exists());
1878 }
1879
1880 #[test]
1881 fn clear_abandoned_active_only_acts_once_dead_and_overrun() {
1882 let dir = tempfile::tempdir().unwrap();
1883 crate::run::set_home(dir.path().to_path_buf());
1888 let home = dir.path().to_path_buf();
1889 let now = ts("2026-09-14T12:00:00Z");
1890 let overrun_seat = || crate::run::ActiveSeat {
1891 node: "implement".to_owned(),
1892 started_at: now - SignedDuration::new(21_000, 0),
1893 timeout_secs: 3_600,
1894 attempt: 0,
1895 task: None,
1896 command: None,
1897 index: None,
1898 total: None,
1899 };
1900
1901 let mut state = RunState::new(
1902 PathBuf::from("/repo"),
1903 "main".to_owned(),
1904 "abc1234".to_owned(),
1905 "fixture".to_owned(),
1906 crate::config::Config::default(),
1907 );
1908 state.status = RunStatus::Implementing;
1909 state.active.insert("impl-A".to_owned(), overrun_seat());
1910
1911 let mut fresh = state.clone();
1914 fresh.active.insert(
1915 "impl-B".to_owned(),
1916 crate::run::ActiveSeat {
1917 node: "implement".to_owned(),
1918 started_at: now,
1919 timeout_secs: 3_600,
1920 attempt: 0,
1921 task: None,
1922 command: None,
1923 index: None,
1924 total: None,
1925 },
1926 );
1927 assert!(!clear_abandoned_active(&mut fresh, &home, now).unwrap());
1928 assert!(!fresh.active.is_empty());
1929 assert_eq!(fresh.status, RunStatus::Implementing);
1930
1931 let store = Questions::at(home.join("questions"));
1932 let q = open_question(&store, &state.id);
1933
1934 assert!(clear_abandoned_active(&mut state, &home, now).unwrap());
1935 assert!(state.active.is_empty());
1936 assert_eq!(state.status, RunStatus::Failed);
1937 assert!(
1938 !store.get(&q.id).unwrap().status.open(),
1939 "the abandoned seat's own open question must not keep badging the \
1940 operator until some later daemon startup notices it"
1941 );
1942 }
1943
1944 fn write_meta(runs: &Path, id: &str, status: &str, updated_at: &str) {
1946 let day = &updated_at[..10];
1947 std::fs::create_dir_all(runs.join(id)).unwrap();
1948 let body = format!(
1949 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}}"#
1950 );
1951 std::fs::write(runs.join(id).join("run.json"), body).unwrap();
1952 }
1953
1954 fn due_run(runs: &Path, wt: &Path, id: &str, schema: u32) -> String {
1964 due_run_with_status(runs, wt, id, schema, RunStatus::Ready)
1965 }
1966
1967 fn due_run_with_status(
1970 runs: &Path,
1971 wt: &Path,
1972 id: &str,
1973 schema: u32,
1974 status: RunStatus,
1975 ) -> String {
1976 let mut config = crate::config::Config::default();
1977 config.graph.worktree_root = Some(wt.to_path_buf());
1978 let mut state = RunState::new(
1979 PathBuf::from("/nonexistent/repo"),
1980 "main".to_owned(),
1981 "0000000000000000000000000000000000000000".to_owned(),
1982 String::new(),
1983 config,
1984 );
1985 state.id = id.to_owned();
1986 state.status = status;
1987 state.updated_at = ts("2026-08-01T00:00:00Z");
1988 let mut value = serde_json::to_value(&state).unwrap();
1989 value["schema"] = serde_json::json!(schema);
1990 std::fs::create_dir_all(runs.join(id)).unwrap();
1991 std::fs::write(
1992 runs.join(id).join("run.json"),
1993 serde_json::to_string_pretty(&value).unwrap(),
1994 )
1995 .unwrap();
1996 id.to_owned()
1997 }
1998
1999 #[test]
2000 fn fold_due_folds_a_superseded_run_same_as_any_other_terminal_one() {
2001 let dir = tempfile::tempdir().unwrap();
2002 let runs = dir.path().join("runs");
2003 let wt = dir.path().join("wt");
2004 let home = dir.path().to_path_buf();
2005 let disk = Disk::default();
2006 let now = ts("2026-09-05T00:00:00Z");
2007
2008 let superseded = due_run_with_status(
2009 &runs,
2010 &wt,
2011 "20260801-000000-cccc",
2012 SCHEMA,
2013 RunStatus::Superseded,
2014 );
2015
2016 let (folded, unreadable) =
2017 block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
2018 assert_eq!(
2019 folded, 1,
2020 "a superseded run has nothing left for a human to check, so it folds \
2021 exactly like a merged one"
2022 );
2023 assert_eq!(unreadable, 0);
2024 assert!(
2025 read_meta(&runs, &superseded).is_ok(),
2026 "folding drops the worktree, not the record"
2027 );
2028 }
2029
2030 fn init_repo(dir: &Path) {
2033 use crate::proc::Quiet as _;
2034 let run = |args: &[&str]| {
2035 let out = std::process::Command::new("git")
2036 .args(args)
2037 .current_dir(dir)
2038 .quiet()
2039 .output()
2040 .expect("spawn git");
2041 assert!(
2042 out.status.success(),
2043 "git {args:?} failed: {}",
2044 String::from_utf8_lossy(&out.stderr)
2045 );
2046 };
2047 run(&["init", "-b", "main"]);
2048 run(&["config", "user.name", "magi test"]);
2049 run(&["config", "user.email", "magi@example.com"]);
2050 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
2051 run(&["add", "-A"]);
2052 run(&["commit", "-m", "init"]);
2053 }
2054
2055 async fn due_run_with_winner_worktree(
2059 runs: &Path,
2060 wt: &Path,
2061 repo: &Path,
2062 id: &str,
2063 status: RunStatus,
2064 ) -> (String, PathBuf) {
2065 use crate::run::{Candidate, Tally};
2066 use std::collections::BTreeMap;
2067
2068 let mut config = crate::config::Config::default();
2069 config.graph.worktree_root = Some(wt.to_path_buf());
2070 let mut state = RunState::new(
2071 repo.to_path_buf(),
2072 "main".to_owned(),
2073 "0000000000000000000000000000000000000000".to_owned(),
2074 String::new(),
2075 config,
2076 );
2077 state.id = id.to_owned();
2078 state.status = status;
2079 state.updated_at = ts("2026-08-01T00:00:00Z");
2080
2081 let winner_wt = state.worktree_root().join("cand-A");
2082 crate::git::worktree_add_branch(repo, &winner_wt, &format!("magi/{id}/A"), "main")
2083 .await
2084 .expect("winner worktree");
2085
2086 state.candidates.push(Candidate {
2087 index: 0,
2088 label: 'A',
2089 agent: "agent".to_owned(),
2090 branch: format!("magi/{id}/A"),
2091 worktree: winner_wt.clone(),
2092 summary: String::new(),
2093 stat: String::new(),
2094 files: 0,
2095 commits: 0,
2096 empty: false,
2097 failed: None,
2098 verified_noop: None,
2099 duration_ms: 0,
2100 folded: false,
2101 });
2102 state.tally = Some(Tally {
2103 first_choice: BTreeMap::from([('A', 1)]),
2104 borda: BTreeMap::new(),
2105 winner: 'A',
2106 rankings: 1,
2107 unanimous_initial: true,
2108 deliberated: false,
2109 changed_votes: 0,
2110 unanimous_final: true,
2111 tie_break: None,
2112 judges: 1,
2113 present: 1,
2114 quorum: 1,
2115 met_quorum: true,
2116 uncontested: None,
2117 });
2118 std::fs::create_dir_all(runs.join(id)).unwrap();
2119 std::fs::write(
2120 runs.join(id).join("run.json"),
2121 serde_json::to_string_pretty(&state).unwrap(),
2122 )
2123 .unwrap();
2124 (id.to_owned(), winner_wt)
2125 }
2126
2127 #[tokio::test]
2128 async fn fold_due_drops_a_superseded_runs_own_winner_worktree_but_keeps_a_readys() {
2129 let dir = tempfile::tempdir().unwrap();
2130 let runs = dir.path().join("runs");
2131 let wt = dir.path().join("wt");
2132 let repo = dir.path().join("repo");
2133 let home = dir.path().to_path_buf();
2134 let disk = Disk::default();
2135 let now = ts("2026-09-05T00:00:00Z");
2136 std::fs::create_dir_all(&repo).unwrap();
2137 init_repo(&repo);
2138
2139 let (superseded_id, superseded_wt) = due_run_with_winner_worktree(
2140 &runs,
2141 &wt,
2142 &repo,
2143 "20260801-000000-supw",
2144 RunStatus::Superseded,
2145 )
2146 .await;
2147 let (ready_id, ready_wt) = due_run_with_winner_worktree(
2154 &runs,
2155 &wt,
2156 &repo,
2157 "20260801-000000-rdyw",
2158 RunStatus::Ready,
2159 )
2160 .await;
2161
2162 let (folded, unreadable) = fold_due(&runs, &home, &wt, &disk, now)
2163 .await
2164 .expect("fold_due");
2165 assert_eq!(folded, 2);
2166 assert_eq!(unreadable, 0);
2167
2168 assert!(
2169 !superseded_wt.exists(),
2170 "a superseded run's own winner never lands anywhere else, so its worktree \
2171 must be dropped exactly like a merged run's"
2172 );
2173 assert!(
2174 ready_wt.exists(),
2175 "a still-ready run's winner may yet be merged by hand - folding must not \
2176 touch it"
2177 );
2178
2179 assert!(read_meta(&runs, &superseded_id).is_ok());
2181 assert!(read_meta(&runs, &ready_id).is_ok());
2182 }
2183}