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 crate::retention::sweep(home, &cfg.disk, now).await;
89 if cfg.disk.auto_fold {
90 let runs = home.join("runs");
91 match fold_due(&runs, home, worktrees_root, &cfg.disk, now).await {
92 Ok((folded, unreadable)) => {
93 out.folded = folded;
94 out.unreadable = unreadable;
95 }
96 Err(e) => {
97 tracing::warn!("housekeep: fold due runs: {e:#}");
98 crate::notices::raise_in(
101 home,
102 crate::notices::Notice::warn(
103 "housekeep:fold",
104 "Automatic cleanup of finished runs failed; disk usage may keep growing.",
105 ),
106 );
107 }
108 }
109 out.orphaned_worktrees =
110 fold_orphaned_worktrees(&runs, worktrees_root, home, cfg.disk.fold_grace_secs, now)
111 .await;
112 if let Err(e) = crate::git::worktree_prune(repo).await {
116 tracing::warn!("housekeep: prune worktree registrations: {e:#}");
117 }
118 out.external_merges_recorded = reconcile_external_merges(&runs, home, &cfg.disk, now).await;
119 out.stale_pr_states_repaired = crate::land::repair_stale_pr_states(home, 5).await.0;
122 }
123 match prune_cache_if_over_limit(cfg, home) {
124 Ok(Some(pruned)) => {
125 out.cache_files = pruned.files;
126 out.cache_freed = pruned.freed;
127 }
128 Ok(None) => {}
129 Err(e) => tracing::warn!("housekeep: prune cache: {e:#}"),
130 }
131 out.questions_abandoned =
139 abandon_settled_questions(&Questions::at(home.join("questions")), &home.join("runs"));
140 out
141}
142
143pub fn abandon_settled_questions(store: &Questions, runs: &Path) -> usize {
154 let waiting_on: BTreeSet<String> = store
155 .list()
156 .into_iter()
157 .filter(|q| q.status.open())
158 .map(|q| q.run)
159 .collect();
160 let mut abandoned = 0;
161 for run in waiting_on {
162 let Ok(meta) = read_meta(runs, &run) else {
163 continue;
164 };
165 match store.settle_run(&run, meta.status) {
166 Ok(n) => abandoned += n,
167 Err(e) => tracing::warn!("housekeep: abandon questions for {run}: {e:#}"),
168 }
169 }
170 abandoned
171}
172
173pub async fn fold_due(
198 runs: &Path,
199 home: &Path,
200 _worktrees_root: &Path,
201 disk: &Disk,
202 now: Timestamp,
203) -> Result<(usize, usize)> {
204 let mut folded = 0usize;
205 let mut unreadable = 0usize;
206 let mut ids: Vec<String> = std::fs::read_dir(runs)
207 .into_iter()
208 .flatten()
209 .flatten()
210 .filter(|e| e.path().join("run.json").is_file())
211 .map(|e| e.file_name().to_string_lossy().into_owned())
212 .collect();
213 ids.sort_unstable();
214 for id in ids {
215 if crate::daemon::is_working_on(home, &id, now) {
216 continue;
217 }
218 let meta = match read_meta(runs, &id) {
219 Ok(meta) => meta,
220 Err(e) => {
221 unreadable += 1;
222 tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
223 continue;
224 }
225 };
226 if meta.status.resumable() || !due(now, meta.updated_at, disk.fold_grace_secs) {
227 continue;
228 }
229 let mut state = match read_state(runs, &id) {
238 Ok(state) => state,
239 Err(e) => {
240 unreadable += 1;
241 tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
242 continue;
243 }
244 };
245 if let Some(reason) = crate::pr_recovery::source_retention_reason(home, &state) {
246 tracing::info!(%id, %reason, "housekeep: source retained; fold refused");
247 continue;
248 }
249 if state.schema != SCHEMA {
250 tracing::info!(
251 "housekeep: run {id} was written by schema {} (this build speaks {SCHEMA}); \
252 folding it anyway",
253 state.schema
254 );
255 }
256 let drop_winner = matches!(
266 state.status,
267 RunStatus::Merged | RunStatus::Superseded | RunStatus::AlreadyInBase
268 );
269 match crate::graph::fold_run(&mut state, drop_winner, home).await {
276 Ok(_) => folded += 1,
277 Err(e) => tracing::warn!("housekeep: fold {id}: {e:#}"),
278 }
279 }
280 Ok((folded, unreadable))
281}
282
283#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
285pub struct FetchReport {
286 pub fetched: usize,
288 pub no_origin: usize,
290 pub failed: usize,
292}
293
294pub async fn fetch_origins(
302 roots: &[PathBuf],
303 per_repo: std::time::Duration,
304 stop: impl Fn() -> bool,
305) -> FetchReport {
306 let mut report = FetchReport::default();
307 for repo in crate::repos::scan(roots) {
308 if stop() {
309 break;
310 }
311 match crate::git::git_raw(&repo.path, &["remote", "get-url", "origin"]).await {
312 Ok(out) if out.ok() => {}
313 _ => {
314 report.no_origin += 1;
315 continue;
316 }
317 }
318 match crate::git::fetch_origin(&repo.path, per_repo).await {
319 Ok(()) => report.fetched += 1,
320 Err(e) => {
321 tracing::warn!("fetch origin in {}: {e:#}", repo.name);
322 report.failed += 1;
323 }
324 }
325 }
326 report
327}
328
329pub fn due(now: Timestamp, updated: Timestamp, grace_secs: u64) -> bool {
335 now.duration_since(updated) > SignedDuration::new(grace_secs as i64, 0)
336}
337
338#[derive(Deserialize)]
341struct Meta {
342 status: RunStatus,
343 updated_at: Timestamp,
344}
345
346fn read_meta(runs: &Path, id: &str) -> Result<Meta> {
350 let path = runs.join(id).join("run.json");
351 let body =
352 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
353 let meta: Meta =
354 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
355 Ok(meta)
356}
357
358const MAX_EXTERNAL_MERGE_CHECKS_PER_PASS: usize = 5;
364
365#[derive(Deserialize)]
368struct ExternalMergeMeta {
369 status: RunStatus,
370 updated_at: Timestamp,
371 #[serde(default)]
372 merge: Option<crate::run::MergeOutcome>,
373}
374
375fn read_external_merge_meta(runs: &Path, id: &str) -> Result<ExternalMergeMeta> {
376 let path = runs.join(id).join("run.json");
377 let body =
378 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
379 let meta: ExternalMergeMeta =
380 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
381 Ok(meta)
382}
383
384pub fn eligible_for_external_merge_check(
393 status: RunStatus,
394 merge_recorded: bool,
395 updated_at: Timestamp,
396 now: Timestamp,
397 grace_secs: u64,
398) -> bool {
399 status == RunStatus::Blocked && !merge_recorded && due(now, updated_at, grace_secs)
400}
401
402fn rotate_from(ids: &[String], start: usize) -> Vec<String> {
420 if ids.is_empty() {
421 return Vec::new();
422 }
423 let start = start % ids.len();
424 ids[start..]
425 .iter()
426 .chain(ids[..start].iter())
427 .cloned()
428 .collect()
429}
430
431const EXTERNAL_MERGE_CURSOR_FILE: &str = "external-merge-cursor";
435
436fn read_external_merge_cursor(home: &Path) -> usize {
441 std::fs::read_to_string(home.join(EXTERNAL_MERGE_CURSOR_FILE))
442 .ok()
443 .and_then(|s| s.trim().parse().ok())
444 .unwrap_or(0)
445}
446
447fn write_external_merge_cursor(home: &Path, cursor: usize) {
452 if let Err(e) = std::fs::write(home.join(EXTERNAL_MERGE_CURSOR_FILE), cursor.to_string()) {
453 tracing::warn!("housekeep: persist the external-merge sweep's cursor: {e:#}");
454 }
455}
456
457async fn reconcile_external_merges(runs: &Path, home: &Path, disk: &Disk, now: Timestamp) -> usize {
478 let mut ids: Vec<String> = std::fs::read_dir(runs)
479 .into_iter()
480 .flatten()
481 .flatten()
482 .filter(|e| e.path().join("run.json").is_file())
483 .map(|e| e.file_name().to_string_lossy().into_owned())
484 .collect();
485 ids.sort_unstable();
486
487 let mut eligible: Vec<String> = Vec::new();
493 for id in ids {
494 if crate::daemon::is_working_on(home, &id, now) {
495 continue;
496 }
497 let meta = match read_external_merge_meta(runs, &id) {
498 Ok(meta) => meta,
499 Err(_) => continue,
502 };
503 if eligible_for_external_merge_check(
504 meta.status,
505 meta.merge.is_some(),
506 meta.updated_at,
507 now,
508 disk.fold_grace_secs,
509 ) {
510 eligible.push(id);
511 }
512 }
513
514 if eligible.is_empty() {
515 return 0;
516 }
517 let cursor = read_external_merge_cursor(home);
518 let window: Vec<String> = rotate_from(&eligible, cursor)
519 .into_iter()
520 .take(MAX_EXTERNAL_MERGE_CHECKS_PER_PASS)
521 .collect();
522 write_external_merge_cursor(home, (cursor + window.len()) % eligible.len());
527
528 let mut reconciled = 0usize;
529 for id in window {
530 let mut state = match read_state(runs, &id) {
531 Ok(state) => state,
532 Err(_) => continue,
533 };
534 match crate::land::find_external_merge(&state).await {
535 Ok(Some(found)) => {
536 match crate::land::correct_confirmed_external_merge(&mut state, &found.url).await {
537 Ok(_) => {
538 if let Err(e) = crate::graph::fold_run(&mut state, true, home).await {
539 tracing::warn!(
540 "housekeep: fold {id} after recording its external merge: {e:#}"
541 );
542 }
543 reconciled += 1;
544 }
545 Err(e) => tracing::warn!(
546 "housekeep: record external merge {} for {id}: {e:#}",
547 found.url
548 ),
549 }
550 }
551 Ok(None) => {}
552 Err(e) => {
553 tracing::warn!("housekeep: check external merge for {id}: {e:#}");
554 crate::notices::raise_in(
555 home,
556 crate::notices::Notice::warn(
557 &format!("merged-unrecorded:{id}"),
558 format!(
559 "Run {id} is blocked with no recorded merge, and checking GitHub \
560 for a merge failed; check by hand."
561 ),
562 )
563 .link(crate::notices::Link::Run { id: id.clone() }),
564 );
565 }
566 }
567 }
568 reconciled
569}
570
571fn read_state(runs: &Path, id: &str) -> Result<RunState> {
586 let path = runs.join(id).join("run.json");
587 let body =
588 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
589 let state: RunState =
590 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
591 Ok(state)
592}
593
594async fn remove_clean_bay(root: &Path) -> Result<()> {
597 let mut pending = vec![root.to_path_buf()];
598 let mut directories = Vec::new();
599 let mut worktrees = Vec::new();
600 while let Some(path) = pending.pop() {
601 if path.join(".git").exists() {
602 if !crate::git::is_clean(&path).await? {
603 anyhow::bail!("uncommitted work in {}; recovery required", path.display());
604 }
605 worktrees.push(path);
606 } else {
607 for entry in std::fs::read_dir(&path)? {
608 let entry = entry?;
609 if !entry.file_type()?.is_dir() {
610 anyhow::bail!(
611 "unknown worktree content in {}; recovery required",
612 path.display()
613 );
614 }
615 pending.push(entry.path());
616 }
617 directories.push(path);
618 }
619 }
620 for path in worktrees {
621 if !crate::git::worktree_remove_clean(&path, &path).await? {
622 anyhow::bail!("clean worktree removal refused: {}", path.display());
623 }
624 }
625 for path in directories.into_iter().rev() {
626 std::fs::remove_dir(path)?;
627 }
628 Ok(())
629}
630
631pub async fn fold_unreadable(runs: &Path, worktrees_root: &Path, id: &str) -> Result<Vec<String>> {
639 let resolved = resolve_id_path(runs, id)?;
640 let mut removed = Vec::new();
641 let run_dir = runs.join(&resolved);
642 let wt = worktrees_root.join(short_of(&resolved));
643 if wt.exists() {
644 remove_clean_bay(&wt).await?;
645 removed.push(wt.to_string_lossy().into_owned());
646 }
647 if run_dir.exists() {
649 std::fs::remove_dir_all(&run_dir)
650 .with_context(|| format!("remove {}", run_dir.display()))?;
651 removed.push(format!("runs/{resolved}"));
652 }
653 Ok(removed)
654}
655
656pub async fn fold_orphaned_worktrees(
695 runs: &Path,
696 worktrees_root: &Path,
697 home: &Path,
698 grace_secs: u64,
699 now: Timestamp,
700) -> usize {
701 let known: std::collections::HashSet<String> = std::fs::read_dir(runs)
702 .into_iter()
703 .flatten()
704 .flatten()
705 .map(|e| e.file_name().to_string_lossy().into_owned())
706 .filter(|name| crate::run::is_run_id(name))
707 .map(|id| short_of(&id).to_owned())
708 .collect();
709
710 let mut folded = 0usize;
711 for entry in std::fs::read_dir(worktrees_root)
712 .into_iter()
713 .flatten()
714 .flatten()
715 {
716 if !entry.path().is_dir() {
717 continue;
718 }
719 let short = entry.file_name().to_string_lossy().into_owned();
720 if !looks_like_a_worktree_bay(&short) {
721 continue;
722 }
723 if known.contains(&short) || crate::daemon::is_working_on_short(home, &short, now) {
724 continue;
725 }
726 let wt = entry.path();
727 if !stale_enough(&wt, grace_secs, now) {
728 continue;
729 }
730 match remove_clean_bay(&wt).await {
731 Ok(()) => folded += 1,
732 Err(e) => tracing::warn!(
733 "housekeep: remove orphaned worktree {}: {e:#}",
734 wt.display()
735 ),
736 }
737 }
738 folded
739}
740
741fn looks_like_a_worktree_bay(name: &str) -> bool {
750 name.len() == 4 && name.bytes().all(|b| b.is_ascii_alphanumeric())
751}
752
753fn stale_enough(dir: &Path, grace_secs: u64, now: Timestamp) -> bool {
772 let Ok(modified) = std::fs::metadata(dir).and_then(|m| m.modified()) else {
773 return false;
774 };
775 let Ok(ts) = Timestamp::try_from(modified) else {
776 return false;
777 };
778 due(now, ts, grace_secs.max(MIN_ORPHAN_AGE_SECS))
779}
780
781const MIN_ORPHAN_AGE_SECS: u64 = 5 * 60;
792
793fn resolve_id_path(runs: &Path, prefix: &str) -> Result<String> {
796 if runs.join(prefix).is_dir() && crate::run::is_run_id(prefix) {
801 return Ok(prefix.to_owned());
802 }
803 let mut hits: Vec<String> = Vec::new();
804 for e in std::fs::read_dir(runs).into_iter().flatten().flatten() {
805 if !e.path().is_dir() {
806 continue;
807 }
808 let id = e.file_name().to_string_lossy().into_owned();
809 if crate::run::is_run_id(&id) && (id.starts_with(prefix) || id.ends_with(prefix)) {
810 hits.push(id);
811 }
812 }
813 match hits.len() {
814 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
815 0 => bail!("no run matches `{prefix}`"),
816 _ => bail!(
817 "`{prefix}` matches {} runs: {}",
818 hits.len(),
819 hits.join(", ")
820 ),
821 }
822}
823
824pub fn clear_abandoned_active(state: &mut RunState, home: &Path, now: Timestamp) -> Result<bool> {
839 if crate::daemon::is_working_on(home, &state.id, now) || !state.active_all_overrun(now) {
840 return Ok(false);
841 }
842 state.abandon("fold");
843 state.save_under(home)?;
844 if let Err(e) = Questions::at(home.join("questions")).settle_run(&state.id, state.status) {
852 tracing::warn!("abandon questions for {}: {e:#}", state.id);
853 }
854 Ok(true)
855}
856
857pub fn prune_cache(home: &Path, cache: &Path, limit_bytes: u64) -> Result<Option<Prune>> {
866 crate::cache::maintenance_prune(home, cache, limit_bytes)
867}
868
869pub fn prune_cache_if_over_limit(
879 cfg: &crate::config::Config,
880 home: &Path,
881) -> Result<Option<Prune>> {
882 if cfg.disk.cache_limit_bytes == 0 {
883 return Ok(None);
884 }
885 let Some(cache) = cfg.cache_dir() else {
886 return Ok(None);
887 };
888 let limit = crate::disk::free_bytes(&cache)
889 .ok()
890 .map_or(cfg.disk.cache_limit_bytes, |free| {
891 crate::disk::pressure_limit(
892 dir_size(&cache),
893 cfg.disk.cache_limit_bytes,
894 cfg.disk.min_free_bytes,
895 free,
896 )
897 });
898 prune_cache(home, &cache, limit)
899}
900
901pub fn cache_report(cfg: &crate::config::Config) -> Option<(PathBuf, u64, u64)> {
907 let cache = cfg.cache_dir()?;
908 Some((
909 cache.clone(),
910 cache_size(&cache),
911 cfg.disk.cache_limit_bytes,
912 ))
913}
914
915pub fn cache_size(cache: &Path) -> u64 {
917 dir_size(cache)
918}
919
920#[cfg(test)]
921mod tests {
922
923 async fn sh(cwd: &Path, args: &[&str]) {
924 let out = crate::git::git_raw(cwd, args).await.expect("spawn git");
925 assert!(out.ok(), "git {args:?}: {}", out.stderr);
926 }
927
928 async fn commit(cwd: &Path, file: &str, body: &str) {
929 std::fs::write(cwd.join(file), body).unwrap();
930 sh(cwd, &["add", file]).await;
931 sh(
932 cwd,
933 &[
934 "-c",
935 "user.name=t",
936 "-c",
937 "user.email=t@example.com",
938 "commit",
939 "-q",
940 "-m",
941 body,
942 ],
943 )
944 .await;
945 }
946
947 const LONG: std::time::Duration = std::time::Duration::from_secs(30);
948
949 #[tokio::test]
950 async fn fetch_advances_origin_main_and_leaves_the_checkout_alone() {
951 let tmp = tempfile::tempdir().unwrap();
952 let t = tmp.path();
953 let bare = t.join("remote.git");
954 sh(
955 t,
956 &["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
957 )
958 .await;
959 let dir = t.join("root/h/o/r");
960 std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
961 sh(
962 t,
963 &["clone", "-q", bare.to_str().unwrap(), dir.to_str().unwrap()],
964 )
965 .await;
966 sh(&dir, &["checkout", "-q", "-b", "main"]).await;
967 commit(&dir, "a.txt", "one").await;
968 sh(&dir, &["push", "-q", "origin", "main"]).await;
969 sh(&dir, &["checkout", "-q", "--detach"]).await;
970 std::fs::write(dir.join("a.txt"), "dirty").unwrap();
971
972 let other = t.join("other");
973 sh(
974 t,
975 &[
976 "clone",
977 "-q",
978 bare.to_str().unwrap(),
979 other.to_str().unwrap(),
980 ],
981 )
982 .await;
983 commit(&other, "b.txt", "two").await;
984 sh(&other, &["push", "-q", "origin", "HEAD:main"]).await;
985
986 let rev = |d: PathBuf, r: &'static str| async move {
987 crate::git::git(&d, &["rev-parse", r]).await.unwrap()
988 };
989 let head = rev(dir.clone(), "HEAD").await;
990 let before = rev(dir.clone(), "origin/main").await;
991 let status = crate::git::git(&dir, &["status", "--porcelain"])
992 .await
993 .unwrap();
994
995 let r = fetch_origins(&[t.join("root")], LONG, || false).await;
996 assert_eq!(
997 r,
998 FetchReport {
999 fetched: 1,
1000 no_origin: 0,
1001 failed: 0
1002 }
1003 );
1004
1005 assert_ne!(rev(dir.clone(), "origin/main").await, before);
1006 assert_eq!(rev(dir.clone(), "HEAD").await, head);
1007 assert_eq!(
1008 crate::git::git(&dir, &["status", "--porcelain"])
1009 .await
1010 .unwrap(),
1011 status
1012 );
1013 assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "dirty");
1014 }
1015
1016 #[tokio::test]
1017 async fn fetch_never_writes_a_local_branch_whatever_the_configured_refspec() {
1018 let tmp = tempfile::tempdir().unwrap();
1019 let t = tmp.path();
1020 let bare = t.join("remote.git");
1021 sh(
1022 t,
1023 &["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
1024 )
1025 .await;
1026 let dir = t.join("root/h/o/r");
1027 std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
1028 sh(
1029 t,
1030 &["clone", "-q", bare.to_str().unwrap(), dir.to_str().unwrap()],
1031 )
1032 .await;
1033 sh(&dir, &["checkout", "-q", "-b", "main"]).await;
1034 commit(&dir, "a.txt", "one").await;
1035 sh(&dir, &["push", "-q", "origin", "main"]).await;
1036 sh(&dir, &["checkout", "-q", "--detach"]).await;
1037 sh(
1039 &dir,
1040 &[
1041 "config",
1042 "remote.origin.fetch",
1043 "+refs/heads/main:refs/heads/main",
1044 ],
1045 )
1046 .await;
1047 let _ = crate::git::git_raw(&dir, &["update-ref", "-d", "refs/remotes/origin/main"]).await;
1048
1049 let other = t.join("other");
1050 sh(
1051 t,
1052 &[
1053 "clone",
1054 "-q",
1055 bare.to_str().unwrap(),
1056 other.to_str().unwrap(),
1057 ],
1058 )
1059 .await;
1060 commit(&other, "b.txt", "two").await;
1061 sh(&other, &["push", "-q", "origin", "HEAD:main"]).await;
1062 let remote_tip = crate::git::git(&other, &["rev-parse", "HEAD"])
1063 .await
1064 .unwrap();
1065 let local_main = crate::git::git(&dir, &["rev-parse", "refs/heads/main"])
1066 .await
1067 .unwrap();
1068
1069 let r = fetch_origins(&[t.join("root")], LONG, || false).await;
1070 assert_eq!(r.fetched, 1);
1071 assert_eq!(
1072 crate::git::git(&dir, &["rev-parse", "refs/remotes/origin/main"])
1073 .await
1074 .unwrap(),
1075 remote_tip
1076 );
1077 assert_eq!(
1078 crate::git::git(&dir, &["rev-parse", "refs/heads/main"])
1079 .await
1080 .unwrap(),
1081 local_main
1082 );
1083 }
1084
1085 #[tokio::test]
1086 async fn fetch_skips_no_origin_and_survives_an_unreachable_origin() {
1087 let tmp = tempfile::tempdir().unwrap();
1088 let t = tmp.path();
1089 let root = t.join("root");
1090 let bare = t.join("remote.git");
1091 sh(t, &["init", "-q", "--bare", bare.to_str().unwrap()]).await;
1092
1093 let none = root.join("h/o/none");
1094 let dead = root.join("h/o/dead");
1095 let good = root.join("h/o/good");
1096 for d in [&none, &dead, &good] {
1097 std::fs::create_dir_all(d).unwrap();
1098 sh(d, &["init", "-q"]).await;
1099 }
1100 sh(
1101 &dead,
1102 &[
1103 "remote",
1104 "add",
1105 "origin",
1106 t.join("missing").to_str().unwrap(),
1107 ],
1108 )
1109 .await;
1110 sh(&good, &["remote", "add", "origin", bare.to_str().unwrap()]).await;
1111
1112 let r = fetch_origins(&[root], LONG, || false).await;
1113 assert_eq!(
1114 r,
1115 FetchReport {
1116 fetched: 1,
1117 no_origin: 1,
1118 failed: 1
1119 }
1120 );
1121 }
1122 use super::*;
1123 use crate::config::Disk;
1124 use std::fs;
1125
1126 fn ts(s: &str) -> Timestamp {
1127 s.parse().expect("rfc3339")
1128 }
1129
1130 fn block_on<F: std::future::Future>(f: F) -> F::Output {
1131 tokio::runtime::Runtime::new().expect("runtime").block_on(f)
1132 }
1133
1134 #[test]
1135 fn a_run_is_due_after_its_grace_and_not_before() {
1136 let now = ts("2026-09-05T00:00:00Z");
1137 let grace = 600;
1138 let old = now - SignedDuration::new(601, 0);
1139 let fresh = now - SignedDuration::new(599, 0);
1140 assert!(due(now, old, grace));
1141 assert!(!due(now, fresh, grace));
1142 let edge = now - SignedDuration::new(600, 0);
1144 assert!(!due(now, edge, grace));
1145 assert!(due(now, old, 0));
1147 }
1148
1149 #[test]
1150 fn rotate_from_moves_the_starting_point_as_the_cursor_advances() {
1151 let ids: Vec<String> = ["a", "b", "c", "d", "e"]
1152 .iter()
1153 .map(|s| s.to_string())
1154 .collect();
1155
1156 assert_eq!(rotate_from(&ids, 0), ids);
1158
1159 let at_2 = rotate_from(&ids, 2);
1162 assert_eq!(at_2, vec!["c", "d", "e", "a", "b"]);
1163 for id in &ids {
1164 assert!(at_2.contains(id));
1165 }
1166
1167 assert_eq!(rotate_from(&ids, 7), rotate_from(&ids, 2));
1170
1171 assert_eq!(rotate_from(&[], 0), Vec::<String>::new());
1173 }
1174
1175 #[test]
1182 fn advancing_the_cursor_by_the_window_size_gives_every_id_a_turn() {
1183 let ids: Vec<String> = (0..12).map(|n| format!("run-{n}")).collect();
1184 let cap = 5usize;
1185 let mut cursor = 0usize;
1186 let mut ever_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1187 for _ in 0..ids.len() {
1188 let window: Vec<String> = rotate_from(&ids, cursor).into_iter().take(cap).collect();
1189 ever_seen.extend(window.iter().cloned());
1190 cursor = (cursor + window.len()) % ids.len();
1191 }
1192 assert_eq!(
1193 ever_seen.len(),
1194 ids.len(),
1195 "every id must be checked at least once across a full rotation, whatever \
1196 the cadence between passes"
1197 );
1198 }
1199
1200 #[test]
1201 fn the_external_merge_cursor_round_trips_through_a_files_absence() {
1202 let dir = tempfile::tempdir().unwrap();
1203 let home = dir.path();
1204
1205 assert_eq!(read_external_merge_cursor(home), 0);
1207
1208 write_external_merge_cursor(home, 7);
1209 assert_eq!(read_external_merge_cursor(home), 7);
1210
1211 std::fs::write(home.join(EXTERNAL_MERGE_CURSOR_FILE), "not a number").unwrap();
1214 assert_eq!(read_external_merge_cursor(home), 0);
1215 }
1216
1217 #[test]
1218 fn a_run_qualifies_for_an_external_merge_check_only_when_blocked_unmerged_and_due() {
1219 let now = ts("2026-09-05T00:00:00Z");
1220 let grace = 600;
1221 let old = now - SignedDuration::new(601, 0);
1222 let fresh = now - SignedDuration::new(599, 0);
1223
1224 assert!(
1225 eligible_for_external_merge_check(RunStatus::Blocked, false, old, now, grace),
1226 "blocked, unmerged, and past its grace period is exactly the run this exists for"
1227 );
1228 assert!(
1229 !eligible_for_external_merge_check(RunStatus::Blocked, false, fresh, now, grace),
1230 "an operator mid-fix deserves the same grace window `fold_due` gives before \
1231 the janitor starts asking GitHub about it"
1232 );
1233 assert!(
1234 !eligible_for_external_merge_check(RunStatus::Blocked, true, old, now, grace),
1235 "a run `land::land` already recorded a merge outcome for has its own answer \
1236 already; this check is only for a run with nothing recorded at all"
1237 );
1238 assert!(
1239 !eligible_for_external_merge_check(RunStatus::Stalled, false, old, now, grace),
1240 "stalled is not blocked - it means the tally never reached quorum, which a \
1241 pull request cannot fix"
1242 );
1243 assert!(
1244 !eligible_for_external_merge_check(RunStatus::Ready, false, old, now, grace),
1245 "ready has nothing to correct - it was never landed by design"
1246 );
1247 assert!(
1248 !eligible_for_external_merge_check(RunStatus::Superseded, false, old, now, grace),
1249 "a superseded run's task already has its answer from a later attempt; \
1250 there is nothing left for GitHub to confirm here"
1251 );
1252 }
1253
1254 fn write_blocked_run(runs: &Path, id: &str, updated_at: Timestamp) {
1260 use crate::run::{Candidate, Tally};
1261 use std::collections::BTreeMap;
1262
1263 let mut state = RunState::new(
1264 PathBuf::from("/nonexistent/repo"),
1265 "main".to_owned(),
1266 "0000000000000000000000000000000000000000".to_owned(),
1267 String::new(),
1268 crate::config::Config::default(),
1269 );
1270 state.id = id.to_owned();
1271 state.status = RunStatus::Blocked;
1272 state.updated_at = updated_at;
1273 state.candidates.push(Candidate {
1274 index: 0,
1275 label: 'A',
1276 agent: "agent".to_owned(),
1277 branch: format!("magi/{id}/A"),
1278 worktree: PathBuf::from("/nonexistent/repo"),
1279 summary: String::new(),
1280 stat: String::new(),
1281 files: 0,
1282 commits: 0,
1283 empty: false,
1284 failed: None,
1285 verified_noop: None,
1286 duration_ms: 0,
1287 folded: false,
1288 });
1289 state.tally = Some(Tally {
1290 first_choice: BTreeMap::from([('A', 1)]),
1291 borda: BTreeMap::new(),
1292 winner: 'A',
1293 rankings: 1,
1294 unanimous_initial: true,
1295 deliberated: false,
1296 changed_votes: 0,
1297 unanimous_final: true,
1298 tie_break: None,
1299 judges: 1,
1300 present: 1,
1301 quorum: 1,
1302 met_quorum: true,
1303 uncontested: None,
1304 });
1305 std::fs::create_dir_all(runs.join(id)).unwrap();
1306 std::fs::write(
1307 runs.join(id).join("run.json"),
1308 serde_json::to_string_pretty(&state).unwrap(),
1309 )
1310 .unwrap();
1311 }
1312
1313 #[tokio::test]
1318 async fn reconcile_external_merges_leaves_ineligible_and_unconfirmable_runs_alone() {
1319 let dir = tempfile::tempdir().unwrap();
1320 let runs = dir.path().join("runs");
1321 let home = dir.path().to_path_buf();
1322 std::fs::create_dir_all(&runs).unwrap();
1323
1324 let now = ts("2026-09-05T00:00:00Z");
1325 let disk = Disk {
1326 fold_grace_secs: 600,
1327 ..Disk::default()
1328 };
1329
1330 let due_id = "20260905-000000-blkd";
1331 write_blocked_run(&runs, due_id, ts("2026-08-01T00:00:00Z"));
1332
1333 let fresh_id = "20260905-000000-fres";
1334 write_blocked_run(&runs, fresh_id, now);
1335
1336 let live_id = "20260905-000000-live";
1337 write_blocked_run(&runs, live_id, ts("2026-08-01T00:00:00Z"));
1338 let mut status = crate::daemon::Status::new();
1339 status.current = vec![crate::daemon::Current {
1340 task: "20260905-000000-task".to_owned(),
1341 run: live_id.to_owned(),
1342 }];
1343 status.updated_at = now;
1344 crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
1345
1346 let reconciled = reconcile_external_merges(&runs, &home, &disk, now).await;
1347 assert_eq!(
1348 reconciled, 0,
1349 "an unreachable repository can never be confirmed merged"
1350 );
1351 for id in [due_id, fresh_id, live_id] {
1352 assert_eq!(
1353 read_meta(&runs, id).unwrap().status,
1354 RunStatus::Blocked,
1355 "{id} must be left exactly as it was found"
1356 );
1357 }
1358 let notices = crate::notices::Notices::at(home.join("notifications")).list();
1359 let due_notice = notices
1360 .iter()
1361 .find(|n| n.key == format!("merged-unrecorded:{due_id}"))
1362 .expect("notice for due_id must be raised");
1363 assert_eq!(
1364 due_notice.link,
1365 Some(crate::notices::Link::Run {
1366 id: due_id.to_owned(),
1367 })
1368 );
1369 }
1370
1371 #[tokio::test]
1381 async fn reconcile_external_merges_covers_a_larger_fleet_across_repeated_passes_at_one_instant()
1382 {
1383 let dir = tempfile::tempdir().unwrap();
1384 let runs = dir.path().join("runs");
1385 let home = dir.path().to_path_buf();
1386 std::fs::create_dir_all(&runs).unwrap();
1387
1388 let now = ts("2026-09-05T00:00:00Z");
1389 let disk = Disk {
1390 fold_grace_secs: 600,
1391 ..Disk::default()
1392 };
1393 let old = ts("2026-08-01T00:00:00Z");
1394
1395 let ids: Vec<String> = (0..8).map(|n| format!("20260905-000000-r{n:03}")).collect();
1396 for id in &ids {
1397 write_blocked_run(&runs, id, old);
1398 }
1399
1400 let notices = crate::notices::Notices::at(home.join("notifications"));
1401
1402 reconcile_external_merges(&runs, &home, &disk, now).await;
1408 let after_first = notices.list().len();
1409 assert_eq!(
1410 after_first, MAX_EXTERNAL_MERGE_CHECKS_PER_PASS,
1411 "the first pass checks exactly one cap's worth"
1412 );
1413
1414 reconcile_external_merges(&runs, &home, &disk, now).await;
1415 let after_second = notices.list().len();
1416 assert_eq!(
1417 after_second,
1418 ids.len(),
1419 "a second pass at the same instant must still reach every id the \
1420 first pass had no room for, not repeat the same cap's worth"
1421 );
1422 }
1423
1424 #[test]
1425 fn the_meta_reader_is_tolerant_of_everything_except_the_deciders() {
1426 let dir = tempfile::tempdir().unwrap();
1427 let runs = dir.path().join("runs");
1428 let id = "20260905-000000-abcd";
1429 std::fs::create_dir_all(runs.join(id)).unwrap();
1430 std::fs::write(
1431 runs.join(id).join("run.json"),
1432 r#"{"schema": 99, "id": "20260905-000000-abcd", "updated_at": "2026-09-05T00:00:00Z", "status": "ready", "junk_from_another_build": [1, 2, 3]}"#,
1433 )
1434 .unwrap();
1435 let meta = read_meta(&runs, id).expect("readable");
1436 assert_eq!(meta.status, RunStatus::Ready);
1437 assert_eq!(meta.updated_at, ts("2026-09-05T00:00:00Z"));
1438 assert!(read_meta(&runs, "nope").is_err(), "missing file unreadable");
1439 std::fs::write(runs.join(id).join("run.json"), "not json at all").unwrap();
1440 assert!(read_meta(&runs, id).is_err(), "garbage unreadable");
1441 }
1442
1443 #[test]
1444 fn fold_unreadable_preserves_unknown_content_and_recovery_artifacts() {
1445 let dir = tempfile::tempdir().unwrap();
1446 let runs = dir.path().join("runs");
1447 let wt = dir.path().join("wt");
1448 let id = "20260905-000000-abcd";
1449 std::fs::create_dir_all(runs.join(id).join("artifacts")).unwrap();
1450 std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
1451 std::fs::create_dir_all(wt.join("abcd")).unwrap();
1452 std::fs::write(wt.join("abcd/leftover"), "owned edits").unwrap();
1453 assert!(block_on(fold_unreadable(&runs, &wt, id)).is_err());
1454 assert!(runs.join(id).join("artifacts").exists());
1455 assert_eq!(
1456 std::fs::read_to_string(wt.join("abcd/leftover")).unwrap(),
1457 "owned edits"
1458 );
1459 }
1460
1461 #[test]
1462 fn fold_unreadable_releases_run_dir_and_worktrees() {
1463 let dir = tempfile::tempdir().unwrap();
1464 let runs = dir.path().join("runs");
1465 let wt = dir.path().join("wt");
1466 let id = "20260905-000000-abcd";
1467 std::fs::create_dir_all(runs.join(id)).unwrap();
1468 std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
1469 std::fs::create_dir_all(wt.join("abcd")).unwrap();
1470
1471 let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold");
1472 assert_eq!(removed.len(), 2);
1473 assert!(!runs.join(id).exists(), "run dir gone");
1474 assert!(!wt.join("abcd").exists(), "worktrees gone");
1475
1476 std::fs::create_dir_all(runs.join(id)).unwrap();
1478 std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
1479 std::fs::create_dir_all(wt.join("abcd")).unwrap();
1480 let removed = block_on(fold_unreadable(&runs, &wt, "20260905")).expect("by prefix");
1481 assert_eq!(removed.len(), 2);
1482 assert!(
1486 block_on(fold_unreadable(&runs, &wt, id)).is_err(),
1487 "a run already gone cannot be resolved again"
1488 );
1489 }
1490
1491 #[test]
1492 fn prune_cache_sheds_the_oldest_generation_until_it_fits() {
1493 let home = tempfile::tempdir().unwrap();
1494 let dir = tempfile::tempdir().unwrap();
1495 fs::write(dir.path().join("old"), b"xx").unwrap();
1498 fs::write(dir.path().join("new"), b"yy").unwrap();
1499 touch(&dir.path().join("old"), 1_000_000);
1500 touch(&dir.path().join("new"), 2_000_000);
1501
1502 let out = prune_cache(home.path(), dir.path(), 2)
1503 .expect("prune")
1504 .expect("the cache is free");
1505 assert_eq!(out.files, 1, "one deletion is enough to reach the cap");
1506 assert_eq!(out.remaining, 2);
1507 assert!(!dir.path().join("old").exists(), "the older file went");
1508 assert!(dir.path().join("new").exists(), "the newer one stayed");
1509
1510 let tied = tempfile::tempdir().unwrap();
1515 fs::write(tied.path().join("big"), b"xxxx").unwrap();
1516 fs::write(tied.path().join("small"), b"yy").unwrap();
1517 touch(&tied.path().join("big"), 1_000_000);
1518 touch(&tied.path().join("small"), 1_000_000);
1519 let out = prune_cache(home.path(), tied.path(), 2)
1520 .expect("prune")
1521 .expect("the cache is free");
1522 assert_eq!(out.files, 1, "the big one alone gets under the cap");
1523 assert_eq!(out.remaining, 2);
1524 assert!(tied.path().join("small").exists());
1525 }
1526
1527 #[test]
1528 fn prune_cache_if_over_limit_resolves_the_opt_outs_before_ever_measuring() {
1529 let home = tempfile::tempdir().unwrap();
1530 let dir = tempfile::tempdir().unwrap();
1531 fs::write(dir.path().join("big"), vec![0u8; 10]).unwrap();
1532
1533 let mut cfg = crate::config::Config::default();
1534 cfg.verify.gate = vec![format!(
1535 "CARGO_TARGET_DIR={} cargo make check",
1536 dir.path().display()
1537 )];
1538
1539 cfg.disk.cache_limit_bytes = 0;
1542 assert_eq!(
1543 prune_cache_if_over_limit(&cfg, home.path()).unwrap(),
1544 None,
1545 "a zero cap must not even look at the directory"
1546 );
1547 assert!(dir.path().join("big").exists());
1548
1549 let mut no_cache = crate::config::Config::default();
1552 no_cache.disk.cache_limit_bytes = 1;
1553 assert_eq!(
1554 prune_cache_if_over_limit(&no_cache, home.path()).unwrap(),
1555 None
1556 );
1557
1558 cfg.disk.cache_limit_bytes = 1;
1561 let pruned = prune_cache_if_over_limit(&cfg, home.path())
1562 .unwrap()
1563 .expect("a real cache dir over its cap prunes");
1564 assert_eq!(pruned.files, 1);
1565 assert!(!dir.path().join("big").exists());
1566 }
1567
1568 fn touch(path: &Path, secs: u64) {
1571 let f = fs::File::options().write(true).open(path).unwrap();
1572 f.set_times(fs::FileTimes::new().set_modified(
1573 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1574 ))
1575 .unwrap();
1576 }
1577
1578 #[test]
1581 fn fold_unreadable_clears_a_run_whose_state_never_landed() {
1582 let dir = tempfile::tempdir().unwrap();
1583 let runs = dir.path().join("runs");
1584 let wt = dir.path().join("wt");
1585 let id = "20260904-014540-88c0";
1586 std::fs::create_dir_all(runs.join(id)).unwrap();
1587 std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
1588
1589 let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold by id");
1590 assert_eq!(removed, vec![format!("runs/{id}")]);
1591 assert!(!runs.join(id).exists(), "record gone");
1592
1593 std::fs::create_dir_all(runs.join(id)).unwrap();
1595 std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
1596 assert!(
1597 block_on(fold_unreadable(&runs, &wt, "88c0")).is_ok(),
1598 "by prefix"
1599 );
1600
1601 std::fs::create_dir_all(runs.join("scratch")).unwrap();
1603 assert!(
1604 block_on(fold_unreadable(&runs, &wt, "scratch")).is_err(),
1605 "a stray directory is not a run"
1606 );
1607 }
1608
1609 #[test]
1610 fn fold_due_folds_terminal_runs_of_any_schema_but_leaves_genuinely_unreadable_ones() {
1611 let dir = tempfile::tempdir().unwrap();
1612 let runs = dir.path().join("runs");
1613 let wt = dir.path().join("wt");
1614 let home = dir.path().to_path_buf();
1615 let disk = Disk::default();
1616 let now = ts("2026-09-05T00:00:00Z");
1617
1618 let judging = "20260801-000000-0001";
1620 write_meta(&runs, judging, "judging", "2026-08-01T00:00:00Z");
1621
1622 let ready_fresh = "20260904-220000-0002";
1629 write_meta(&runs, ready_fresh, "ready", "2026-09-04T22:00:00Z");
1630
1631 let garbage = "20260901-000000-0004";
1637 std::fs::create_dir_all(runs.join(garbage)).unwrap();
1638 std::fs::write(runs.join(garbage).join("run.json"), "not json").unwrap();
1639 std::fs::create_dir_all(wt.join("0004")).unwrap();
1640
1641 let due_ready = due_run(&runs, &wt, "20260801-000000-ffff", SCHEMA);
1644
1645 let due_old_schema = due_run(&runs, &wt, "20260801-000000-eeee", SCHEMA - 1);
1650
1651 let (folded, unreadable) =
1652 block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
1653 assert_eq!(
1654 folded, 2,
1655 "both due, parseable runs fold regardless of their schema number"
1656 );
1657 assert_eq!(
1658 unreadable, 1,
1659 "only the run with broken JSON counts as unreadable"
1660 );
1661 assert!(runs.join(judging).exists(), "runnable never folded");
1662 assert!(runs.join(ready_fresh).exists(), "fresh never folded");
1663 assert!(runs.join(garbage).exists(), "unreadable record kept");
1664 assert!(wt.join("0004").exists(), "unreadable worktree kept");
1665 assert!(
1666 runs.join(&due_ready).exists(),
1667 "folding drops worktrees, not the record"
1668 );
1669 assert!(
1670 runs.join(&due_old_schema).exists(),
1671 "an old-schema record survives its fold exactly like a current one"
1672 );
1673 for id in [&due_ready, &due_old_schema] {
1679 let saved = read_meta(&runs, id).expect("folded run still parses");
1680 assert_ne!(
1681 saved.updated_at,
1682 ts("2026-08-01T00:00:00Z"),
1683 "fold_run must have saved the updated state back through the \
1684 `runs` directory this test passed to fold_due"
1685 );
1686 }
1687 }
1688
1689 #[tokio::test]
1695 async fn housekeep_leaves_everything_alone_when_auto_fold_is_disabled() {
1696 let dir = tempfile::tempdir().unwrap();
1697 let runs = dir.path().join("runs");
1698 let wt = dir.path().join("wt");
1699 let home = dir.path().to_path_buf();
1700 crate::run::set_home(dir.path().to_path_buf());
1701
1702 let due_id = due_run(&runs, &wt, "20260801-000000-abcd", SCHEMA);
1703 std::fs::create_dir_all(wt.join("orphan").join("cand-A")).unwrap();
1704
1705 let mut cfg = crate::config::Config::default();
1706 cfg.disk.auto_fold = false;
1707 cfg.disk.cache_limit_bytes = 0;
1708
1709 let out = housekeep(&cfg, &home, &wt, &dir.path().join("repo"), Timestamp::now()).await;
1710
1711 assert_eq!(out.folded, 0);
1712 assert_eq!(out.unreadable, 0);
1713 assert_eq!(out.orphaned_worktrees, 0);
1714 assert!(
1715 runs.join(&due_id).exists(),
1716 "a due run's record survives untouched"
1717 );
1718 assert!(
1719 wt.join("orphan").exists(),
1720 "an orphaned worktree survives untouched: the reclaim pass never ran"
1721 );
1722 }
1723
1724 #[test]
1725 fn fold_orphaned_worktrees_removes_only_worktrees_no_run_claims_and_none_in_flight() {
1726 let dir = tempfile::tempdir().unwrap();
1727 let runs = dir.path().join("runs");
1728 let wt = dir.path().join("wt");
1729 let home = dir.path().to_path_buf();
1730
1731 write_meta(
1734 &runs,
1735 "20260801-000000-aaaa",
1736 "ready",
1737 "2026-08-01T00:00:00Z",
1738 );
1739 std::fs::create_dir_all(wt.join("aaaa").join("cand-A")).unwrap();
1740
1741 std::fs::create_dir_all(wt.join("bbbb").join("cand-A")).unwrap();
1745
1746 std::fs::create_dir_all(wt.join("cccc")).unwrap();
1751
1752 std::fs::create_dir_all(wt.join("scratch")).unwrap();
1756
1757 let now = Timestamp::now() + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
1763 let mut status = crate::daemon::Status::new();
1764 status.current = vec![crate::daemon::Current {
1765 task: "20260905-000000-t111".to_owned(),
1766 run: "20260905-000000-cccc".to_owned(),
1767 }];
1768 status.updated_at = now;
1769 crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
1770
1771 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
1772 assert_eq!(
1773 folded, 1,
1774 "only the truly orphaned, idle, bay-shaped worktree is removed"
1775 );
1776 assert!(wt.join("aaaa").exists(), "claimed by a run record");
1777 assert!(!wt.join("bbbb").exists(), "orphaned and idle: reclaimed");
1778 assert!(wt.join("cccc").exists(), "a run in flight is never touched");
1779 assert!(
1780 wt.join("scratch").exists(),
1781 "not shaped like a worktree bay, so never a reclaim target"
1782 );
1783 }
1784
1785 #[test]
1793 fn fold_orphaned_worktrees_leaves_a_freshly_created_bay_alone() {
1794 let dir = tempfile::tempdir().unwrap();
1795 let runs = dir.path().join("runs");
1796 let wt = dir.path().join("wt");
1797 let home = dir.path().to_path_buf();
1798
1799 std::fs::create_dir_all(wt.join("dddd").join("under-review")).unwrap();
1800
1801 let now = Timestamp::now();
1802 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 6 * 60 * 60, now));
1803 assert_eq!(
1804 folded, 0,
1805 "too fresh to tell apart from a run still being set up"
1806 );
1807 assert!(wt.join("dddd").exists());
1808 }
1809
1810 fn open_question(store: &Questions, run: &str) -> crate::ask::Question {
1812 let mut q = crate::ask::Question::new(
1813 run.to_owned(),
1814 "implement".to_owned(),
1815 "impl-A".to_owned(),
1816 "Which storage backend should the cache use?".to_owned(),
1817 String::new(),
1818 vec!["SQLite".to_owned(), "Redis".to_owned()],
1819 );
1820 store.put(&mut q).unwrap();
1821 q
1822 }
1823
1824 #[test]
1830 fn a_finished_runs_open_question_is_swept_up() {
1831 let dir = tempfile::tempdir().unwrap();
1832 let runs = dir.path().join("runs");
1833 let store = Questions::at(dir.path().join("questions"));
1834
1835 let failed = "20260908-205802-c9eb";
1836 write_meta(&runs, failed, "failed", "2026-09-08T20:58:02Z");
1837 let failed_q = open_question(&store, failed);
1838
1839 let merged = "20260908-205501-ca67";
1840 write_meta(&runs, merged, "merged", "2026-09-08T20:55:01Z");
1841 let merged_q = open_question(&store, merged);
1842
1843 let n = abandon_settled_questions(&store, &runs);
1844 assert_eq!(n, 2, "both dead runs' questions are swept in one pass");
1845
1846 for (id, run) in [(&failed_q.id, failed), (&merged_q.id, merged)] {
1847 let back = store.get(id).unwrap();
1848 assert!(!back.status.open(), "{run} is done; nobody reads an answer");
1849 assert!(back.detail.contains(run), "{}", back.detail);
1850 }
1851 }
1852
1853 #[test]
1854 fn a_still_alive_runs_open_question_survives_the_sweep() {
1855 let dir = tempfile::tempdir().unwrap();
1856 let runs = dir.path().join("runs");
1857 let store = Questions::at(dir.path().join("questions"));
1858
1859 for (id, status) in [
1863 ("20260908-000000-b10c", "blocked"),
1864 ("20260908-000000-5ta1", "stalled"),
1865 ("20260908-000000-jud6", "judging"),
1866 ] {
1867 write_meta(&runs, id, status, "2026-09-08T00:00:00Z");
1868 let q = open_question(&store, id);
1869
1870 let n = abandon_settled_questions(&store, &runs);
1871 assert_eq!(n, 0, "{status} run is not done; nothing to sweep");
1872 assert!(
1873 store.get(&q.id).unwrap().status.open(),
1874 "{status} run's question must still be waiting"
1875 );
1876 }
1877 }
1878
1879 #[test]
1880 fn the_sweep_leaves_an_answered_question_and_an_unreadable_run_alone() {
1881 let dir = tempfile::tempdir().unwrap();
1882 let runs = dir.path().join("runs");
1883 let store = Questions::at(dir.path().join("questions"));
1884
1885 let done = "20260908-000000-answ";
1888 write_meta(&runs, done, "failed", "2026-09-08T00:00:00Z");
1889 let mut answered = open_question(&store, done);
1890 answered
1891 .answer(crate::ask::Answer::Choice("SQLite".to_owned()))
1892 .unwrap();
1893 store.put(&mut answered).unwrap();
1894
1895 let gone = "20260908-000000-gone";
1897 let orphan = open_question(&store, gone);
1898
1899 assert_eq!(abandon_settled_questions(&store, &runs), 0);
1900 assert_eq!(
1901 store.get(&answered.id).unwrap().status,
1902 crate::ask::QuestionStatus::Answered,
1903 "a real answer is never overwritten by a sweep"
1904 );
1905 assert!(
1906 store.get(&orphan.id).unwrap().status.open(),
1907 "a run this sweep cannot read is left exactly as it was, not guessed at"
1908 );
1909 }
1910
1911 #[test]
1920 fn fold_orphaned_worktrees_floors_a_zero_grace_at_the_race_safe_minimum() {
1921 let dir = tempfile::tempdir().unwrap();
1922 let runs = dir.path().join("runs");
1923 let wt = dir.path().join("wt");
1924 let home = dir.path().to_path_buf();
1925
1926 std::fs::create_dir_all(wt.join("eeee").join("under-review")).unwrap();
1927
1928 let now = Timestamp::now();
1930 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
1931 assert_eq!(
1932 folded, 0,
1933 "a zero grace must not defeat the race-safety floor"
1934 );
1935 assert!(wt.join("eeee").exists());
1936
1937 let later = now + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
1940 let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, later));
1941 assert_eq!(folded, 1, "old enough now, regardless of the zero grace");
1942 assert!(!wt.join("eeee").exists());
1943 }
1944
1945 #[test]
1946 fn clear_abandoned_active_only_acts_once_dead_and_overrun() {
1947 let dir = tempfile::tempdir().unwrap();
1948 crate::run::set_home(dir.path().to_path_buf());
1953 let home = dir.path().to_path_buf();
1954 let now = ts("2026-09-14T12:00:00Z");
1955 let overrun_seat = || crate::run::ActiveSeat {
1956 node: "implement".to_owned(),
1957 started_at: now - SignedDuration::new(21_000, 0),
1958 timeout_secs: 3_600,
1959 attempt: 0,
1960 task: None,
1961 command: None,
1962 index: None,
1963 total: None,
1964 };
1965
1966 let mut state = RunState::new(
1967 PathBuf::from("/repo"),
1968 "main".to_owned(),
1969 "abc1234".to_owned(),
1970 "fixture".to_owned(),
1971 crate::config::Config::default(),
1972 );
1973 state.status = RunStatus::Implementing;
1974 state.active.insert("impl-A".to_owned(), overrun_seat());
1975
1976 let mut fresh = state.clone();
1979 fresh.active.insert(
1980 "impl-B".to_owned(),
1981 crate::run::ActiveSeat {
1982 node: "implement".to_owned(),
1983 started_at: now,
1984 timeout_secs: 3_600,
1985 attempt: 0,
1986 task: None,
1987 command: None,
1988 index: None,
1989 total: None,
1990 },
1991 );
1992 assert!(!clear_abandoned_active(&mut fresh, &home, now).unwrap());
1993 assert!(!fresh.active.is_empty());
1994 assert_eq!(fresh.status, RunStatus::Implementing);
1995
1996 let store = Questions::at(home.join("questions"));
1997 let q = open_question(&store, &state.id);
1998
1999 assert!(clear_abandoned_active(&mut state, &home, now).unwrap());
2000 assert!(state.active.is_empty());
2001 assert_eq!(state.status, RunStatus::Failed);
2002 assert!(
2003 !store.get(&q.id).unwrap().status.open(),
2004 "the abandoned seat's own open question must not keep badging the \
2005 operator until some later daemon startup notices it"
2006 );
2007 }
2008
2009 fn write_meta(runs: &Path, id: &str, status: &str, updated_at: &str) {
2011 let day = &updated_at[..10];
2012 std::fs::create_dir_all(runs.join(id)).unwrap();
2013 let body = format!(
2014 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}}"#
2015 );
2016 std::fs::write(runs.join(id).join("run.json"), body).unwrap();
2017 }
2018
2019 fn due_run(runs: &Path, wt: &Path, id: &str, schema: u32) -> String {
2029 due_run_with_status(runs, wt, id, schema, RunStatus::Ready)
2030 }
2031
2032 fn due_run_with_status(
2035 runs: &Path,
2036 wt: &Path,
2037 id: &str,
2038 schema: u32,
2039 status: RunStatus,
2040 ) -> String {
2041 let mut config = crate::config::Config::default();
2042 config.graph.worktree_root = Some(wt.to_path_buf());
2043 let mut state = RunState::new(
2044 PathBuf::from("/nonexistent/repo"),
2045 "main".to_owned(),
2046 "0000000000000000000000000000000000000000".to_owned(),
2047 String::new(),
2048 config,
2049 );
2050 state.id = id.to_owned();
2051 state.status = status;
2052 state.updated_at = ts("2026-08-01T00:00:00Z");
2053 let mut value = serde_json::to_value(&state).unwrap();
2054 value["schema"] = serde_json::json!(schema);
2055 std::fs::create_dir_all(runs.join(id)).unwrap();
2056 std::fs::write(
2057 runs.join(id).join("run.json"),
2058 serde_json::to_string_pretty(&value).unwrap(),
2059 )
2060 .unwrap();
2061 id.to_owned()
2062 }
2063
2064 #[test]
2065 fn fold_due_folds_a_superseded_run_same_as_any_other_terminal_one() {
2066 let dir = tempfile::tempdir().unwrap();
2067 let runs = dir.path().join("runs");
2068 let wt = dir.path().join("wt");
2069 let home = dir.path().to_path_buf();
2070 let disk = Disk::default();
2071 let now = ts("2026-09-05T00:00:00Z");
2072
2073 let superseded = due_run_with_status(
2074 &runs,
2075 &wt,
2076 "20260801-000000-cccc",
2077 SCHEMA,
2078 RunStatus::Superseded,
2079 );
2080
2081 let (folded, unreadable) =
2082 block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
2083 assert_eq!(
2084 folded, 1,
2085 "a superseded run has nothing left for a human to check, so it folds \
2086 exactly like a merged one"
2087 );
2088 assert_eq!(unreadable, 0);
2089 assert!(
2090 read_meta(&runs, &superseded).is_ok(),
2091 "folding drops the worktree, not the record"
2092 );
2093 }
2094
2095 fn init_repo(dir: &Path) {
2098 use crate::proc::Quiet as _;
2099 let run = |args: &[&str]| {
2100 let out = std::process::Command::new("git")
2101 .args(args)
2102 .current_dir(dir)
2103 .quiet()
2104 .output()
2105 .expect("spawn git");
2106 assert!(
2107 out.status.success(),
2108 "git {args:?} failed: {}",
2109 String::from_utf8_lossy(&out.stderr)
2110 );
2111 };
2112 run(&["init", "-b", "main"]);
2113 run(&["config", "user.name", "magi test"]);
2114 run(&["config", "user.email", "magi@example.com"]);
2115 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
2116 run(&["add", "-A"]);
2117 run(&["commit", "-m", "init"]);
2118 }
2119
2120 async fn due_run_with_winner_worktree(
2124 runs: &Path,
2125 wt: &Path,
2126 repo: &Path,
2127 id: &str,
2128 status: RunStatus,
2129 ) -> (String, PathBuf) {
2130 use crate::run::{Candidate, Tally};
2131 use std::collections::BTreeMap;
2132
2133 let mut config = crate::config::Config::default();
2134 config.graph.worktree_root = Some(wt.to_path_buf());
2135 let mut state = RunState::new(
2136 repo.to_path_buf(),
2137 "main".to_owned(),
2138 "0000000000000000000000000000000000000000".to_owned(),
2139 String::new(),
2140 config,
2141 );
2142 state.id = id.to_owned();
2143 state.status = status;
2144 state.updated_at = ts("2026-08-01T00:00:00Z");
2145
2146 let winner_wt = state.worktree_root().join("cand-A");
2147 crate::git::worktree_add_branch(repo, &winner_wt, &format!("magi/{id}/A"), "main")
2148 .await
2149 .expect("winner worktree");
2150
2151 state.candidates.push(Candidate {
2152 index: 0,
2153 label: 'A',
2154 agent: "agent".to_owned(),
2155 branch: format!("magi/{id}/A"),
2156 worktree: winner_wt.clone(),
2157 summary: String::new(),
2158 stat: String::new(),
2159 files: 0,
2160 commits: 0,
2161 empty: false,
2162 failed: None,
2163 verified_noop: None,
2164 duration_ms: 0,
2165 folded: false,
2166 });
2167 state.tally = Some(Tally {
2168 first_choice: BTreeMap::from([('A', 1)]),
2169 borda: BTreeMap::new(),
2170 winner: 'A',
2171 rankings: 1,
2172 unanimous_initial: true,
2173 deliberated: false,
2174 changed_votes: 0,
2175 unanimous_final: true,
2176 tie_break: None,
2177 judges: 1,
2178 present: 1,
2179 quorum: 1,
2180 met_quorum: true,
2181 uncontested: None,
2182 });
2183 std::fs::create_dir_all(runs.join(id)).unwrap();
2184 std::fs::write(
2185 runs.join(id).join("run.json"),
2186 serde_json::to_string_pretty(&state).unwrap(),
2187 )
2188 .unwrap();
2189 (id.to_owned(), winner_wt)
2190 }
2191
2192 #[tokio::test]
2193 async fn completed_task_retains_independent_historical_sources_without_a_merge_proof() {
2194 let dir = tempfile::tempdir().unwrap();
2195 let home = dir.path();
2196 let runs = home.join("runs");
2197 let wt = home.join("wt");
2198 let repo = home.join("repo");
2199 std::fs::create_dir_all(&repo).unwrap();
2200 init_repo(&repo);
2201 let queue = crate::queue::Queue::at(home.join("queue"));
2202 let mut task = crate::queue::Task::new(
2203 "completed".into(),
2204 "retain independent historical changes".into(),
2205 repo.clone(),
2206 crate::queue::Source::Human,
2207 );
2208 let mut sources = Vec::new();
2209 for (id, status) in [
2210 ("20260801-000000-old1", RunStatus::Superseded),
2211 ("20260801-000000-old2", RunStatus::Failed),
2212 ] {
2213 let (id, worktree) = due_run_with_winner_worktree(&runs, &wt, &repo, id, status).await;
2214 std::fs::write(worktree.join("independent.txt"), &id).unwrap();
2215 crate::git::git(&worktree, &["add", "independent.txt"])
2216 .await
2217 .unwrap();
2218 crate::git::git(
2219 &worktree,
2220 &["commit", "-m", "Independent historical change"],
2221 )
2222 .await
2223 .unwrap();
2224 let head = crate::git::rev_parse(&worktree, "HEAD").await.unwrap();
2225 let mut state = RunState::load_under(&id, home).unwrap();
2226 state.driver_exited = true;
2227 if status == RunStatus::Failed {
2228 state.tally = None;
2229 }
2230 state.save_under(home).unwrap();
2231 task.link_run(&id);
2232 sources.push((id, worktree, head));
2233 }
2234 let mut latest = RunState::new(
2237 repo.clone(),
2238 "main".into(),
2239 crate::git::rev_parse(&repo, "main").await.unwrap(),
2240 task.instruction.clone(),
2241 crate::config::Config::default(),
2242 );
2243 latest.status = RunStatus::Ready;
2244 latest.driver_exited = true;
2245 latest.save_under(home).unwrap();
2246 task.start(latest.id.clone());
2247 task.succeed();
2248 queue.put(&mut task).unwrap();
2249
2250 let (folded, unreadable) = fold_due(
2251 &runs,
2252 home,
2253 &wt,
2254 &Disk::default(),
2255 ts("2026-09-05T00:00:00Z"),
2256 )
2257 .await
2258 .unwrap();
2259 assert_eq!((folded, unreadable), (0, 0));
2260 for (id, worktree, head) in sources {
2261 assert!(worktree.exists());
2262 assert_eq!(
2263 crate::git::rev_parse(&worktree, "HEAD").await.unwrap(),
2264 head
2265 );
2266 let mut state = RunState::load_under(&id, home).unwrap();
2267 assert!(!state.candidates[0].folded);
2268 let error = crate::graph::fold_run(&mut state, true, home)
2269 .await
2270 .unwrap_err();
2271 assert!(
2272 error
2273 .to_string()
2274 .contains(&format!("source of task {}", task.id))
2275 );
2276 assert_eq!(
2277 crate::git::rev_parse(&repo, &state.candidates[0].branch)
2278 .await
2279 .unwrap(),
2280 head
2281 );
2282 assert!(worktree.exists());
2283 assert!(runs.join(&id).join("run.json").exists());
2284 }
2285 }
2286
2287 #[tokio::test]
2288 async fn recovery_retains_historical_sources_after_success_and_automatic_cleanup() {
2289 let dir = tempfile::tempdir().unwrap();
2290 let home = dir.path();
2291 let runs = home.join("runs");
2292 let wt = home.join("wt");
2293 let repo = home.join("repo");
2294 std::fs::create_dir_all(&repo).unwrap();
2295 init_repo(&repo);
2296 let mut task = crate::queue::Task::new(
2297 "recover".into(),
2298 "keep measured behavior".into(),
2299 repo.clone(),
2300 crate::queue::Source::Human,
2301 );
2302 let mut sources = Vec::new();
2303 for (id, status) in [
2304 ("20260801-000000-old1", RunStatus::Blocked),
2305 ("20260801-000000-old2", RunStatus::Stalled),
2306 ] {
2307 let (id, worktree) = due_run_with_winner_worktree(&runs, &wt, &repo, id, status).await;
2308 let mut state = RunState::load_under(&id, home).unwrap();
2309 state.driver_exited = true;
2310 state.save_under(home).unwrap();
2311 task.link_run(&id);
2312 sources.push((id, worktree));
2313 }
2314 crate::pr_recovery::retain_sources(&task, home).unwrap();
2315 let mut recovered = RunState::new(
2316 repo.clone(),
2317 "main".into(),
2318 "base".into(),
2319 task.instruction.clone(),
2320 crate::config::Config::default(),
2321 );
2322 recovered.config.graph.worktree_root = Some(wt.clone());
2323 recovered.status = RunStatus::Ready;
2324 recovered.driver_exited = true;
2325 recovered.save_under(home).unwrap();
2326 task.start(recovered.id.clone());
2327 task.succeed();
2328 crate::daemon::supersede_prior_runs(&task, home);
2329 for (id, _) in &sources {
2330 assert_eq!(
2331 RunState::load_under(id, home).unwrap().status,
2332 RunStatus::Superseded
2333 );
2334 }
2335 let disk = Disk::default();
2336 let now = Timestamp::now()
2337 .checked_add(SignedDuration::from_secs(
2338 i64::try_from(disk.fold_grace_secs + 1).unwrap(),
2339 ))
2340 .unwrap();
2341 let (_, unreadable) = fold_due(&runs, home, &wt, &disk, now).await.unwrap();
2342 assert_eq!(unreadable, 0);
2343 for (id, worktree) in sources {
2344 assert!(worktree.exists());
2345 assert!(
2346 crate::git::branch_exists(&repo, &format!("magi/{id}/A"))
2347 .await
2348 .unwrap()
2349 );
2350 let mut historical = RunState::load_under(&id, home).unwrap();
2351 assert!(!historical.candidates[0].folded);
2352 let error = crate::graph::fold_run(&mut historical, true, home)
2353 .await
2354 .unwrap_err();
2355 assert!(
2356 error
2357 .to_string()
2358 .contains("explicitly retained recovery source")
2359 );
2360 assert!(!historical.candidates[0].folded);
2361 assert!(
2362 worktree.exists(),
2363 "every automatic fold entry preserves retained sources"
2364 );
2365 }
2366 }
2367
2368 #[tokio::test]
2369 async fn fold_due_drops_a_superseded_runs_own_winner_worktree_but_keeps_a_readys() {
2370 let dir = tempfile::tempdir().unwrap();
2371 let runs = dir.path().join("runs");
2372 let wt = dir.path().join("wt");
2373 let repo = dir.path().join("repo");
2374 let home = dir.path().to_path_buf();
2375 let disk = Disk::default();
2376 let now = ts("2026-09-05T00:00:00Z");
2377 std::fs::create_dir_all(&repo).unwrap();
2378 init_repo(&repo);
2379
2380 let (superseded_id, superseded_wt) = due_run_with_winner_worktree(
2381 &runs,
2382 &wt,
2383 &repo,
2384 "20260801-000000-supw",
2385 RunStatus::Superseded,
2386 )
2387 .await;
2388 let (ready_id, ready_wt) = due_run_with_winner_worktree(
2395 &runs,
2396 &wt,
2397 &repo,
2398 "20260801-000000-rdyw",
2399 RunStatus::Ready,
2400 )
2401 .await;
2402
2403 let (folded, unreadable) = fold_due(&runs, &home, &wt, &disk, now)
2404 .await
2405 .expect("fold_due");
2406 assert_eq!(folded, 2);
2407 assert_eq!(unreadable, 0);
2408
2409 assert!(
2410 !superseded_wt.exists(),
2411 "a superseded run's own winner never lands anywhere else, so its worktree \
2412 must be dropped exactly like a merged run's"
2413 );
2414 assert!(
2415 ready_wt.exists(),
2416 "a still-ready run's winner may yet be merged by hand - folding must not \
2417 touch it"
2418 );
2419
2420 assert!(read_meta(&runs, &superseded_id).is_ok());
2422 assert!(read_meta(&runs, &ready_id).is_ok());
2423 }
2424}