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 #[must_use]
310 pub fn busy_now(&self) -> bool {
311 self.busy.load(Ordering::SeqCst)
312 }
313
314 fn busy(&self, running: bool) {
316 self.busy.store(running, Ordering::SeqCst);
317 }
318
319 async fn idle(&self, poll: Duration) {
321 tokio::select! {
322 () = tokio::time::sleep(poll) => {}
323 () = self.wake.notified() => {}
324 }
325 }
326}
327
328#[derive(Debug, Clone, Default, Deserialize)]
335#[serde(default)]
336pub struct Reading {
337 pub schema: u32,
339 pub pid: Option<u32>,
341 pub started_at: Option<Timestamp>,
343 pub updated_at: Option<Timestamp>,
345 pub idle: bool,
347 pub current: Option<Current>,
349 pub completed: u64,
351 pub polls: u64,
353}
354
355impl Reading {
356 #[must_use]
359 pub fn age_secs(&self, now: Timestamp) -> Option<i64> {
360 self.updated_at
361 .map(|at| (now.as_second() - at.as_second()).max(0))
362 }
363
364 #[must_use]
368 pub fn running(&self, now: Timestamp) -> bool {
369 self.age_secs(now).is_some_and(|secs| secs <= STALE_SECS)
370 }
371}
372
373#[must_use]
380pub fn read_status(home: &Path) -> Option<Reading> {
381 let body = std::fs::read_to_string(home.join("daemon.json")).ok()?;
382 serde_json::from_str(&body).ok()
383}
384
385#[must_use]
394pub fn current_work(home: &Path, now: Timestamp) -> Option<Current> {
395 read_status(home)
396 .filter(|reading| reading.running(now))
397 .and_then(|reading| reading.current)
398}
399
400#[must_use]
402pub fn is_working_on(home: &Path, run: &str, now: Timestamp) -> bool {
403 current_work(home, now).is_some_and(|c| c.run == run)
404}
405
406#[must_use]
408pub fn is_working_on_task(home: &Path, task: &str, now: Timestamp) -> bool {
409 current_work(home, now).is_some_and(|c| c.task == task)
410}
411
412pub fn sweep_stale_claims(queue: &Queue, older_than: Duration) -> Vec<String> {
429 let mut swept: Vec<String> = std::fs::read_dir(queue.root())
430 .into_iter()
431 .flatten()
432 .flatten()
433 .map(|e| e.path())
434 .filter(|p| p.extension().is_some_and(|x| x == "lock"))
435 .filter(|p| {
436 p.metadata()
437 .and_then(|m| m.modified())
438 .and_then(|t| t.elapsed().map_err(std::io::Error::other))
439 .is_ok_and(|age| age >= older_than)
440 })
441 .filter(|p| std::fs::remove_file(p).is_ok())
442 .filter_map(|p| {
443 p.file_stem()
444 .and_then(|s| s.to_str())
445 .map(std::borrow::ToOwned::to_owned)
446 })
447 .collect();
448 swept.sort_unstable();
449 swept
450}
451
452#[derive(Debug, Clone, Copy)]
459pub struct Verdict {
460 pub status: RunStatus,
462 pub left_pr: bool,
464 pub quota_hit: bool,
466 pub parked: bool,
468}
469
470pub fn settle(task: &mut Task, verdict: Verdict, detail: &str, max_attempts: usize) {
503 if verdict.parked {
509 task.stall(detail);
510 return;
511 }
512 match verdict.status {
513 RunStatus::Merged | RunStatus::Ready => task.succeed(),
514 RunStatus::Stalled if verdict.quota_hit => task.stall(detail),
515 RunStatus::Stalled | RunStatus::Failed => task.fail(detail, max_attempts),
516 RunStatus::Blocked if verdict.left_pr => task.handed_off(detail),
517 RunStatus::Blocked => task.fail(detail, max_attempts),
518 other => task.fail(
519 format!(
520 "the graph stopped at `{}` without reaching a terminal status: {detail}",
521 label(other)
522 ),
523 max_attempts,
524 ),
525 }
526}
527
528pub async fn serve(opts: Opts) -> Result<()> {
534 serve_until(opts, Stop::new()).await
535}
536
537pub async fn serve_until(opts: Opts, stop: Stop) -> Result<()> {
554 let signal = {
555 let stop = stop.clone();
556 tokio::spawn(async move {
557 if tokio::signal::ctrl_c().await.is_ok() {
558 stop.stop();
559 tracing::info!("shutdown requested; a run in flight will be finished first");
560 }
561 })
562 };
563
564 let outcome = drive(&opts, &Queue::open(), &status_path(), &stop).await;
565
566 signal.abort();
567 outcome
568}
569
570async fn drive(opts: &Opts, queue: &Queue, status_file: &Path, stop: &Stop) -> Result<()> {
579 let swept = sweep_stale_claims(queue, STALE_CLAIM);
580 if !swept.is_empty() {
581 tracing::warn!(
582 "swept {} stale claim(s) left behind by an earlier daemon: {}",
583 swept.len(),
584 swept.join(", ")
585 );
586 }
587
588 let status = Arc::new(Mutex::new(Status::new()));
596 write_status_to(status_file, &lock(&status)).context("publish the daemon status file")?;
597 let beat = tokio::spawn(heartbeat(Arc::clone(&status), status_file.to_path_buf()));
598
599 tracing::info!(
600 "magi serve: queue {} (poll {}s, {} attempts per task, one run at a time)",
601 queue.root().display(),
602 opts.poll.as_secs(),
603 opts.max_attempts
604 );
605
606 let outcome = poll(opts, queue, &status, stop).await;
607
608 beat.abort();
609 clear_status_at(status_file);
610 outcome
611}
612
613async fn heartbeat(status: Arc<Mutex<Status>>, path: PathBuf) {
619 loop {
620 tokio::time::sleep(HEARTBEAT).await;
621 let snapshot = {
622 let mut guard = lock(&status);
623 guard.updated_at = Timestamp::now();
624 guard.clone()
625 };
626 if let Err(e) = write_status_to(&path, &snapshot) {
627 tracing::warn!("could not refresh the daemon status file: {e:#}");
630 }
631 }
632}
633
634async fn poll(opts: &Opts, queue: &Queue, status: &Arc<Mutex<Status>>, stop: &Stop) -> Result<()> {
637 let mut attempted: Vec<String> = Vec::new();
642
643 while !stop.stopped() {
644 lock(status).polls += 1;
645
646 let candidates: Vec<Task> = runnable(queue)
647 .into_iter()
648 .filter(|t| !opts.once || !attempted.contains(&t.id))
649 .collect();
650
651 let mut ran = false;
652 for candidate in candidates {
653 if stop.stopped() {
654 break;
655 }
656 let Ok(_claim) = queue.claim(&candidate.id) else {
661 tracing::debug!("task {} is claimed elsewhere; skipping", candidate.short());
662 continue;
663 };
664 let mut task = match queue.get(&candidate.id) {
667 Ok(t) if t.status.runnable() => t,
668 Ok(_) => continue,
669 Err(e) => {
670 tracing::warn!("could not re-read task {}: {e:#}", candidate.short());
671 continue;
672 }
673 };
674 attempted.push(task.id.clone());
675 lock(status).idle = false;
676 stop.busy(true);
679 attempt(opts, queue, status, stop, &mut task).await;
680 stop.busy(false);
681 {
682 let mut guard = lock(status);
683 guard.current = None;
684 guard.completed += 1;
685 }
686 ran = true;
687 break;
688 }
689
690 if ran {
691 continue;
692 }
693
694 lock(status).idle = true;
695 if opts.once {
696 return Ok(());
697 }
698 stop.idle(opts.poll).await;
699 }
700 Ok(())
701}
702
703async fn attempt(
709 opts: &Opts,
710 queue: &Queue,
711 status: &Arc<Mutex<Status>>,
712 stop: &Stop,
713 task: &mut Task,
714) {
715 let repo = repo_for(task, &opts.repo);
716 tracing::info!(
717 "task {} — {} (repo {})",
718 task.short(),
719 task.title,
720 repo.display()
721 );
722
723 let config = match prepare(&repo, opts) {
724 Ok(c) => c,
725 Err(e) => {
726 task.attempts += 1;
730 task.fail(format!("config: {e:#}"), opts.max_attempts);
731 record(queue, task);
732 return;
733 }
734 };
735
736 let unfinished = task
742 .runs
743 .iter()
744 .rev()
745 .find(|id| {
746 RunState::load(id)
747 .map(|s| !s.status.done())
748 .unwrap_or(false)
749 })
750 .cloned();
751 let started = match &unfinished {
752 Some(id) => {
753 tracing::info!("resuming run {id} rather than competing again");
754 Runner::resume(id)
755 }
756 None => Runner::start(&repo, task.instruction.clone(), config).await,
757 };
758 let mut runner = match started {
759 Ok(r) => r,
760 Err(e) => {
761 task.attempts += 1;
762 task.fail(format!("could not start the run: {e:#}"), opts.max_attempts);
763 record(queue, task);
764 return;
765 }
766 };
767 runner.on_pause(stop.pause());
769
770 let run = runner.state.id.clone();
773 task.start(run.clone());
774 record(queue, task);
775 lock(status).current = Some(Current {
776 task: task.id.clone(),
777 run,
778 });
779
780 let detail = match runner.execute().await {
781 Ok(()) => describe(&runner.state),
782 Err(e) => format!("{e:#}"),
783 };
784 let verdict = Verdict {
785 status: runner.state.status,
786 left_pr: runner.state.pr.is_some(),
789 quota_hit: !runner.state.quota.is_empty(),
791 parked: runner.state.parked,
795 };
796 settle(task, verdict, &detail, opts.max_attempts);
797 record(queue, task);
798 tracing::info!(
799 "task {} is {} after run {} ({})",
800 task.short(),
801 task.status.as_str(),
802 runner.state.short(),
803 label(runner.state.status)
804 );
805}
806
807fn prepare(repo: &Path, opts: &Opts) -> Result<Config> {
809 let (mut config, _layers) = Config::discover(repo, opts.config.as_deref())?;
810 if let Some(mode) = &opts.merge {
811 config.merge.mode = merge_mode(mode)?;
812 }
813 Ok(config)
814}
815
816fn repo_for(task: &Task, fallback: &Path) -> PathBuf {
819 if task.repo.as_os_str().is_empty() || task.repo == Path::new(".") {
820 return fallback.to_path_buf();
821 }
822 task.repo.clone()
823}
824
825fn record(queue: &Queue, task: &mut Task) {
829 if let Err(e) = queue.put(task) {
830 tracing::error!("could not record task {}: {e:#}", task.short());
831 }
832}
833
834fn runnable(queue: &Queue) -> Vec<Task> {
840 let mut tasks: Vec<Task> = queue
841 .list()
842 .into_iter()
843 .filter(|t| t.status.runnable())
844 .collect();
845 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
846 tasks
847}
848
849fn describe(state: &RunState) -> String {
855 let mut detail = if state.status == RunStatus::Stalled {
856 let mut seats: Vec<&str> = state.quota.iter().map(|q| q.seat.as_str()).collect();
857 seats.sort_unstable();
858 seats.dedup();
859 if seats.is_empty() {
860 "the judging panel lost its quorum".to_owned()
861 } else {
862 format!(
863 "the judging panel lost its quorum; quota took out {}",
864 seats.join(", ")
865 )
866 }
867 } else {
868 format!("run ended {}", label(state.status))
869 };
870 if let Some(last) = state.events.last() {
871 detail.push_str(&format!(" ({}: {})", last.node, last.message));
872 }
873 detail.push_str(&format!(" [run {}]", state.id));
874 detail
875}
876
877fn label(status: RunStatus) -> &'static str {
882 status.as_str()
883}
884
885fn merge_mode(mode: &str) -> Result<MergeMode> {
887 match mode {
888 "none" => Ok(MergeMode::None),
889 "local" => Ok(MergeMode::Local),
890 "pr" => Ok(MergeMode::Pr),
891 other => bail!("unknown merge mode `{other}`; expected none, local or pr"),
892 }
893}
894
895fn lock(status: &Mutex<Status>) -> MutexGuard<'_, Status> {
900 status
901 .lock()
902 .unwrap_or_else(std::sync::PoisonError::into_inner)
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908 use crate::queue::{Source, TaskStatus};
909 use pretty_assertions::assert_eq;
910
911 fn task() -> Task {
912 Task::new(
913 "add retries".to_owned(),
914 "add retries".to_owned(),
915 PathBuf::from("/repo"),
916 Source::Human,
917 )
918 }
919
920 #[test]
921 fn every_run_status_settles_the_task_it_came_from() {
922 let table = [
924 (RunStatus::Merged, TaskStatus::Done, 1),
925 (RunStatus::Ready, TaskStatus::Done, 1),
926 (RunStatus::Stalled, TaskStatus::Failed, 0),
927 (RunStatus::Blocked, TaskStatus::Failed, 1),
928 (RunStatus::Failed, TaskStatus::Failed, 1),
929 (RunStatus::Prep, TaskStatus::Failed, 1),
930 (RunStatus::Implementing, TaskStatus::Failed, 1),
931 (RunStatus::Judging, TaskStatus::Failed, 1),
932 (RunStatus::Deliberating, TaskStatus::Failed, 1),
933 (RunStatus::Voting, TaskStatus::Failed, 1),
934 (RunStatus::Reviewing, TaskStatus::Failed, 1),
935 (RunStatus::Gating, TaskStatus::Failed, 1),
936 ];
937 for (run, want, attempts) in table {
938 let mut t = task();
939 t.start("20260902-000000-aaaa".to_owned());
940 settle(
941 &mut t,
942 Verdict {
943 status: run,
944 left_pr: false,
945 parked: false,
946 quota_hit: matches!(run, RunStatus::Stalled),
947 },
948 "why",
949 2,
950 );
951 assert_eq!(t.status, want, "task status after {}", label(run));
952 assert_eq!(t.attempts, attempts, "attempts after {}", label(run));
953 }
954 }
955
956 #[test]
957 fn a_quota_stall_costs_the_task_no_attempt_but_a_block_does() {
958 let mut stalled = task();
959 stalled.start("20260902-000000-aaaa".to_owned());
960 settle(
961 &mut stalled,
962 Verdict {
963 status: RunStatus::Stalled,
964 left_pr: false,
965 parked: false,
966 quota_hit: true,
967 },
968 "quota",
969 1,
970 );
971 assert_eq!(stalled.attempts, 0);
972 assert!(
973 stalled.status.runnable(),
974 "a machine problem must leave the task in line"
975 );
976
977 let mut blocked = task();
978 blocked.start("20260902-000000-aaaa".to_owned());
979 settle(
980 &mut blocked,
981 Verdict {
982 status: RunStatus::Blocked,
983 left_pr: false,
984 parked: false,
985 quota_hit: false,
986 },
987 "findings open",
988 1,
989 );
990 assert_eq!(blocked.attempts, 1);
991 assert_eq!(
992 blocked.status,
993 TaskStatus::Held,
994 "the last attempt hands the task to a human"
995 );
996 }
997
998 #[test]
999 fn a_run_that_opened_a_pull_request_is_never_re_competed() {
1000 let mut delivered = task();
1003 delivered.start("20260903-080619-01c2".to_owned());
1004 settle(
1005 &mut delivered,
1006 Verdict {
1007 status: RunStatus::Blocked,
1008 left_pr: true,
1009 parked: false,
1010 quota_hit: false,
1011 },
1012 "no check status",
1013 4,
1014 );
1015 assert_eq!(
1016 delivered.status,
1017 TaskStatus::Held,
1018 "a pull request waiting on CI or a person is not a retryable failure"
1019 );
1020 assert!(
1021 !delivered.status.runnable(),
1022 "the loop must not pick this task up again"
1023 );
1024 assert_eq!(
1025 delivered.last_error.as_deref(),
1026 Some("no check status"),
1027 "the operator needs to be told what the gate was waiting for"
1028 );
1029
1030 let mut empty_handed = task();
1033 empty_handed.start("20260903-080619-01c2".to_owned());
1034 settle(
1035 &mut empty_handed,
1036 Verdict {
1037 status: RunStatus::Blocked,
1038 left_pr: false,
1039 parked: false,
1040 quota_hit: false,
1041 },
1042 "findings open",
1043 4,
1044 );
1045 assert_eq!(empty_handed.status, TaskStatus::Failed);
1046 assert!(empty_handed.status.runnable());
1047 }
1048
1049 #[test]
1050 fn parking_costs_the_task_no_attempt_and_leaves_it_in_line() {
1051 let mut parked = task();
1056 parked.start("20260903-183634-2d98".to_owned());
1057 settle(
1058 &mut parked,
1059 Verdict {
1060 status: RunStatus::Implementing,
1061 left_pr: false,
1062 quota_hit: false,
1063 parked: true,
1064 },
1065 "parked after `implementing`",
1066 2,
1067 );
1068 assert_eq!(parked.attempts, 0, "a park is refunded");
1069 assert!(
1070 parked.status.runnable(),
1071 "and the task stays in line so the next loop resumes its run"
1072 );
1073 assert_eq!(
1074 parked.last_error.as_deref(),
1075 Some("parked after `implementing`"),
1076 "the card says where it stopped"
1077 );
1078
1079 let mut broken = task();
1083 broken.start("20260903-183634-2d98".to_owned());
1084 settle(
1085 &mut broken,
1086 Verdict {
1087 status: RunStatus::Implementing,
1088 left_pr: false,
1089 quota_hit: false,
1090 parked: false,
1091 },
1092 "returned mid-flight",
1093 2,
1094 );
1095 assert_eq!(broken.attempts, 1);
1096 }
1097
1098 #[test]
1099 fn only_a_rate_limit_buys_the_task_its_attempt_back() {
1100 let mut flaky = task();
1105 flaky.start("20260903-123023-e633".to_owned());
1106 settle(
1107 &mut flaky,
1108 Verdict {
1109 status: RunStatus::Stalled,
1110 left_pr: false,
1111 parked: false,
1112 quota_hit: false,
1113 },
1114 "verdict rests on 1 of 3 judges",
1115 2,
1116 );
1117 assert_eq!(
1118 flaky.attempts, 1,
1119 "flakiness spends an attempt, so `max_attempts` still bounds it"
1120 );
1121 assert!(flaky.status.runnable(), "and it is still worth retrying");
1122
1123 let mut limited = task();
1125 limited.start("20260903-123023-e633".to_owned());
1126 settle(
1127 &mut limited,
1128 Verdict {
1129 status: RunStatus::Stalled,
1130 left_pr: false,
1131 parked: false,
1132 quota_hit: true,
1133 },
1134 "judge-2, judge-3 out of quota",
1135 2,
1136 );
1137 assert_eq!(limited.attempts, 0, "a quota window is refunded");
1138 assert!(limited.status.runnable());
1139
1140 let mut worn = task();
1143 for _ in 0..2 {
1144 worn.release();
1145 }
1146 worn.start("20260903-123023-e633".to_owned());
1147 worn.attempts = 2;
1148 settle(
1149 &mut worn,
1150 Verdict {
1151 status: RunStatus::Stalled,
1152 left_pr: false,
1153 parked: false,
1154 quota_hit: false,
1155 },
1156 "no quorum again",
1157 2,
1158 );
1159 assert_eq!(worn.status, TaskStatus::Held);
1160 assert!(!worn.status.runnable());
1161 }
1162
1163 #[test]
1164 fn a_held_task_is_never_offered_to_the_loop() {
1165 let dir = tempfile::tempdir().unwrap();
1166 let queue = Queue::at(dir.path().to_path_buf());
1167 for (n, priority) in [(1, 0), (2, 5), (3, 5)] {
1168 let mut t = task();
1169 t.id = format!("2026090{n}-000000-000{n}");
1170 t.priority = priority;
1171 queue.put(&mut t).unwrap();
1172 }
1173 let mut held = task();
1174 held.id = "20260909-000000-9999".to_owned();
1175 held.priority = 99;
1176 held.hold();
1177 queue.put(&mut held).unwrap();
1178
1179 let order: Vec<String> = runnable(&queue).into_iter().map(|t| t.id).collect();
1180 assert_eq!(order.len(), 3);
1181 assert!(!order.contains(&held.id));
1182 assert_eq!(
1183 order.first().cloned(),
1184 queue.next_runnable().map(|t| t.id),
1185 "the loop's first candidate is exactly what the queue offers"
1186 );
1187 assert_eq!(
1188 order,
1189 vec![
1190 "20260902-000000-0002".to_owned(),
1191 "20260903-000000-0003".to_owned(),
1192 "20260901-000000-0001".to_owned(),
1193 ],
1194 "priority first, then oldest, so nothing starves"
1195 );
1196 }
1197
1198 #[test]
1199 fn sweep_removes_an_abandoned_lock_and_keeps_a_live_one() {
1200 let dir = tempfile::tempdir().unwrap();
1201 let queue = Queue::at(dir.path().to_path_buf());
1202 let mut old = task();
1203 old.id = "20260101-000000-old0".to_owned();
1204 queue.put(&mut old).unwrap();
1205 let mut fresh = task();
1206 fresh.id = "20260101-000000-new0".to_owned();
1207 queue.put(&mut fresh).unwrap();
1208
1209 let abandoned = queue.claim(&old.id).unwrap();
1210 std::thread::sleep(Duration::from_millis(60));
1211 let live = queue.claim(&fresh.id).unwrap();
1212
1213 let swept = sweep_stale_claims(&queue, Duration::from_millis(50));
1214 assert_eq!(swept, vec![old.id.clone()]);
1215 assert!(
1216 queue.claim(&old.id).is_ok(),
1217 "a swept task is claimable again"
1218 );
1219 assert!(
1220 queue.claim(&fresh.id).is_err(),
1221 "a lock younger than the threshold still protects its task"
1222 );
1223 drop((abandoned, live));
1224 }
1225
1226 #[test]
1227 fn an_already_claimed_task_is_skipped_rather_than_failed() {
1228 let dir = tempfile::tempdir().unwrap();
1229 let queue = Queue::at(dir.path().to_path_buf());
1230 let mut only = task();
1231 queue.put(&mut only).unwrap();
1232
1233 let _elsewhere = queue.claim(&only.id).unwrap();
1234 let candidates = runnable(&queue);
1235 assert_eq!(candidates.len(), 1, "the task is still runnable");
1236 assert!(
1237 queue.claim(&candidates[0].id).is_err(),
1238 "the loop cannot take a claim somebody else holds"
1239 );
1240
1241 let after = queue.get(&only.id).unwrap();
1242 assert_eq!(after.status, TaskStatus::Queued);
1243 assert_eq!(
1244 after.attempts, 0,
1245 "losing the race is not an attempt at the task"
1246 );
1247 assert_eq!(after.last_error, None);
1248 }
1249
1250 #[test]
1251 fn the_status_file_round_trips_and_its_heartbeat_advances() {
1252 let dir = tempfile::tempdir().unwrap();
1253 let path = dir.path().join("daemon.json");
1254
1255 let mut status = Status::new();
1256 status.idle = false;
1257 status.completed = 7;
1258 status.current = Some(Current {
1259 task: "20260902-000000-t111".to_owned(),
1260 run: "20260902-000001-r111".to_owned(),
1261 });
1262 write_status_to(&path, &status).unwrap();
1263 let first: Status = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1264 assert_eq!(first.schema, SCHEMA);
1265 assert_eq!(first.pid, std::process::id());
1266 assert!(!first.idle);
1267 assert_eq!(first.completed, 7);
1268 assert_eq!(first.current, status.current);
1269 assert!(
1270 !path.with_extension("json.tmp").exists(),
1271 "the temp file is renamed, not left behind"
1272 );
1273
1274 std::thread::sleep(Duration::from_millis(5));
1275 status.updated_at = Timestamp::now();
1276 status.polls = 3;
1277 write_status_to(&path, &status).unwrap();
1278 let second: Status =
1279 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1280 assert!(
1281 second.updated_at > first.updated_at,
1282 "a reader can only detect staleness if the heartbeat moves"
1283 );
1284 assert_eq!(
1285 second.started_at, first.started_at,
1286 "the start time is not a heartbeat"
1287 );
1288 assert_eq!(second.polls, 3);
1289 }
1290
1291 #[test]
1292 fn reading_counts_as_running_only_while_its_heartbeat_is_fresh() {
1293 let dir = tempfile::tempdir().unwrap();
1294
1295 assert!(read_status(dir.path()).is_none(), "no file, no daemon");
1296
1297 let mut status = Status::new();
1298 status.updated_at = Timestamp::now() - jiff::SignedDuration::from_secs(60);
1299 write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
1300 let stale = read_status(dir.path()).unwrap();
1301 assert!(
1302 !stale.running(Timestamp::now()),
1303 "a minute without a heartbeat is a dead daemon, not a busy one"
1304 );
1305 assert!(stale.age_secs(Timestamp::now()).is_some_and(|s| s >= 55));
1306
1307 status.updated_at = Timestamp::now();
1308 write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
1309 let fresh = read_status(dir.path()).unwrap();
1310 assert!(fresh.running(Timestamp::now()));
1311 }
1312
1313 #[test]
1314 fn only_a_live_daemon_on_this_very_run_counts_as_working_on_it() {
1315 let dir = tempfile::tempdir().unwrap();
1316 let now = Timestamp::now();
1317 let mine = "20260903-080619-01c2";
1318
1319 assert!(
1320 !is_working_on(dir.path(), mine, now),
1321 "no status file means nobody is working on anything"
1322 );
1323
1324 let mut status = Status::new();
1325 status.current = Some(Current {
1326 task: "20260903-080340-0167".to_owned(),
1327 run: mine.to_owned(),
1328 });
1329 status.updated_at = now;
1330 write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
1331 assert!(is_working_on(dir.path(), mine, now));
1332 assert!(
1333 !is_working_on(dir.path(), "20260903-105039-3cbf", now),
1334 "a daemon busy with one run is not working on another"
1335 );
1336
1337 status.updated_at = now - jiff::SignedDuration::from_secs(600);
1340 write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
1341 assert!(
1342 !is_working_on(dir.path(), mine, now),
1343 "a stale heartbeat is a dead daemon, so its run is a leftover"
1344 );
1345 }
1346
1347 #[test]
1348 fn a_newer_status_file_still_yields_a_reading() {
1349 let dir = tempfile::tempdir().unwrap();
1350 std::fs::write(
1353 dir.path().join("daemon.json"),
1354 serde_json::json!({
1355 "schema": 2,
1356 "updated_at": Timestamp::now().to_string(),
1357 "idle": true,
1358 "surprise": { "nested": [1, 2, 3] },
1359 })
1360 .to_string(),
1361 )
1362 .unwrap();
1363
1364 let reading = read_status(dir.path()).expect("a forward-compatible read");
1365 assert!(reading.running(Timestamp::now()));
1366 assert!(reading.idle);
1367 assert_eq!(reading.current, None);
1368 }
1369
1370 #[test]
1371 fn a_task_without_a_repository_runs_in_the_daemons_default() {
1372 let fallback = Path::new("/default");
1373 let mut blank = task();
1374 blank.repo = PathBuf::new();
1375 assert_eq!(repo_for(&blank, fallback), PathBuf::from("/default"));
1376 let mut dot = task();
1377 dot.repo = PathBuf::from(".");
1378 assert_eq!(repo_for(&dot, fallback), PathBuf::from("/default"));
1379 assert_eq!(
1380 repo_for(&task(), fallback),
1381 PathBuf::from("/repo"),
1382 "a task that names a repository keeps it"
1383 );
1384 }
1385
1386 #[test]
1387 fn merge_overrides_are_parsed_or_refused() {
1388 assert_eq!(merge_mode("none").unwrap(), MergeMode::None);
1389 assert_eq!(merge_mode("local").unwrap(), MergeMode::Local);
1390 assert_eq!(merge_mode("pr").unwrap(), MergeMode::Pr);
1391 assert!(merge_mode("squash").is_err());
1392 }
1393
1394 fn idle_loop(dir: &Path) -> (Opts, Queue, PathBuf) {
1398 let opts = Opts {
1399 poll: Duration::from_secs(30),
1400 ..Opts::default()
1401 };
1402 (
1405 opts,
1406 Queue::at(dir.join("queue")),
1407 dir.join("home").join("daemon.json"),
1408 )
1409 }
1410
1411 #[test]
1412 fn a_stop_is_idempotent_and_once_set_stays_set() {
1413 let stop = Stop::new();
1414 assert!(!stop.stopped());
1415
1416 stop.stop();
1417 assert!(stop.stopped());
1418 stop.stop();
1419 assert!(stop.stopped(), "a second stop is not a toggle");
1420
1421 let shared = stop.clone();
1422 assert!(
1423 shared.stopped(),
1424 "a clone is the same stop; that is how the loop and its caller share one"
1425 );
1426 }
1427
1428 #[test]
1429 fn only_a_stop_with_a_run_in_flight_reads_as_finishing() {
1430 let stop = Stop::new();
1431 stop.busy(true);
1432 assert!(
1433 !stop.finishing(),
1434 "a busy loop nobody has asked to stop is just running"
1435 );
1436
1437 stop.stop();
1438 assert!(
1439 stop.finishing(),
1440 "a stop asked for mid-run has not landed until the run is settled"
1441 );
1442
1443 stop.busy(false);
1444 assert!(
1445 !stop.finishing(),
1446 "once the run is settled the stop has landed and there is nothing to finish"
1447 );
1448 }
1449
1450 #[tokio::test]
1451 async fn a_loop_already_asked_to_stop_returns_without_waiting_out_a_poll() {
1452 let dir = tempfile::tempdir().unwrap();
1453 let (opts, queue, status_file) = idle_loop(dir.path());
1454 let stop = Stop::new();
1455 stop.stop();
1456
1457 let began = std::time::Instant::now();
1458 tokio::time::timeout(
1459 Duration::from_secs(2),
1460 drive(&opts, &queue, &status_file, &stop),
1461 )
1462 .await
1463 .expect("a stopped loop must return, not sit out its poll interval")
1464 .expect("the loop's own setup and teardown must not fail");
1465 assert!(
1466 began.elapsed() < opts.poll,
1467 "returned only after {:?}, which is a poll interval, not a stop",
1468 began.elapsed()
1469 );
1470 }
1471
1472 #[tokio::test]
1473 async fn a_stop_while_idle_wakes_the_wait_instead_of_sleeping_it_out() {
1474 let dir = tempfile::tempdir().unwrap();
1475 let (opts, queue, status_file) = idle_loop(dir.path());
1476 let stop = Stop::new();
1477
1478 let asker = {
1481 let stop = stop.clone();
1482 tokio::spawn(async move {
1483 tokio::time::sleep(Duration::from_millis(20)).await;
1484 stop.stop();
1485 })
1486 };
1487
1488 let began = std::time::Instant::now();
1489 tokio::time::timeout(
1490 Duration::from_secs(2),
1491 drive(&opts, &queue, &status_file, &stop),
1492 )
1493 .await
1494 .expect("a stop asked for while idle must wake the wait")
1495 .expect("the loop's own setup and teardown must not fail");
1496 asker.await.unwrap();
1497 assert!(
1498 began.elapsed() < opts.poll,
1499 "returned only after {:?}, so the stop waited on the sleep",
1500 began.elapsed()
1501 );
1502 }
1503
1504 #[tokio::test]
1505 async fn a_stopped_loop_leaves_no_status_file_claiming_it_is_running() {
1506 let dir = tempfile::tempdir().unwrap();
1507 let (opts, queue, status_file) = idle_loop(dir.path());
1508 let home = status_file.parent().unwrap().to_path_buf();
1509 let stop = Stop::new();
1510 stop.stop();
1511
1512 tokio::time::timeout(
1513 Duration::from_secs(2),
1514 drive(&opts, &queue, &status_file, &stop),
1515 )
1516 .await
1517 .expect("a stopped loop must return")
1518 .expect("the loop's own setup and teardown must not fail");
1519
1520 assert!(
1521 home.is_dir(),
1522 "the loop did publish a status file, so its removal is the teardown and not an absence"
1523 );
1524 assert!(
1525 !status_file.exists(),
1526 "a stopped loop clears its status file"
1527 );
1528 assert!(
1529 read_status(&home).is_none(),
1530 "a reader must see no daemon at all, not a heartbeat that merely stopped"
1531 );
1532 }
1533}