1use std::path::{Path, PathBuf};
42use std::sync::Arc;
43use std::sync::atomic::{AtomicBool, Ordering};
44use std::sync::{Mutex, MutexGuard};
45use std::time::Duration;
46
47use anyhow::{Context, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50use tokio::sync::Notify;
51
52use crate::config::{Config, MergeMode};
53use crate::graph::Runner;
54use crate::queue::{Queue, Task};
55use crate::run::{RunState, RunStatus};
56
57pub const SCHEMA: u32 = 1;
59
60pub const HEARTBEAT: Duration = Duration::from_secs(5);
64
65pub const STALE_SECS: i64 = 30;
74
75pub const POLL: Duration = Duration::from_secs(5);
77
78pub const STALE_CLAIM: Duration = Duration::from_secs(6 * 60 * 60);
82
83#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(default)]
86pub struct Current {
87 pub task: String,
89 pub run: String,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Status {
101 pub schema: u32,
103 pub pid: u32,
105 pub started_at: Timestamp,
107 pub updated_at: Timestamp,
109 pub idle: bool,
111 pub current: Option<Current>,
113 pub completed: usize,
115 pub polls: u64,
117}
118
119impl Status {
120 #[must_use]
122 pub fn new() -> Self {
123 let now = Timestamp::now();
124 Self {
125 schema: SCHEMA,
126 pid: std::process::id(),
127 started_at: now,
128 updated_at: now,
129 idle: true,
130 current: None,
131 completed: 0,
132 polls: 0,
133 }
134 }
135}
136
137impl Default for Status {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143#[derive(Debug, Clone)]
145pub struct Opts {
146 pub repo: PathBuf,
148 pub config: Option<PathBuf>,
150 pub poll: Duration,
152 pub max_attempts: usize,
154 pub once: bool,
156 pub merge: Option<String>,
158}
159
160impl Default for Opts {
161 fn default() -> Self {
162 Self {
163 repo: PathBuf::from("."),
164 config: None,
165 poll: POLL,
166 max_attempts: 2,
167 once: false,
168 merge: None,
169 }
170 }
171}
172
173#[must_use]
175pub fn status_path() -> PathBuf {
176 crate::run::home().join("daemon.json")
177}
178
179pub fn write_status(status: &Status) -> Result<()> {
181 write_status_to(&status_path(), status)
182}
183
184pub fn write_status_to(path: &Path, status: &Status) -> Result<()> {
189 if let Some(parent) = path.parent() {
190 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
191 }
192 let body = serde_json::to_string_pretty(status).context("serialize daemon status")?;
193 let tmp = path.with_extension("json.tmp");
194 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
195 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
196 Ok(())
197}
198
199pub fn clear_status() {
202 clear_status_at(&status_path());
203}
204
205fn clear_status_at(path: &Path) {
209 let _ = std::fs::remove_file(path);
210}
211
212#[derive(Debug, Clone, Default)]
225pub struct Stop {
226 stopped: Arc<AtomicBool>,
230 busy: Arc<AtomicBool>,
233 wake: Arc<Notify>,
237 pause: crate::graph::Pause,
240}
241
242impl Stop {
243 #[must_use]
245 pub fn new() -> Self {
246 Self::default()
247 }
248
249 pub fn stop(&self) {
252 self.stopped.store(true, Ordering::SeqCst);
253 self.wake.notify_one();
257 }
258
259 #[must_use]
261 pub fn stopped(&self) -> bool {
262 self.stopped.load(Ordering::SeqCst)
263 }
264
265 #[must_use]
273 pub fn finishing(&self) -> bool {
274 self.stopped() && self.busy.load(Ordering::SeqCst)
275 }
276
277 pub fn park(&self) {
288 self.pause.park();
289 self.stop();
290 }
291
292 #[must_use]
294 pub fn parking(&self) -> bool {
295 self.pause.parked()
296 }
297
298 #[must_use]
300 pub fn pause(&self) -> crate::graph::Pause {
301 self.pause.clone()
302 }
303
304 fn busy(&self, running: bool) {
306 self.busy.store(running, Ordering::SeqCst);
307 }
308
309 async fn idle(&self, poll: Duration) {
311 tokio::select! {
312 () = tokio::time::sleep(poll) => {}
313 () = self.wake.notified() => {}
314 }
315 }
316}
317
318#[derive(Debug, Clone, Default, Deserialize)]
325#[serde(default)]
326pub struct Reading {
327 pub schema: u32,
329 pub pid: Option<u32>,
331 pub started_at: Option<Timestamp>,
333 pub updated_at: Option<Timestamp>,
335 pub idle: bool,
337 pub current: Option<Current>,
339 pub completed: u64,
341 pub polls: u64,
343}
344
345impl Reading {
346 #[must_use]
349 pub fn age_secs(&self, now: Timestamp) -> Option<i64> {
350 self.updated_at
351 .map(|at| (now.as_second() - at.as_second()).max(0))
352 }
353
354 #[must_use]
358 pub fn running(&self, now: Timestamp) -> bool {
359 self.age_secs(now).is_some_and(|secs| secs <= STALE_SECS)
360 }
361}
362
363#[must_use]
370pub fn read_status(home: &Path) -> Option<Reading> {
371 let body = std::fs::read_to_string(home.join("daemon.json")).ok()?;
372 serde_json::from_str(&body).ok()
373}
374
375#[must_use]
384pub fn current_work(home: &Path, now: Timestamp) -> Option<Current> {
385 read_status(home)
386 .filter(|reading| reading.running(now))
387 .and_then(|reading| reading.current)
388}
389
390#[must_use]
392pub fn is_working_on(home: &Path, run: &str, now: Timestamp) -> bool {
393 current_work(home, now).is_some_and(|c| c.run == run)
394}
395
396#[must_use]
398pub fn is_working_on_task(home: &Path, task: &str, now: Timestamp) -> bool {
399 current_work(home, now).is_some_and(|c| c.task == task)
400}
401
402pub fn sweep_stale_claims(queue: &Queue, older_than: Duration) -> Vec<String> {
419 let mut swept: Vec<String> = std::fs::read_dir(queue.root())
420 .into_iter()
421 .flatten()
422 .flatten()
423 .map(|e| e.path())
424 .filter(|p| p.extension().is_some_and(|x| x == "lock"))
425 .filter(|p| {
426 p.metadata()
427 .and_then(|m| m.modified())
428 .and_then(|t| t.elapsed().map_err(std::io::Error::other))
429 .is_ok_and(|age| age >= older_than)
430 })
431 .filter(|p| std::fs::remove_file(p).is_ok())
432 .filter_map(|p| {
433 p.file_stem()
434 .and_then(|s| s.to_str())
435 .map(std::borrow::ToOwned::to_owned)
436 })
437 .collect();
438 swept.sort_unstable();
439 swept
440}
441
442#[derive(Debug, Clone, Copy)]
449pub struct Verdict {
450 pub status: RunStatus,
452 pub left_pr: bool,
454 pub quota_hit: bool,
456 pub parked: bool,
458}
459
460pub fn settle(task: &mut Task, verdict: Verdict, detail: &str, max_attempts: usize) {
493 if verdict.parked {
499 task.stall(detail);
500 return;
501 }
502 match verdict.status {
503 RunStatus::Merged | RunStatus::Ready => task.succeed(),
504 RunStatus::Stalled if verdict.quota_hit => task.stall(detail),
505 RunStatus::Stalled | RunStatus::Failed => task.fail(detail, max_attempts),
506 RunStatus::Blocked if verdict.left_pr => task.handed_off(detail),
507 RunStatus::Blocked => task.fail(detail, max_attempts),
508 other => task.fail(
509 format!(
510 "the graph stopped at `{}` without reaching a terminal status: {detail}",
511 label(other)
512 ),
513 max_attempts,
514 ),
515 }
516}
517
518pub async fn serve(opts: Opts) -> Result<()> {
524 serve_until(opts, Stop::new()).await
525}
526
527pub async fn serve_until(opts: Opts, stop: Stop) -> Result<()> {
544 let signal = {
545 let stop = stop.clone();
546 tokio::spawn(async move {
547 if tokio::signal::ctrl_c().await.is_ok() {
548 stop.stop();
549 tracing::info!("shutdown requested; a run in flight will be finished first");
550 }
551 })
552 };
553
554 let outcome = drive(&opts, &Queue::open(), &status_path(), &stop).await;
555
556 signal.abort();
557 outcome
558}
559
560async fn drive(opts: &Opts, queue: &Queue, status_file: &Path, stop: &Stop) -> Result<()> {
569 let swept = sweep_stale_claims(queue, STALE_CLAIM);
570 if !swept.is_empty() {
571 tracing::warn!(
572 "swept {} stale claim(s) left behind by an earlier daemon: {}",
573 swept.len(),
574 swept.join(", ")
575 );
576 }
577
578 let status = Arc::new(Mutex::new(Status::new()));
586 write_status_to(status_file, &lock(&status)).context("publish the daemon status file")?;
587 let beat = tokio::spawn(heartbeat(Arc::clone(&status), status_file.to_path_buf()));
588
589 tracing::info!(
590 "magi serve: queue {} (poll {}s, {} attempts per task, one run at a time)",
591 queue.root().display(),
592 opts.poll.as_secs(),
593 opts.max_attempts
594 );
595
596 let outcome = poll(opts, queue, &status, stop).await;
597
598 beat.abort();
599 clear_status_at(status_file);
600 outcome
601}
602
603async fn heartbeat(status: Arc<Mutex<Status>>, path: PathBuf) {
609 loop {
610 tokio::time::sleep(HEARTBEAT).await;
611 let snapshot = {
612 let mut guard = lock(&status);
613 guard.updated_at = Timestamp::now();
614 guard.clone()
615 };
616 if let Err(e) = write_status_to(&path, &snapshot) {
617 tracing::warn!("could not refresh the daemon status file: {e:#}");
620 }
621 }
622}
623
624async fn poll(opts: &Opts, queue: &Queue, status: &Arc<Mutex<Status>>, stop: &Stop) -> Result<()> {
627 let mut attempted: Vec<String> = Vec::new();
632
633 while !stop.stopped() {
634 lock(status).polls += 1;
635
636 let candidates: Vec<Task> = runnable(queue)
637 .into_iter()
638 .filter(|t| !opts.once || !attempted.contains(&t.id))
639 .collect();
640
641 let mut ran = false;
642 for candidate in candidates {
643 if stop.stopped() {
644 break;
645 }
646 let Ok(_claim) = queue.claim(&candidate.id) else {
651 tracing::debug!("task {} is claimed elsewhere; skipping", candidate.short());
652 continue;
653 };
654 let mut task = match queue.get(&candidate.id) {
657 Ok(t) if t.status.runnable() => t,
658 Ok(_) => continue,
659 Err(e) => {
660 tracing::warn!("could not re-read task {}: {e:#}", candidate.short());
661 continue;
662 }
663 };
664 attempted.push(task.id.clone());
665 lock(status).idle = false;
666 stop.busy(true);
669 attempt(opts, queue, status, stop, &mut task).await;
670 stop.busy(false);
671 {
672 let mut guard = lock(status);
673 guard.current = None;
674 guard.completed += 1;
675 }
676 ran = true;
677 break;
678 }
679
680 if ran {
681 continue;
682 }
683
684 lock(status).idle = true;
685 if opts.once {
686 return Ok(());
687 }
688 stop.idle(opts.poll).await;
689 }
690 Ok(())
691}
692
693async fn attempt(
699 opts: &Opts,
700 queue: &Queue,
701 status: &Arc<Mutex<Status>>,
702 stop: &Stop,
703 task: &mut Task,
704) {
705 let repo = repo_for(task, &opts.repo);
706 tracing::info!(
707 "task {} — {} (repo {})",
708 task.short(),
709 task.title,
710 repo.display()
711 );
712
713 let config = match prepare(&repo, opts) {
714 Ok(c) => c,
715 Err(e) => {
716 task.attempts += 1;
720 task.fail(format!("config: {e:#}"), opts.max_attempts);
721 record(queue, task);
722 return;
723 }
724 };
725
726 let unfinished = task
732 .runs
733 .iter()
734 .rev()
735 .find(|id| {
736 RunState::load(id)
737 .map(|s| !s.status.done())
738 .unwrap_or(false)
739 })
740 .cloned();
741 let started = match &unfinished {
742 Some(id) => {
743 tracing::info!("resuming run {id} rather than competing again");
744 Runner::resume(id)
745 }
746 None => Runner::start(&repo, task.instruction.clone(), config).await,
747 };
748 let mut runner = match started {
749 Ok(r) => r,
750 Err(e) => {
751 task.attempts += 1;
752 task.fail(format!("could not start the run: {e:#}"), opts.max_attempts);
753 record(queue, task);
754 return;
755 }
756 };
757 runner.on_pause(stop.pause());
759
760 let run = runner.state.id.clone();
763 task.start(run.clone());
764 record(queue, task);
765 lock(status).current = Some(Current {
766 task: task.id.clone(),
767 run,
768 });
769
770 let detail = match runner.execute().await {
771 Ok(()) => describe(&runner.state),
772 Err(e) => format!("{e:#}"),
773 };
774 let verdict = Verdict {
775 status: runner.state.status,
776 left_pr: runner.state.pr.is_some(),
779 quota_hit: !runner.state.quota.is_empty(),
781 parked: runner.state.parked,
785 };
786 settle(task, verdict, &detail, opts.max_attempts);
787 record(queue, task);
788 tracing::info!(
789 "task {} is {} after run {} ({})",
790 task.short(),
791 task.status.as_str(),
792 runner.state.short(),
793 label(runner.state.status)
794 );
795}
796
797fn prepare(repo: &Path, opts: &Opts) -> Result<Config> {
799 let (mut config, _layers) = Config::discover(repo, opts.config.as_deref())?;
800 if let Some(mode) = &opts.merge {
801 config.merge.mode = merge_mode(mode)?;
802 }
803 Ok(config)
804}
805
806fn repo_for(task: &Task, fallback: &Path) -> PathBuf {
809 if task.repo.as_os_str().is_empty() || task.repo == Path::new(".") {
810 return fallback.to_path_buf();
811 }
812 task.repo.clone()
813}
814
815fn record(queue: &Queue, task: &mut Task) {
819 if let Err(e) = queue.put(task) {
820 tracing::error!("could not record task {}: {e:#}", task.short());
821 }
822}
823
824fn runnable(queue: &Queue) -> Vec<Task> {
830 let mut tasks: Vec<Task> = queue
831 .list()
832 .into_iter()
833 .filter(|t| t.status.runnable())
834 .collect();
835 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
836 tasks
837}
838
839fn describe(state: &RunState) -> String {
845 let mut detail = if state.status == RunStatus::Stalled {
846 let mut seats: Vec<&str> = state.quota.iter().map(|q| q.seat.as_str()).collect();
847 seats.sort_unstable();
848 seats.dedup();
849 if seats.is_empty() {
850 "the judging panel lost its quorum".to_owned()
851 } else {
852 format!(
853 "the judging panel lost its quorum; quota took out {}",
854 seats.join(", ")
855 )
856 }
857 } else {
858 format!("run ended {}", label(state.status))
859 };
860 if let Some(last) = state.events.last() {
861 detail.push_str(&format!(" ({}: {})", last.node, last.message));
862 }
863 detail.push_str(&format!(" [run {}]", state.id));
864 detail
865}
866
867fn label(status: RunStatus) -> &'static str {
872 status.as_str()
873}
874
875fn merge_mode(mode: &str) -> Result<MergeMode> {
877 match mode {
878 "none" => Ok(MergeMode::None),
879 "local" => Ok(MergeMode::Local),
880 "pr" => Ok(MergeMode::Pr),
881 other => bail!("unknown merge mode `{other}`; expected none, local or pr"),
882 }
883}
884
885fn lock(status: &Mutex<Status>) -> MutexGuard<'_, Status> {
890 status
891 .lock()
892 .unwrap_or_else(std::sync::PoisonError::into_inner)
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898 use crate::queue::{Source, TaskStatus};
899 use pretty_assertions::assert_eq;
900
901 fn task() -> Task {
902 Task::new(
903 "add retries".to_owned(),
904 "add retries".to_owned(),
905 PathBuf::from("/repo"),
906 Source::Human,
907 )
908 }
909
910 #[test]
911 fn every_run_status_settles_the_task_it_came_from() {
912 let table = [
914 (RunStatus::Merged, TaskStatus::Done, 1),
915 (RunStatus::Ready, TaskStatus::Done, 1),
916 (RunStatus::Stalled, TaskStatus::Failed, 0),
917 (RunStatus::Blocked, TaskStatus::Failed, 1),
918 (RunStatus::Failed, TaskStatus::Failed, 1),
919 (RunStatus::Prep, TaskStatus::Failed, 1),
920 (RunStatus::Implementing, TaskStatus::Failed, 1),
921 (RunStatus::Judging, TaskStatus::Failed, 1),
922 (RunStatus::Deliberating, TaskStatus::Failed, 1),
923 (RunStatus::Voting, TaskStatus::Failed, 1),
924 (RunStatus::Reviewing, TaskStatus::Failed, 1),
925 (RunStatus::Gating, TaskStatus::Failed, 1),
926 ];
927 for (run, want, attempts) in table {
928 let mut t = task();
929 t.start("20260902-000000-aaaa".to_owned());
930 settle(
931 &mut t,
932 Verdict {
933 status: run,
934 left_pr: false,
935 parked: false,
936 quota_hit: matches!(run, RunStatus::Stalled),
937 },
938 "why",
939 2,
940 );
941 assert_eq!(t.status, want, "task status after {}", label(run));
942 assert_eq!(t.attempts, attempts, "attempts after {}", label(run));
943 }
944 }
945
946 #[test]
947 fn a_quota_stall_costs_the_task_no_attempt_but_a_block_does() {
948 let mut stalled = task();
949 stalled.start("20260902-000000-aaaa".to_owned());
950 settle(
951 &mut stalled,
952 Verdict {
953 status: RunStatus::Stalled,
954 left_pr: false,
955 parked: false,
956 quota_hit: true,
957 },
958 "quota",
959 1,
960 );
961 assert_eq!(stalled.attempts, 0);
962 assert!(
963 stalled.status.runnable(),
964 "a machine problem must leave the task in line"
965 );
966
967 let mut blocked = task();
968 blocked.start("20260902-000000-aaaa".to_owned());
969 settle(
970 &mut blocked,
971 Verdict {
972 status: RunStatus::Blocked,
973 left_pr: false,
974 parked: false,
975 quota_hit: false,
976 },
977 "findings open",
978 1,
979 );
980 assert_eq!(blocked.attempts, 1);
981 assert_eq!(
982 blocked.status,
983 TaskStatus::Held,
984 "the last attempt hands the task to a human"
985 );
986 }
987
988 #[test]
989 fn a_run_that_opened_a_pull_request_is_never_re_competed() {
990 let mut delivered = task();
993 delivered.start("20260903-080619-01c2".to_owned());
994 settle(
995 &mut delivered,
996 Verdict {
997 status: RunStatus::Blocked,
998 left_pr: true,
999 parked: false,
1000 quota_hit: false,
1001 },
1002 "no check status",
1003 4,
1004 );
1005 assert_eq!(
1006 delivered.status,
1007 TaskStatus::Held,
1008 "a pull request waiting on CI or a person is not a retryable failure"
1009 );
1010 assert!(
1011 !delivered.status.runnable(),
1012 "the loop must not pick this task up again"
1013 );
1014 assert_eq!(
1015 delivered.last_error.as_deref(),
1016 Some("no check status"),
1017 "the operator needs to be told what the gate was waiting for"
1018 );
1019
1020 let mut empty_handed = task();
1023 empty_handed.start("20260903-080619-01c2".to_owned());
1024 settle(
1025 &mut empty_handed,
1026 Verdict {
1027 status: RunStatus::Blocked,
1028 left_pr: false,
1029 parked: false,
1030 quota_hit: false,
1031 },
1032 "findings open",
1033 4,
1034 );
1035 assert_eq!(empty_handed.status, TaskStatus::Failed);
1036 assert!(empty_handed.status.runnable());
1037 }
1038
1039 #[test]
1040 fn parking_costs_the_task_no_attempt_and_leaves_it_in_line() {
1041 let mut parked = task();
1046 parked.start("20260903-183634-2d98".to_owned());
1047 settle(
1048 &mut parked,
1049 Verdict {
1050 status: RunStatus::Implementing,
1051 left_pr: false,
1052 quota_hit: false,
1053 parked: true,
1054 },
1055 "parked after `implementing`",
1056 2,
1057 );
1058 assert_eq!(parked.attempts, 0, "a park is refunded");
1059 assert!(
1060 parked.status.runnable(),
1061 "and the task stays in line so the next loop resumes its run"
1062 );
1063 assert_eq!(
1064 parked.last_error.as_deref(),
1065 Some("parked after `implementing`"),
1066 "the card says where it stopped"
1067 );
1068
1069 let mut broken = task();
1073 broken.start("20260903-183634-2d98".to_owned());
1074 settle(
1075 &mut broken,
1076 Verdict {
1077 status: RunStatus::Implementing,
1078 left_pr: false,
1079 quota_hit: false,
1080 parked: false,
1081 },
1082 "returned mid-flight",
1083 2,
1084 );
1085 assert_eq!(broken.attempts, 1);
1086 }
1087
1088 #[test]
1089 fn only_a_rate_limit_buys_the_task_its_attempt_back() {
1090 let mut flaky = task();
1095 flaky.start("20260903-123023-e633".to_owned());
1096 settle(
1097 &mut flaky,
1098 Verdict {
1099 status: RunStatus::Stalled,
1100 left_pr: false,
1101 parked: false,
1102 quota_hit: false,
1103 },
1104 "verdict rests on 1 of 3 judges",
1105 2,
1106 );
1107 assert_eq!(
1108 flaky.attempts, 1,
1109 "flakiness spends an attempt, so `max_attempts` still bounds it"
1110 );
1111 assert!(flaky.status.runnable(), "and it is still worth retrying");
1112
1113 let mut limited = task();
1115 limited.start("20260903-123023-e633".to_owned());
1116 settle(
1117 &mut limited,
1118 Verdict {
1119 status: RunStatus::Stalled,
1120 left_pr: false,
1121 parked: false,
1122 quota_hit: true,
1123 },
1124 "judge-2, judge-3 out of quota",
1125 2,
1126 );
1127 assert_eq!(limited.attempts, 0, "a quota window is refunded");
1128 assert!(limited.status.runnable());
1129
1130 let mut worn = task();
1133 for _ in 0..2 {
1134 worn.release();
1135 }
1136 worn.start("20260903-123023-e633".to_owned());
1137 worn.attempts = 2;
1138 settle(
1139 &mut worn,
1140 Verdict {
1141 status: RunStatus::Stalled,
1142 left_pr: false,
1143 parked: false,
1144 quota_hit: false,
1145 },
1146 "no quorum again",
1147 2,
1148 );
1149 assert_eq!(worn.status, TaskStatus::Held);
1150 assert!(!worn.status.runnable());
1151 }
1152
1153 #[test]
1154 fn a_held_task_is_never_offered_to_the_loop() {
1155 let dir = tempfile::tempdir().unwrap();
1156 let queue = Queue::at(dir.path().to_path_buf());
1157 for (n, priority) in [(1, 0), (2, 5), (3, 5)] {
1158 let mut t = task();
1159 t.id = format!("2026090{n}-000000-000{n}");
1160 t.priority = priority;
1161 queue.put(&mut t).unwrap();
1162 }
1163 let mut held = task();
1164 held.id = "20260909-000000-9999".to_owned();
1165 held.priority = 99;
1166 held.hold();
1167 queue.put(&mut held).unwrap();
1168
1169 let order: Vec<String> = runnable(&queue).into_iter().map(|t| t.id).collect();
1170 assert_eq!(order.len(), 3);
1171 assert!(!order.contains(&held.id));
1172 assert_eq!(
1173 order.first().cloned(),
1174 queue.next_runnable().map(|t| t.id),
1175 "the loop's first candidate is exactly what the queue offers"
1176 );
1177 assert_eq!(
1178 order,
1179 vec![
1180 "20260902-000000-0002".to_owned(),
1181 "20260903-000000-0003".to_owned(),
1182 "20260901-000000-0001".to_owned(),
1183 ],
1184 "priority first, then oldest, so nothing starves"
1185 );
1186 }
1187
1188 #[test]
1189 fn sweep_removes_an_abandoned_lock_and_keeps_a_live_one() {
1190 let dir = tempfile::tempdir().unwrap();
1191 let queue = Queue::at(dir.path().to_path_buf());
1192 let mut old = task();
1193 old.id = "20260101-000000-old0".to_owned();
1194 queue.put(&mut old).unwrap();
1195 let mut fresh = task();
1196 fresh.id = "20260101-000000-new0".to_owned();
1197 queue.put(&mut fresh).unwrap();
1198
1199 let abandoned = queue.claim(&old.id).unwrap();
1200 std::thread::sleep(Duration::from_millis(60));
1201 let live = queue.claim(&fresh.id).unwrap();
1202
1203 let swept = sweep_stale_claims(&queue, Duration::from_millis(50));
1204 assert_eq!(swept, vec![old.id.clone()]);
1205 assert!(
1206 queue.claim(&old.id).is_ok(),
1207 "a swept task is claimable again"
1208 );
1209 assert!(
1210 queue.claim(&fresh.id).is_err(),
1211 "a lock younger than the threshold still protects its task"
1212 );
1213 drop((abandoned, live));
1214 }
1215
1216 #[test]
1217 fn an_already_claimed_task_is_skipped_rather_than_failed() {
1218 let dir = tempfile::tempdir().unwrap();
1219 let queue = Queue::at(dir.path().to_path_buf());
1220 let mut only = task();
1221 queue.put(&mut only).unwrap();
1222
1223 let _elsewhere = queue.claim(&only.id).unwrap();
1224 let candidates = runnable(&queue);
1225 assert_eq!(candidates.len(), 1, "the task is still runnable");
1226 assert!(
1227 queue.claim(&candidates[0].id).is_err(),
1228 "the loop cannot take a claim somebody else holds"
1229 );
1230
1231 let after = queue.get(&only.id).unwrap();
1232 assert_eq!(after.status, TaskStatus::Queued);
1233 assert_eq!(
1234 after.attempts, 0,
1235 "losing the race is not an attempt at the task"
1236 );
1237 assert_eq!(after.last_error, None);
1238 }
1239
1240 #[test]
1241 fn the_status_file_round_trips_and_its_heartbeat_advances() {
1242 let dir = tempfile::tempdir().unwrap();
1243 let path = dir.path().join("daemon.json");
1244
1245 let mut status = Status::new();
1246 status.idle = false;
1247 status.completed = 7;
1248 status.current = Some(Current {
1249 task: "20260902-000000-t111".to_owned(),
1250 run: "20260902-000001-r111".to_owned(),
1251 });
1252 write_status_to(&path, &status).unwrap();
1253 let first: Status = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1254 assert_eq!(first.schema, SCHEMA);
1255 assert_eq!(first.pid, std::process::id());
1256 assert!(!first.idle);
1257 assert_eq!(first.completed, 7);
1258 assert_eq!(first.current, status.current);
1259 assert!(
1260 !path.with_extension("json.tmp").exists(),
1261 "the temp file is renamed, not left behind"
1262 );
1263
1264 std::thread::sleep(Duration::from_millis(5));
1265 status.updated_at = Timestamp::now();
1266 status.polls = 3;
1267 write_status_to(&path, &status).unwrap();
1268 let second: Status =
1269 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1270 assert!(
1271 second.updated_at > first.updated_at,
1272 "a reader can only detect staleness if the heartbeat moves"
1273 );
1274 assert_eq!(
1275 second.started_at, first.started_at,
1276 "the start time is not a heartbeat"
1277 );
1278 assert_eq!(second.polls, 3);
1279 }
1280
1281 #[test]
1282 fn reading_counts_as_running_only_while_its_heartbeat_is_fresh() {
1283 let dir = tempfile::tempdir().unwrap();
1284
1285 assert!(read_status(dir.path()).is_none(), "no file, no daemon");
1286
1287 let mut status = Status::new();
1288 status.updated_at = Timestamp::now() - jiff::SignedDuration::from_secs(60);
1289 write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
1290 let stale = read_status(dir.path()).unwrap();
1291 assert!(
1292 !stale.running(Timestamp::now()),
1293 "a minute without a heartbeat is a dead daemon, not a busy one"
1294 );
1295 assert!(stale.age_secs(Timestamp::now()).is_some_and(|s| s >= 55));
1296
1297 status.updated_at = Timestamp::now();
1298 write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
1299 let fresh = read_status(dir.path()).unwrap();
1300 assert!(fresh.running(Timestamp::now()));
1301 }
1302
1303 #[test]
1304 fn only_a_live_daemon_on_this_very_run_counts_as_working_on_it() {
1305 let dir = tempfile::tempdir().unwrap();
1306 let now = Timestamp::now();
1307 let mine = "20260903-080619-01c2";
1308
1309 assert!(
1310 !is_working_on(dir.path(), mine, now),
1311 "no status file means nobody is working on anything"
1312 );
1313
1314 let mut status = Status::new();
1315 status.current = Some(Current {
1316 task: "20260903-080340-0167".to_owned(),
1317 run: mine.to_owned(),
1318 });
1319 status.updated_at = now;
1320 write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
1321 assert!(is_working_on(dir.path(), mine, now));
1322 assert!(
1323 !is_working_on(dir.path(), "20260903-105039-3cbf", now),
1324 "a daemon busy with one run is not working on another"
1325 );
1326
1327 status.updated_at = now - jiff::SignedDuration::from_secs(600);
1330 write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
1331 assert!(
1332 !is_working_on(dir.path(), mine, now),
1333 "a stale heartbeat is a dead daemon, so its run is a leftover"
1334 );
1335 }
1336
1337 #[test]
1338 fn a_newer_status_file_still_yields_a_reading() {
1339 let dir = tempfile::tempdir().unwrap();
1340 std::fs::write(
1343 dir.path().join("daemon.json"),
1344 serde_json::json!({
1345 "schema": 2,
1346 "updated_at": Timestamp::now().to_string(),
1347 "idle": true,
1348 "surprise": { "nested": [1, 2, 3] },
1349 })
1350 .to_string(),
1351 )
1352 .unwrap();
1353
1354 let reading = read_status(dir.path()).expect("a forward-compatible read");
1355 assert!(reading.running(Timestamp::now()));
1356 assert!(reading.idle);
1357 assert_eq!(reading.current, None);
1358 }
1359
1360 #[test]
1361 fn a_task_without_a_repository_runs_in_the_daemons_default() {
1362 let fallback = Path::new("/default");
1363 let mut blank = task();
1364 blank.repo = PathBuf::new();
1365 assert_eq!(repo_for(&blank, fallback), PathBuf::from("/default"));
1366 let mut dot = task();
1367 dot.repo = PathBuf::from(".");
1368 assert_eq!(repo_for(&dot, fallback), PathBuf::from("/default"));
1369 assert_eq!(
1370 repo_for(&task(), fallback),
1371 PathBuf::from("/repo"),
1372 "a task that names a repository keeps it"
1373 );
1374 }
1375
1376 #[test]
1377 fn merge_overrides_are_parsed_or_refused() {
1378 assert_eq!(merge_mode("none").unwrap(), MergeMode::None);
1379 assert_eq!(merge_mode("local").unwrap(), MergeMode::Local);
1380 assert_eq!(merge_mode("pr").unwrap(), MergeMode::Pr);
1381 assert!(merge_mode("squash").is_err());
1382 }
1383
1384 fn idle_loop(dir: &Path) -> (Opts, Queue, PathBuf) {
1388 let opts = Opts {
1389 poll: Duration::from_secs(30),
1390 ..Opts::default()
1391 };
1392 (
1395 opts,
1396 Queue::at(dir.join("queue")),
1397 dir.join("home").join("daemon.json"),
1398 )
1399 }
1400
1401 #[test]
1402 fn a_stop_is_idempotent_and_once_set_stays_set() {
1403 let stop = Stop::new();
1404 assert!(!stop.stopped());
1405
1406 stop.stop();
1407 assert!(stop.stopped());
1408 stop.stop();
1409 assert!(stop.stopped(), "a second stop is not a toggle");
1410
1411 let shared = stop.clone();
1412 assert!(
1413 shared.stopped(),
1414 "a clone is the same stop; that is how the loop and its caller share one"
1415 );
1416 }
1417
1418 #[test]
1419 fn only_a_stop_with_a_run_in_flight_reads_as_finishing() {
1420 let stop = Stop::new();
1421 stop.busy(true);
1422 assert!(
1423 !stop.finishing(),
1424 "a busy loop nobody has asked to stop is just running"
1425 );
1426
1427 stop.stop();
1428 assert!(
1429 stop.finishing(),
1430 "a stop asked for mid-run has not landed until the run is settled"
1431 );
1432
1433 stop.busy(false);
1434 assert!(
1435 !stop.finishing(),
1436 "once the run is settled the stop has landed and there is nothing to finish"
1437 );
1438 }
1439
1440 #[tokio::test]
1441 async fn a_loop_already_asked_to_stop_returns_without_waiting_out_a_poll() {
1442 let dir = tempfile::tempdir().unwrap();
1443 let (opts, queue, status_file) = idle_loop(dir.path());
1444 let stop = Stop::new();
1445 stop.stop();
1446
1447 let began = std::time::Instant::now();
1448 tokio::time::timeout(
1449 Duration::from_secs(2),
1450 drive(&opts, &queue, &status_file, &stop),
1451 )
1452 .await
1453 .expect("a stopped loop must return, not sit out its poll interval")
1454 .expect("the loop's own setup and teardown must not fail");
1455 assert!(
1456 began.elapsed() < opts.poll,
1457 "returned only after {:?}, which is a poll interval, not a stop",
1458 began.elapsed()
1459 );
1460 }
1461
1462 #[tokio::test]
1463 async fn a_stop_while_idle_wakes_the_wait_instead_of_sleeping_it_out() {
1464 let dir = tempfile::tempdir().unwrap();
1465 let (opts, queue, status_file) = idle_loop(dir.path());
1466 let stop = Stop::new();
1467
1468 let asker = {
1471 let stop = stop.clone();
1472 tokio::spawn(async move {
1473 tokio::time::sleep(Duration::from_millis(20)).await;
1474 stop.stop();
1475 })
1476 };
1477
1478 let began = std::time::Instant::now();
1479 tokio::time::timeout(
1480 Duration::from_secs(2),
1481 drive(&opts, &queue, &status_file, &stop),
1482 )
1483 .await
1484 .expect("a stop asked for while idle must wake the wait")
1485 .expect("the loop's own setup and teardown must not fail");
1486 asker.await.unwrap();
1487 assert!(
1488 began.elapsed() < opts.poll,
1489 "returned only after {:?}, so the stop waited on the sleep",
1490 began.elapsed()
1491 );
1492 }
1493
1494 #[tokio::test]
1495 async fn a_stopped_loop_leaves_no_status_file_claiming_it_is_running() {
1496 let dir = tempfile::tempdir().unwrap();
1497 let (opts, queue, status_file) = idle_loop(dir.path());
1498 let home = status_file.parent().unwrap().to_path_buf();
1499 let stop = Stop::new();
1500 stop.stop();
1501
1502 tokio::time::timeout(
1503 Duration::from_secs(2),
1504 drive(&opts, &queue, &status_file, &stop),
1505 )
1506 .await
1507 .expect("a stopped loop must return")
1508 .expect("the loop's own setup and teardown must not fail");
1509
1510 assert!(
1511 home.is_dir(),
1512 "the loop did publish a status file, so its removal is the teardown and not an absence"
1513 );
1514 assert!(
1515 !status_file.exists(),
1516 "a stopped loop clears its status file"
1517 );
1518 assert!(
1519 read_status(&home).is_none(),
1520 "a reader must see no daemon at all, not a heartbeat that merely stopped"
1521 );
1522 }
1523}