1use std::path::{Path, PathBuf};
34
35use anyhow::{Context, Result, bail};
36use jiff::Timestamp;
37use serde::{Deserialize, Serialize};
38
39pub const SCHEMA: u32 = 3;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum HoldSource {
61 Manual,
63 Machine,
65}
66
67impl HoldSource {
68 pub fn label(self) -> &'static str {
70 match self {
71 Self::Manual => "manual",
72 Self::Machine => "machine",
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(tag = "kind", rename_all = "lowercase")]
81pub enum Source {
82 Human,
84 Agent {
87 run: String,
89 node: String,
91 },
92 Issue {
94 number: u64,
96 repo: String,
98 },
99}
100
101impl Source {
102 pub fn label(&self) -> String {
104 match self {
105 Self::Human => "human".to_owned(),
106 Self::Agent { run, node } => format!("{node}@{}", short(run)),
107 Self::Issue { number, .. } => format!("issue #{number}"),
108 }
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "lowercase")]
115pub enum TaskStatus {
116 Queued,
118 Running,
120 Done,
122 Failed,
124 Held,
126 Blocked,
130}
131
132impl TaskStatus {
133 pub fn runnable(self) -> bool {
135 matches!(self, Self::Queued | Self::Failed)
136 }
137
138 pub fn as_str(self) -> &'static str {
140 match self {
141 Self::Queued => "queued",
142 Self::Running => "running",
143 Self::Done => "done",
144 Self::Failed => "failed",
145 Self::Held => "held",
146 Self::Blocked => "blocked",
147 }
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct Task {
155 pub schema: u32,
157 pub id: String,
159 pub title: String,
161 pub instruction: String,
163 pub repo: PathBuf,
165 pub source: Source,
167 #[serde(default)]
169 pub priority: i32,
170 #[serde(default)]
181 pub solo: bool,
182 pub status: TaskStatus,
184 #[serde(default)]
186 pub attempts: usize,
187 #[serde(default)]
189 pub runs: Vec<String>,
190 #[serde(default)]
192 pub last_error: Option<String>,
193 #[serde(default)]
206 pub hold_reason: Option<String>,
207 #[serde(default)]
210 pub hold_source: Option<HoldSource>,
211 #[serde(default)]
223 pub diagnostic: Option<String>,
224 #[serde(default)]
234 pub blocked_by: Vec<String>,
235 #[serde(default)]
238 pub block_reason: Option<String>,
239 #[serde(default)]
250 pub answers: Vec<AnsweredQuestion>,
251 #[serde(default)]
263 pub review_branch: Option<String>,
264 #[serde(default)]
267 pub fresh_start: bool,
268 #[serde(default)]
279 pub interrupt: bool,
280 pub created_at: Timestamp,
282 pub updated_at: Timestamp,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct AnsweredQuestion {
290 pub question: String,
292 pub answer: String,
294}
295
296impl Task {
297 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
299 let now = Timestamp::now();
300 Self {
301 schema: SCHEMA,
302 id: new_id(),
303 title,
304 instruction,
305 repo,
306 source,
307 priority: 0,
308 solo: false,
309 status: TaskStatus::Queued,
310 attempts: 0,
311 runs: Vec::new(),
312 last_error: None,
313 hold_reason: None,
314 hold_source: None,
315 diagnostic: None,
316 blocked_by: Vec::new(),
317 block_reason: None,
318 answers: Vec::new(),
319 review_branch: None,
320 fresh_start: false,
321 interrupt: false,
322 created_at: now,
323 updated_at: now,
324 }
325 }
326
327 pub fn short(&self) -> &str {
329 short(&self.id)
330 }
331
332 pub fn start(&mut self, run: String) {
343 self.status = TaskStatus::Running;
344 self.attempts += 1;
345 self.runs.push(run);
346 self.last_error = None;
347 self.fresh_start = false;
348 self.interrupt = false;
349 }
350
351 pub fn succeed(&mut self) {
361 self.status = TaskStatus::Done;
362 self.last_error = None;
363 self.hold_reason = None;
364 self.hold_source = None;
365 self.diagnostic = None;
366 self.blocked_by.clear();
367 self.block_reason = None;
368 }
369
370 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
381 self.last_error = Some(why.into());
382 self.diagnostic = None;
383 self.status = if self.attempts >= max_attempts {
384 self.hold_source = Some(HoldSource::Machine);
385 TaskStatus::Held
386 } else {
387 TaskStatus::Failed
388 };
389 }
390
391 pub fn stall(&mut self, why: impl Into<String>) {
401 self.last_error = Some(why.into());
402 self.diagnostic = None;
403 self.attempts = self.attempts.saturating_sub(1);
404 self.status = TaskStatus::Failed;
405 }
406
407 pub fn operator_held(&self) -> bool {
413 self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
414 }
415
416 pub fn hold_manual(&mut self, reason: Option<String>) {
426 self.status = TaskStatus::Held;
427 if reason.is_some() {
428 self.hold_reason = reason;
429 }
430 self.hold_source = Some(HoldSource::Manual);
431 self.blocked_by.clear();
432 self.block_reason = None;
433 }
434
435 pub fn hold_machine(&mut self, reason: Option<String>) {
440 self.status = TaskStatus::Held;
441 if reason.is_some() {
442 self.hold_reason = reason;
443 }
444 self.hold_source = Some(HoldSource::Machine);
445 self.blocked_by.clear();
446 self.block_reason = None;
447 }
448
449 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
453 self.status = TaskStatus::Blocked;
454 self.blocked_by = blocked_by;
455 self.block_reason = reason;
456 }
457
458 pub fn unblock(&mut self, resolved_id: &str) {
470 if self.status != TaskStatus::Blocked {
471 return;
472 }
473 self.blocked_by.retain(|id| id != resolved_id);
474 if self.blocked_by.is_empty() {
475 self.status = TaskStatus::Queued;
476 self.block_reason = None;
477 }
478 }
479
480 pub fn record_answer(&mut self, question: String, answer: String) {
485 self.answers.push(AnsweredQuestion { question, answer });
486 }
487
488 pub fn request_review(&mut self, branch: String) {
492 self.release();
493 self.review_branch = Some(branch);
494 }
495
496 pub fn requeue(&mut self) {
499 self.release();
500 self.fresh_start = true;
501 }
502
503 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
512 if self.status == TaskStatus::Running {
513 bail!(
514 "task {} is running; its priority cannot be changed until \
515 this attempt finishes",
516 self.short()
517 );
518 }
519 self.priority = priority;
520 Ok(())
521 }
522
523 pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
538 if interrupt && !self.status.runnable() {
539 bail!(
540 "task {} is {}; only a queued or failed task can be marked \
541 to interrupt",
542 self.short(),
543 self.status.as_str()
544 );
545 }
546 self.interrupt = interrupt;
547 Ok(())
548 }
549
550 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
562 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
563 bail!(
564 "task {} is {}; only a queued or held task's instruction can \
565 be edited",
566 self.short(),
567 self.status.as_str()
568 );
569 }
570 self.title = title;
571 self.instruction = instruction;
572 Ok(())
573 }
574
575 pub fn handed_off(&mut self, why: impl Into<String>) {
587 self.last_error = Some(why.into());
588 self.diagnostic = None;
589 self.status = TaskStatus::Held;
590 self.hold_source = Some(HoldSource::Machine);
591 }
592
593 pub fn release(&mut self) {
597 self.status = TaskStatus::Queued;
598 self.attempts = 0;
599 self.last_error = None;
600 self.hold_reason = None;
603 self.hold_source = None;
604 self.diagnostic = None;
605 self.blocked_by.clear();
610 self.block_reason = None;
611 self.review_branch = None;
612 self.fresh_start = false;
613 }
614}
615
616#[derive(Debug, Clone)]
618pub struct Queue {
619 root: PathBuf,
620}
621
622impl Queue {
623 pub fn open() -> Self {
625 Self::at(crate::run::home().join("queue"))
626 }
627
628 pub fn at(root: PathBuf) -> Self {
631 Self { root }
632 }
633
634 pub fn root(&self) -> &Path {
636 &self.root
637 }
638
639 pub fn path_of(&self, id: &str) -> PathBuf {
641 self.root.join(format!("{id}.json"))
642 }
643
644 pub fn put(&self, task: &mut Task) -> Result<()> {
647 task.updated_at = Timestamp::now();
648 std::fs::create_dir_all(&self.root)
649 .with_context(|| format!("create {}", self.root.display()))?;
650 let body = serde_json::to_string_pretty(task).context("serialize task")?;
651 let path = self.path_of(&task.id);
652 let tmp = path.with_extension("json.tmp");
653 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
654 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
655 Ok(())
656 }
657
658 pub fn get(&self, id: &str) -> Result<Task> {
660 let resolved = self.resolve_id(id)?;
661 read_path(&self.path_of(&resolved))
662 }
663
664 pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
678 let resolved = self.resolve_id(id)?;
679 if in_flight {
680 bail!("task {resolved} is being run by a live daemon right now");
681 }
682 let path = self.path_of(&resolved);
683 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
684 let lock = self.lock_path(&resolved);
685 if let Err(e) = std::fs::remove_file(&lock) {
686 if e.kind() != std::io::ErrorKind::NotFound {
687 return Err(e).with_context(|| format!("remove {}", lock.display()));
688 }
689 }
690 Ok(resolved)
691 }
692
693 fn lock_path(&self, id: &str) -> PathBuf {
696 self.root.join(format!("{id}.lock"))
697 }
698
699 pub fn list(&self) -> Vec<Task> {
712 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
713 .into_iter()
714 .flatten()
715 .flatten()
716 .map(|e| e.path())
717 .filter(|p| p.extension().is_some_and(|x| x == "json"))
718 .filter_map(|p| read_path(&p).ok())
719 .collect();
720 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
721 tasks
722 }
723
724 pub fn next_runnable(&self) -> Option<Task> {
729 let mut runnable: Vec<Task> = self
730 .list()
731 .into_iter()
732 .filter(|t| t.status.runnable())
733 .collect();
734 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
735 runnable.into_iter().next()
736 }
737
738 pub fn claim(&self, id: &str) -> Result<Claim> {
745 std::fs::create_dir_all(&self.root)
746 .with_context(|| format!("create {}", self.root.display()))?;
747 let path = self.lock_path(id);
748 match std::fs::OpenOptions::new()
749 .write(true)
750 .create_new(true)
751 .open(&path)
752 {
753 Ok(mut f) => {
754 use std::io::Write as _;
755 let _ = writeln!(f, "{}", std::process::id());
757 Ok(Claim { path })
758 }
759 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
760 bail!("task {id} is already claimed ({} exists)", path.display())
761 }
762 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
763 }
764 }
765
766 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
768 if self.path_of(prefix).is_file() {
769 return Ok(prefix.to_owned());
770 }
771 let hits: Vec<String> = self
772 .list()
773 .into_iter()
774 .map(|t| t.id)
775 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
776 .collect();
777 match hits.len() {
778 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
779 0 => bail!("no task matches `{prefix}`"),
780 _ => bail!(
781 "`{prefix}` matches {} tasks: {}",
782 hits.len(),
783 hits.join(", ")
784 ),
785 }
786 }
787
788 pub fn revision(&self) -> u64 {
795 use std::hash::{Hash as _, Hasher as _};
796
797 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
798 .into_iter()
799 .flatten()
800 .flatten()
801 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
802 .filter_map(|e| {
803 let name = e.file_name().to_string_lossy().into_owned();
804 let mtime = e
805 .metadata()
806 .ok()?
807 .modified()
808 .ok()?
809 .duration_since(std::time::UNIX_EPOCH)
810 .ok()?
811 .as_millis() as u64;
812 Some((name, mtime))
813 })
814 .collect();
815
816 if entries.is_empty() {
817 return 0;
818 }
819
820 entries.sort_unstable();
821 let mut hasher = std::hash::DefaultHasher::new();
822 for (name, mtime) in &entries {
823 name.hash(&mut hasher);
824 mtime.hash(&mut hasher);
825 }
826 let h = hasher.finish();
827 if h == 0 { 1 } else { h }
828 }
829}
830
831#[derive(Debug)]
833pub struct Claim {
834 path: PathBuf,
835}
836
837impl Drop for Claim {
838 fn drop(&mut self) {
839 let _ = std::fs::remove_file(&self.path);
840 }
841}
842
843pub fn title_from(instruction: &str, max: usize) -> String {
846 let line = instruction
852 .lines()
853 .map(str::trim)
854 .find(|l| !l.is_empty())
855 .unwrap_or("(empty task)")
856 .trim_start_matches(['#', '-', '*', '>', ' '])
857 .trim();
858 if line.is_empty() {
859 return "(empty task)".to_owned();
860 }
861 if line.chars().count() <= max {
862 return line.to_owned();
863 }
864 let head: String = line.chars().take(max.saturating_sub(1)).collect();
865 format!("{head}…")
866}
867
868fn read_path(path: &Path) -> Result<Task> {
869 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
870 let task: Task =
871 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
872 if task.schema > SCHEMA {
878 bail!(
879 "task {} was written by a different magi (schema {}, this build \
880 speaks {SCHEMA})",
881 task.id,
882 task.schema
883 );
884 }
885 Ok(task)
886}
887
888fn short(id: &str) -> &str {
889 id.split('-').next_back().unwrap_or(id)
890}
891
892fn new_id() -> String {
893 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
894 let seed = crate::rng::entropy();
895 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901
902 fn queue() -> (tempfile::TempDir, Queue) {
905 let dir = tempfile::tempdir().unwrap();
906 let q = Queue::at(dir.path().join("queue"));
907 (dir, q)
908 }
909
910 fn task(title: &str) -> Task {
911 Task::new(
912 title.to_owned(),
913 format!("do {title}"),
914 PathBuf::from("."),
915 Source::Human,
916 )
917 }
918
919 #[test]
920 fn a_markdown_heading_is_the_title_not_decoration() {
921 assert_eq!(
926 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
927 "Rework the config loader"
928 );
929 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
930 assert_eq!(title_from("> quoted task", 40), "quoted task");
931 assert_eq!(title_from(" \n\n", 40), "(empty task)");
933 assert_eq!(title_from("###\n", 40), "(empty task)");
934 }
935
936 #[test]
937 fn a_long_title_is_elided_by_characters_not_bytes() {
938 let long = "課題".repeat(30);
940 let title = title_from(&long, 10);
941 assert_eq!(title.chars().count(), 10);
942 assert!(title.ends_with('…'));
943 }
944
945 #[test]
946 fn priority_wins_and_ties_break_oldest_first() {
947 let (_dir, q) = queue();
948 let mut a = task("first");
949 let mut b = task("second");
950 let mut c = task("urgent");
951 a.id = "20260101-000001-aaaa".to_owned();
953 b.id = "20260101-000002-bbbb".to_owned();
954 c.id = "20260101-000003-cccc".to_owned();
955 c.priority = 5;
956 for t in [&mut a, &mut b, &mut c] {
957 q.put(t).unwrap();
958 }
959
960 assert_eq!(q.next_runnable().unwrap().id, c.id);
962 c.hold_machine(None);
963 q.put(&mut c).unwrap();
964 assert_eq!(q.next_runnable().unwrap().id, a.id);
966 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
967 }
968
969 #[test]
970 fn a_blocked_task_never_starves_another_runnable_one() {
971 let (_dir, q) = queue();
972 let mut blocked = task("blocked");
973 blocked.block(vec!["something".to_owned()], None);
974 q.put(&mut blocked).unwrap();
975
976 let mut runnable = task("free to go");
977 q.put(&mut runnable).unwrap();
978
979 let next = q.next_runnable().expect("a runnable task is still offered");
980 assert_eq!(next.id, runnable.id);
981 }
982
983 #[test]
984 fn a_held_task_is_never_offered_to_the_loop() {
985 let (_dir, q) = queue();
986 let mut t = task("held");
987 q.put(&mut t).unwrap();
988 assert!(q.next_runnable().is_some());
989
990 t.hold_machine(None);
991 q.put(&mut t).unwrap();
992 assert!(
993 q.next_runnable().is_none(),
994 "a held task must wait for a human"
995 );
996
997 t.status = TaskStatus::Failed;
999 q.put(&mut t).unwrap();
1000 assert!(q.next_runnable().is_some());
1001 }
1002
1003 #[test]
1004 fn attempts_are_capped_and_then_the_task_is_held() {
1005 let mut t = task("doomed");
1006
1007 t.start("run-1".to_owned());
1008 t.fail("gate red", 2);
1009 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
1010
1011 t.start("run-2".to_owned());
1012 t.fail("gate red", 2);
1013 assert_eq!(
1014 t.status,
1015 TaskStatus::Held,
1016 "out of attempts: stop spending money on it"
1017 );
1018 assert_eq!(t.runs, ["run-1", "run-2"]);
1019 assert_eq!(t.last_error.as_deref(), Some("gate red"));
1020 }
1021
1022 #[test]
1023 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
1024 let mut t = task("stalled by quota");
1025
1026 t.start("run-1".to_owned());
1027 assert_eq!(t.attempts, 1);
1028 t.stall("judge-1, judge-2 out of quota");
1029 assert_eq!(
1030 t.attempts, 0,
1031 "a closed quota window must not spend the task's retry budget"
1032 );
1033 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
1034 assert_eq!(
1035 t.last_error.as_deref(),
1036 Some("judge-1, judge-2 out of quota")
1037 );
1038
1039 for _ in 0..20 {
1042 t.start("run-n".to_owned());
1043 t.stall("still out of quota");
1044 }
1045 t.start("run-real".to_owned());
1046 t.fail("gate red", 2);
1047 assert_eq!(
1048 t.status,
1049 TaskStatus::Failed,
1050 "the first attempt that was really judged is attempt one"
1051 );
1052 }
1053
1054 #[test]
1055 fn releasing_a_held_task_gives_it_a_real_second_chance() {
1056 let mut t = task("retry me");
1057 t.start("run-1".to_owned());
1058 t.fail("gate red", 1);
1059 assert_eq!(t.status, TaskStatus::Held);
1060
1061 t.release();
1062 assert_eq!(t.status, TaskStatus::Queued);
1063 assert_eq!(t.attempts, 0);
1066 assert!(t.last_error.is_none());
1067 assert_eq!(
1068 t.runs.len(),
1069 1,
1070 "history is kept: attempts reset, evidence does not"
1071 );
1072 }
1073
1074 #[test]
1075 fn a_hold_reason_survives_and_a_release_clears_it() {
1076 let mut t = task("waiting on something else");
1077 t.hold_manual(Some(
1078 "waiting for 20260101-000000-aaaa to land first".to_owned(),
1079 ));
1080 assert_eq!(t.status, TaskStatus::Held);
1081 assert_eq!(
1082 t.hold_reason.as_deref(),
1083 Some("waiting for 20260101-000000-aaaa to land first")
1084 );
1085
1086 t.hold_manual(None);
1088 assert_eq!(
1089 t.hold_reason.as_deref(),
1090 Some("waiting for 20260101-000000-aaaa to land first"),
1091 "a bare re-hold keeps whatever a human already wrote down"
1092 );
1093
1094 let mut plain = task("no reason given");
1096 plain.hold_manual(None);
1097 assert_eq!(plain.status, TaskStatus::Held);
1098 assert!(plain.hold_reason.is_none());
1099
1100 t.release();
1101 assert_eq!(t.status, TaskStatus::Queued);
1102 assert!(
1103 t.hold_reason.is_none(),
1104 "a stale reason must not greet the next person who holds this task"
1105 );
1106 }
1107
1108 #[test]
1109 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
1110 let mut t = task("landed by hand while held");
1115 t.hold_manual(Some("waiting on 3ed9".to_owned()));
1116 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
1117
1118 t.succeed();
1119 assert_eq!(t.status, TaskStatus::Done);
1120 assert!(
1121 t.hold_reason.is_none(),
1122 "a done task cannot still be waiting on something"
1123 );
1124 }
1125
1126 #[test]
1127 fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
1128 let mut held = task("held straight out of blocked");
1135 held.block(
1136 vec!["20260101-000000-dead".to_owned()],
1137 Some("waiting on the migration script".to_owned()),
1138 );
1139 assert_eq!(held.status, TaskStatus::Blocked);
1140
1141 held.hold_manual(None);
1142 assert_eq!(held.status, TaskStatus::Held);
1143 assert!(
1144 held.blocked_by.is_empty(),
1145 "hold overrides the wait, same as release"
1146 );
1147 assert!(held.block_reason.is_none());
1148
1149 let mut done = task("closed straight out of blocked");
1150 done.block(
1151 vec!["20260101-000000-dead".to_owned()],
1152 Some("waiting on the migration script".to_owned()),
1153 );
1154 done.succeed();
1155 assert_eq!(done.status, TaskStatus::Done);
1156 assert!(
1157 done.blocked_by.is_empty(),
1158 "a done task cannot still be waiting on a dependency"
1159 );
1160 assert!(done.block_reason.is_none());
1161 }
1162
1163 #[test]
1164 fn a_blocked_task_is_never_offered_to_the_loop() {
1165 let mut t = task("blocked");
1166 assert!(t.status.runnable());
1167 t.block(
1168 vec!["dep-id".to_owned()],
1169 Some("waits on dep-id".to_owned()),
1170 );
1171 assert_eq!(t.status, TaskStatus::Blocked);
1172 assert!(!t.status.runnable());
1173 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
1174 }
1175
1176 #[test]
1177 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
1178 let mut t = task("blocked on two");
1179 t.block(
1180 vec!["a".to_owned(), "b".to_owned()],
1181 Some("waits on a and b".to_owned()),
1182 );
1183
1184 t.unblock("a");
1185 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
1186 assert_eq!(t.blocked_by, ["b"]);
1187
1188 t.unblock("b");
1189 assert_eq!(t.status, TaskStatus::Queued);
1190 assert!(t.blocked_by.is_empty());
1191 assert!(t.block_reason.is_none());
1192 }
1193
1194 #[test]
1195 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
1196 let mut t = task("never blocked");
1197 t.unblock("whatever");
1198 assert_eq!(t.status, TaskStatus::Queued);
1199 }
1200
1201 #[test]
1202 fn answering_a_question_is_recorded_and_survives_a_release() {
1203 let mut t = task("asked something");
1204 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
1205 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
1206 t.unblock("q1");
1207 assert_eq!(t.status, TaskStatus::Queued);
1208 assert_eq!(t.answers.len(), 1);
1209 assert_eq!(t.answers[0].answer, "SQLite");
1210
1211 t.release();
1215 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
1216 }
1217
1218 #[test]
1219 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
1220 let mut t = task("blocked run with a surviving branch");
1221 t.start("run-1".to_owned());
1222 t.fail("blocked with major findings", 5);
1223 assert_eq!(t.status, TaskStatus::Failed);
1224
1225 t.request_review("magi/eba2/A".to_owned());
1226 assert_eq!(t.status, TaskStatus::Queued);
1227 assert_eq!(t.attempts, 0);
1228 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
1229
1230 t.release();
1232 assert!(t.review_branch.is_none());
1233 }
1234
1235 #[test]
1236 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
1237 let mut t = task("retry");
1238 t.start("run-1".to_owned());
1239 t.requeue();
1240 assert!(t.fresh_start);
1241
1242 t.release();
1243 assert!(!t.fresh_start);
1244 }
1245
1246 #[test]
1247 fn priority_can_be_changed_while_queued_but_not_while_running() {
1248 let mut t = task("reprioritise me");
1249 t.set_priority(5).unwrap();
1250 assert_eq!(t.priority, 5);
1251
1252 t.start("run-1".to_owned());
1253 let err = t.set_priority(9).unwrap_err().to_string();
1254 assert!(err.contains("running"), "{err}");
1255 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
1256 }
1257
1258 #[test]
1259 fn interrupt_can_be_marked_while_queued_but_not_while_running() {
1260 let mut t = task("interrupt me");
1261 assert!(!t.interrupt, "off unless asked, same as any other task");
1262
1263 t.set_interrupt(true).unwrap();
1264 assert!(t.interrupt);
1265
1266 t.start("run-1".to_owned());
1267 assert!(
1268 !t.interrupt,
1269 "the mark is one-shot: dispatching the task fulfils it, \
1270 whatever the run that follows ends up doing"
1271 );
1272 let err = t.set_interrupt(true).unwrap_err().to_string();
1273 assert!(err.contains("running"), "{err}");
1274 t.set_interrupt(false).unwrap();
1277 assert!(!t.interrupt);
1278 }
1279
1280 #[test]
1284 fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
1285 let mut t = task("interrupt me");
1286 t.set_interrupt(true).unwrap();
1287 t.start("run-1".to_owned());
1288 t.fail("mock failure", 5);
1289 assert_eq!(t.status, TaskStatus::Failed);
1290 assert!(
1291 !t.interrupt,
1292 "one attempt already spent the mark; a retry is an ordinary \
1293 requeue, not a fresh interrupt request"
1294 );
1295 }
1296
1297 #[test]
1298 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
1299 let (_dir, q) = queue();
1300 let mut a = task("first filed");
1301 let mut b = task("second filed");
1302 a.id = "20260101-000001-aaaa".to_owned();
1303 b.id = "20260101-000002-bbbb".to_owned();
1304 q.put(&mut a).unwrap();
1305 q.put(&mut b).unwrap();
1306
1307 assert_eq!(
1308 q.next_runnable().unwrap().id,
1309 a.id,
1310 "with equal priority the older task goes first, so a burst of \
1311 new work cannot starve it"
1312 );
1313 assert_eq!(
1314 q.list()[0].id,
1315 b.id,
1316 "but the list an operator reads is newest first, the same as \
1317 before priority existed - a's turn to run does not make it the \
1318 newest task"
1319 );
1320
1321 let mut a = q.get(&a.id).unwrap();
1322 a.set_priority(10).unwrap();
1323 q.put(&mut a).unwrap();
1324
1325 assert_eq!(
1326 q.next_runnable().unwrap().id,
1327 a.id,
1328 "a raised priority must be reflected the moment it is saved"
1329 );
1330 assert_eq!(
1334 q.list()[0].id,
1335 a.id,
1336 "the raised task must sort first in the list an operator reads, \
1337 not only in next_runnable's own ordering"
1338 );
1339 }
1340
1341 #[test]
1342 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
1343 let mut t = Task::new(
1344 "old title".to_owned(),
1345 "old instruction".to_owned(),
1346 PathBuf::from("/repo"),
1347 Source::Agent {
1348 run: "20260101-000000-beef".to_owned(),
1349 node: "implement".to_owned(),
1350 },
1351 );
1352 let id = t.id.clone();
1353 let created_at = t.created_at;
1354 t.runs.push("20260101-000000-beef".to_owned());
1355
1356 t.edit("new title".to_owned(), "new instruction".to_owned())
1357 .unwrap();
1358
1359 assert_eq!(t.title, "new title");
1360 assert_eq!(t.instruction, "new instruction");
1361 assert_eq!(t.id, id, "editing must not mint a new id");
1362 assert_eq!(t.created_at, created_at);
1363 assert_eq!(
1364 t.source,
1365 Source::Agent {
1366 run: "20260101-000000-beef".to_owned(),
1367 node: "implement".to_owned(),
1368 },
1369 "editing must not turn agent attribution into human"
1370 );
1371 assert_eq!(t.runs, ["20260101-000000-beef"]);
1372 }
1373
1374 #[test]
1375 fn editing_is_refused_once_a_task_is_running_or_finished() {
1376 let mut running = task("in flight");
1377 running.start("run-1".to_owned());
1378 let err = running
1379 .edit("x".to_owned(), "y".to_owned())
1380 .unwrap_err()
1381 .to_string();
1382 assert!(err.contains("running"), "{err}");
1383
1384 let mut done = task("finished");
1385 done.succeed();
1386 let err = done
1387 .edit("x".to_owned(), "y".to_owned())
1388 .unwrap_err()
1389 .to_string();
1390 assert!(err.contains("done"), "{err}");
1391
1392 let mut queued = task("waiting");
1394 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
1395 let mut held = task("parked");
1396 held.hold_machine(None);
1397 held.edit("x".to_owned(), "y".to_owned()).unwrap();
1398 }
1399
1400 #[test]
1401 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
1402 let (_dir, q) = queue();
1403 let path = q.path_of("20260101-000000-aaaa");
1404 std::fs::create_dir_all(q.root()).unwrap();
1405 std::fs::write(
1406 &path,
1407 serde_json::json!({
1408 "schema": SCHEMA,
1409 "id": "20260101-000000-aaaa",
1410 "title": "from before hold reasons existed",
1411 "instruction": "from before hold reasons existed",
1412 "repo": ".",
1413 "source": { "kind": "human" },
1414 "status": "held",
1415 "created_at": Timestamp::now().to_string(),
1416 "updated_at": Timestamp::now().to_string(),
1417 })
1418 .to_string(),
1419 )
1420 .unwrap();
1421
1422 let task = q.get("20260101-000000-aaaa").expect("must still read");
1423 assert!(task.hold_reason.is_none());
1424 assert!(task.operator_held());
1425 }
1426
1427 #[test]
1428 fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
1429 let (_dir, q) = queue();
1430 let path = q.path_of("20260101-000000-bbbb");
1431 std::fs::create_dir_all(q.root()).unwrap();
1432 std::fs::write(
1433 &path,
1434 serde_json::json!({
1435 "schema": 2,
1436 "id": "20260101-000000-bbbb",
1437 "title": "old manual recovery",
1438 "instruction": "old manual recovery",
1439 "repo": ".",
1440 "source": { "kind": "human" },
1441 "status": "held",
1442 "hold_reason": "active manual recovery run20260912-224242-daf5",
1443 "created_at": Timestamp::now().to_string(),
1444 "updated_at": Timestamp::now().to_string(),
1445 })
1446 .to_string(),
1447 )
1448 .unwrap();
1449
1450 let task = q.get("20260101-000000-bbbb").expect("must still read");
1451 assert_eq!(task.hold_source, None);
1452 assert!(task.operator_held());
1453 }
1454
1455 #[test]
1456 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
1457 let (_dir, q) = queue();
1458 let path = q.path_of("20260101-000000-aaaa");
1459 std::fs::create_dir_all(q.root()).unwrap();
1460 std::fs::write(
1461 &path,
1462 serde_json::json!({
1463 "schema": SCHEMA,
1464 "id": "20260101-000000-aaaa",
1465 "title": "from before diagnostics existed",
1466 "instruction": "from before diagnostics existed",
1467 "repo": ".",
1468 "source": { "kind": "human" },
1469 "status": "held",
1470 "created_at": Timestamp::now().to_string(),
1471 "updated_at": Timestamp::now().to_string(),
1472 })
1473 .to_string(),
1474 )
1475 .unwrap();
1476
1477 let task = q.get("20260101-000000-aaaa").expect("must still read");
1478 assert!(task.diagnostic.is_none());
1479 }
1480
1481 #[test]
1482 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
1483 let (_dir, q) = queue();
1487 let path = q.path_of("20260101-000000-aaaa");
1488 std::fs::create_dir_all(q.root()).unwrap();
1489 std::fs::write(
1490 &path,
1491 serde_json::json!({
1492 "schema": 1,
1493 "id": "20260101-000000-aaaa",
1494 "title": "from before blocking existed",
1495 "instruction": "from before blocking existed",
1496 "repo": ".",
1497 "source": { "kind": "human" },
1498 "status": "queued",
1499 "created_at": Timestamp::now().to_string(),
1500 "updated_at": Timestamp::now().to_string(),
1501 })
1502 .to_string(),
1503 )
1504 .unwrap();
1505
1506 let task = q.get("20260101-000000-aaaa").expect("must still read");
1507 assert!(task.blocked_by.is_empty());
1508 assert!(task.block_reason.is_none());
1509 assert!(task.answers.is_empty());
1510 assert!(task.review_branch.is_none());
1511 }
1512
1513 #[test]
1514 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
1515 let mut held = task("diagnosed");
1520 held.start("run-1".to_owned());
1521 held.fail("gate red", 1);
1522 held.diagnostic = Some("cargo test failed: ...".to_owned());
1523 assert_eq!(held.status, TaskStatus::Held);
1524
1525 held.release();
1526 assert!(held.diagnostic.is_none());
1527
1528 held.diagnostic = Some("cargo test failed: ...".to_owned());
1529 held.succeed();
1530 assert!(held.diagnostic.is_none());
1531 }
1532
1533 #[test]
1534 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
1535 let mut t = task("retried");
1536 t.start("run-1".to_owned());
1537 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
1538 t.fail("unrelated config error", 5);
1539 assert_eq!(t.status, TaskStatus::Failed);
1540 assert!(
1541 t.diagnostic.is_none(),
1542 "fail() must not let an old diagnostic outlive the run that produced it"
1543 );
1544 }
1545
1546 #[test]
1547 fn a_claim_is_exclusive_and_releases_on_drop() {
1548 let (_dir, q) = queue();
1549 let mut t = task("contended");
1550 q.put(&mut t).unwrap();
1551
1552 let held = q.claim(&t.id).unwrap();
1553 assert!(
1554 q.claim(&t.id).is_err(),
1555 "two daemons must not drive one task into two runs"
1556 );
1557 drop(held);
1558 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
1559 }
1560
1561 #[test]
1562 fn a_round_trip_survives_disk() {
1563 let (_dir, q) = queue();
1564 let mut t = Task::new(
1565 "titled".to_owned(),
1566 "body".to_owned(),
1567 PathBuf::from("/repo"),
1568 Source::Agent {
1569 run: "20260101-000000-beef".to_owned(),
1570 node: "implement".to_owned(),
1571 },
1572 );
1573 t.priority = 3;
1574 q.put(&mut t).unwrap();
1575
1576 let back = q.get(&t.id).unwrap();
1577 assert_eq!(back.id, t.id);
1578 assert_eq!(back.priority, 3);
1579 assert_eq!(back.source.label(), "implement@beef");
1580 assert_eq!(q.get(t.short()).unwrap().id, t.id);
1582 }
1583
1584 #[test]
1585 fn an_unreadable_task_does_not_take_the_queue_down() {
1586 let (_dir, q) = queue();
1587 let mut t = task("fine");
1588 q.put(&mut t).unwrap();
1589 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
1590
1591 let listed = q.list();
1592 assert_eq!(listed.len(), 1, "the readable task still lists");
1593 assert_eq!(listed[0].id, t.id);
1594 }
1595
1596 #[test]
1597 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
1598 let (_dir, q) = queue();
1599 let path = q.path_of("20260101-000000-aaaa");
1600 std::fs::create_dir_all(q.root()).unwrap();
1601 std::fs::write(
1602 &path,
1603 serde_json::json!({
1604 "schema": SCHEMA,
1605 "id": "20260101-000000-aaaa",
1606 "title": "from before solo existed",
1607 "instruction": "from before solo existed",
1608 "repo": ".",
1609 "source": { "kind": "human" },
1610 "status": "queued",
1611 "created_at": Timestamp::now().to_string(),
1612 "updated_at": Timestamp::now().to_string(),
1613 })
1614 .to_string(),
1615 )
1616 .unwrap();
1617
1618 let task = q.get("20260101-000000-aaaa").expect("must still read");
1619 assert!(!task.solo, "a queue file with no `solo` field means false");
1620 }
1621
1622 #[test]
1623 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
1624 let (_dir, q) = queue();
1625 let mut t = task("from the future");
1626 q.put(&mut t).unwrap();
1627 let path = q.path_of(&t.id);
1628 let body = std::fs::read_to_string(&path)
1629 .unwrap()
1630 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
1631 std::fs::write(&path, body).unwrap();
1632
1633 let err = q.get(&t.id).unwrap_err().to_string();
1634 assert!(err.contains("schema 99"), "{err}");
1635 }
1636
1637 #[test]
1638 fn revision_moves_when_the_queue_changes() {
1639 let (_dir, q) = queue();
1640 assert_eq!(q.revision(), 0, "an empty queue has no revision");
1641 let mut t = task("first");
1642 q.put(&mut t).unwrap();
1643 assert!(q.revision() > 0, "a written task moves the revision");
1644 }
1645
1646 #[test]
1647 fn revision_moves_when_deleting_an_older_task() {
1648 let (_dir, q) = queue();
1649 let mut t1 = task("older");
1650 q.put(&mut t1).unwrap();
1651 std::thread::sleep(std::time::Duration::from_millis(10));
1653 let mut t2 = task("newer");
1654 q.put(&mut t2).unwrap();
1655
1656 let rev_before = q.revision();
1657 q.remove(&t1.id, false).unwrap();
1658 let rev_after = q.revision();
1659
1660 assert_ne!(
1661 rev_before, rev_after,
1662 "deleting an older task must change the revision so other clients see the deletion"
1663 );
1664 }
1665
1666 #[test]
1667 fn removing_a_task_takes_it_out_of_the_listing() {
1668 let (_dir, q) = queue();
1669 let mut t = task("delete me");
1670 q.put(&mut t).unwrap();
1671 let removed = q.remove(t.short(), false).unwrap();
1672 assert_eq!(removed, t.id, "a prefix resolves before deleting");
1673 assert!(q.list().is_empty());
1674 assert!(
1675 q.remove(&t.id, false).is_err(),
1676 "removing twice is an error"
1677 );
1678 }
1679
1680 #[test]
1681 fn removing_a_task_takes_its_stale_lock_with_it() {
1682 let (_dir, q) = queue();
1683 let mut t = task("interrupted");
1684 q.put(&mut t).unwrap();
1685
1686 let claim = q.claim(&t.id).unwrap();
1689 std::mem::forget(claim);
1690 assert!(
1691 q.claim(&t.id).is_err(),
1692 "the orphaned lock is what makes the task look claimed"
1693 );
1694
1695 let err = q.remove(&t.id, true).unwrap_err().to_string();
1697 assert!(err.contains("live daemon"), "{err}");
1698 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
1699
1700 q.remove(&t.id, false).unwrap();
1702 assert!(q.list().is_empty());
1703 let mut again = task("interrupted");
1704 again.id = t.id.clone();
1705 q.put(&mut again).unwrap();
1706 assert!(
1707 q.claim(&t.id).is_ok(),
1708 "a task that comes back must be claimable, which a left-behind lock would prevent"
1709 );
1710 }
1711}