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 pub created_at: Timestamp,
270 pub updated_at: Timestamp,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct AnsweredQuestion {
278 pub question: String,
280 pub answer: String,
282}
283
284impl Task {
285 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
287 let now = Timestamp::now();
288 Self {
289 schema: SCHEMA,
290 id: new_id(),
291 title,
292 instruction,
293 repo,
294 source,
295 priority: 0,
296 solo: false,
297 status: TaskStatus::Queued,
298 attempts: 0,
299 runs: Vec::new(),
300 last_error: None,
301 hold_reason: None,
302 hold_source: None,
303 diagnostic: None,
304 blocked_by: Vec::new(),
305 block_reason: None,
306 answers: Vec::new(),
307 review_branch: None,
308 fresh_start: false,
309 created_at: now,
310 updated_at: now,
311 }
312 }
313
314 pub fn short(&self) -> &str {
316 short(&self.id)
317 }
318
319 pub fn start(&mut self, run: String) {
321 self.status = TaskStatus::Running;
322 self.attempts += 1;
323 self.runs.push(run);
324 self.last_error = None;
325 self.fresh_start = false;
326 }
327
328 pub fn succeed(&mut self) {
338 self.status = TaskStatus::Done;
339 self.last_error = None;
340 self.hold_reason = None;
341 self.hold_source = None;
342 self.diagnostic = None;
343 self.blocked_by.clear();
344 self.block_reason = None;
345 }
346
347 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
358 self.last_error = Some(why.into());
359 self.diagnostic = None;
360 self.status = if self.attempts >= max_attempts {
361 self.hold_source = Some(HoldSource::Machine);
362 TaskStatus::Held
363 } else {
364 TaskStatus::Failed
365 };
366 }
367
368 pub fn stall(&mut self, why: impl Into<String>) {
378 self.last_error = Some(why.into());
379 self.diagnostic = None;
380 self.attempts = self.attempts.saturating_sub(1);
381 self.status = TaskStatus::Failed;
382 }
383
384 pub fn operator_held(&self) -> bool {
390 self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
391 }
392
393 pub fn hold_manual(&mut self, reason: Option<String>) {
403 self.status = TaskStatus::Held;
404 if reason.is_some() {
405 self.hold_reason = reason;
406 }
407 self.hold_source = Some(HoldSource::Manual);
408 self.blocked_by.clear();
409 self.block_reason = None;
410 }
411
412 pub fn hold_machine(&mut self, reason: Option<String>) {
417 self.status = TaskStatus::Held;
418 if reason.is_some() {
419 self.hold_reason = reason;
420 }
421 self.hold_source = Some(HoldSource::Machine);
422 self.blocked_by.clear();
423 self.block_reason = None;
424 }
425
426 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
430 self.status = TaskStatus::Blocked;
431 self.blocked_by = blocked_by;
432 self.block_reason = reason;
433 }
434
435 pub fn unblock(&mut self, resolved_id: &str) {
447 if self.status != TaskStatus::Blocked {
448 return;
449 }
450 self.blocked_by.retain(|id| id != resolved_id);
451 if self.blocked_by.is_empty() {
452 self.status = TaskStatus::Queued;
453 self.block_reason = None;
454 }
455 }
456
457 pub fn record_answer(&mut self, question: String, answer: String) {
462 self.answers.push(AnsweredQuestion { question, answer });
463 }
464
465 pub fn request_review(&mut self, branch: String) {
469 self.release();
470 self.review_branch = Some(branch);
471 }
472
473 pub fn requeue(&mut self) {
476 self.release();
477 self.fresh_start = true;
478 }
479
480 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
489 if self.status == TaskStatus::Running {
490 bail!(
491 "task {} is running; its priority cannot be changed until \
492 this attempt finishes",
493 self.short()
494 );
495 }
496 self.priority = priority;
497 Ok(())
498 }
499
500 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
512 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
513 bail!(
514 "task {} is {}; only a queued or held task's instruction can \
515 be edited",
516 self.short(),
517 self.status.as_str()
518 );
519 }
520 self.title = title;
521 self.instruction = instruction;
522 Ok(())
523 }
524
525 pub fn handed_off(&mut self, why: impl Into<String>) {
537 self.last_error = Some(why.into());
538 self.diagnostic = None;
539 self.status = TaskStatus::Held;
540 self.hold_source = Some(HoldSource::Machine);
541 }
542
543 pub fn release(&mut self) {
547 self.status = TaskStatus::Queued;
548 self.attempts = 0;
549 self.last_error = None;
550 self.hold_reason = None;
553 self.hold_source = None;
554 self.diagnostic = None;
555 self.blocked_by.clear();
560 self.block_reason = None;
561 self.review_branch = None;
562 self.fresh_start = false;
563 }
564}
565
566#[derive(Debug, Clone)]
568pub struct Queue {
569 root: PathBuf,
570}
571
572impl Queue {
573 pub fn open() -> Self {
575 Self::at(crate::run::home().join("queue"))
576 }
577
578 pub fn at(root: PathBuf) -> Self {
581 Self { root }
582 }
583
584 pub fn root(&self) -> &Path {
586 &self.root
587 }
588
589 pub fn path_of(&self, id: &str) -> PathBuf {
591 self.root.join(format!("{id}.json"))
592 }
593
594 pub fn put(&self, task: &mut Task) -> Result<()> {
597 task.updated_at = Timestamp::now();
598 std::fs::create_dir_all(&self.root)
599 .with_context(|| format!("create {}", self.root.display()))?;
600 let body = serde_json::to_string_pretty(task).context("serialize task")?;
601 let path = self.path_of(&task.id);
602 let tmp = path.with_extension("json.tmp");
603 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
604 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
605 Ok(())
606 }
607
608 pub fn get(&self, id: &str) -> Result<Task> {
610 let resolved = self.resolve_id(id)?;
611 read_path(&self.path_of(&resolved))
612 }
613
614 pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
628 let resolved = self.resolve_id(id)?;
629 if in_flight {
630 bail!("task {resolved} is being run by a live daemon right now");
631 }
632 let path = self.path_of(&resolved);
633 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
634 let lock = self.lock_path(&resolved);
635 if let Err(e) = std::fs::remove_file(&lock) {
636 if e.kind() != std::io::ErrorKind::NotFound {
637 return Err(e).with_context(|| format!("remove {}", lock.display()));
638 }
639 }
640 Ok(resolved)
641 }
642
643 fn lock_path(&self, id: &str) -> PathBuf {
646 self.root.join(format!("{id}.lock"))
647 }
648
649 pub fn list(&self) -> Vec<Task> {
662 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
663 .into_iter()
664 .flatten()
665 .flatten()
666 .map(|e| e.path())
667 .filter(|p| p.extension().is_some_and(|x| x == "json"))
668 .filter_map(|p| read_path(&p).ok())
669 .collect();
670 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
671 tasks
672 }
673
674 pub fn next_runnable(&self) -> Option<Task> {
679 let mut runnable: Vec<Task> = self
680 .list()
681 .into_iter()
682 .filter(|t| t.status.runnable())
683 .collect();
684 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
685 runnable.into_iter().next()
686 }
687
688 pub fn claim(&self, id: &str) -> Result<Claim> {
695 std::fs::create_dir_all(&self.root)
696 .with_context(|| format!("create {}", self.root.display()))?;
697 let path = self.lock_path(id);
698 match std::fs::OpenOptions::new()
699 .write(true)
700 .create_new(true)
701 .open(&path)
702 {
703 Ok(mut f) => {
704 use std::io::Write as _;
705 let _ = writeln!(f, "{}", std::process::id());
707 Ok(Claim { path })
708 }
709 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
710 bail!("task {id} is already claimed ({} exists)", path.display())
711 }
712 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
713 }
714 }
715
716 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
718 if self.path_of(prefix).is_file() {
719 return Ok(prefix.to_owned());
720 }
721 let hits: Vec<String> = self
722 .list()
723 .into_iter()
724 .map(|t| t.id)
725 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
726 .collect();
727 match hits.len() {
728 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
729 0 => bail!("no task matches `{prefix}`"),
730 _ => bail!(
731 "`{prefix}` matches {} tasks: {}",
732 hits.len(),
733 hits.join(", ")
734 ),
735 }
736 }
737
738 pub fn revision(&self) -> u64 {
745 use std::hash::{Hash as _, Hasher as _};
746
747 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
748 .into_iter()
749 .flatten()
750 .flatten()
751 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
752 .filter_map(|e| {
753 let name = e.file_name().to_string_lossy().into_owned();
754 let mtime = e
755 .metadata()
756 .ok()?
757 .modified()
758 .ok()?
759 .duration_since(std::time::UNIX_EPOCH)
760 .ok()?
761 .as_millis() as u64;
762 Some((name, mtime))
763 })
764 .collect();
765
766 if entries.is_empty() {
767 return 0;
768 }
769
770 entries.sort_unstable();
771 let mut hasher = std::hash::DefaultHasher::new();
772 for (name, mtime) in &entries {
773 name.hash(&mut hasher);
774 mtime.hash(&mut hasher);
775 }
776 let h = hasher.finish();
777 if h == 0 { 1 } else { h }
778 }
779}
780
781#[derive(Debug)]
783pub struct Claim {
784 path: PathBuf,
785}
786
787impl Drop for Claim {
788 fn drop(&mut self) {
789 let _ = std::fs::remove_file(&self.path);
790 }
791}
792
793pub fn title_from(instruction: &str, max: usize) -> String {
796 let line = instruction
802 .lines()
803 .map(str::trim)
804 .find(|l| !l.is_empty())
805 .unwrap_or("(empty task)")
806 .trim_start_matches(['#', '-', '*', '>', ' '])
807 .trim();
808 if line.is_empty() {
809 return "(empty task)".to_owned();
810 }
811 if line.chars().count() <= max {
812 return line.to_owned();
813 }
814 let head: String = line.chars().take(max.saturating_sub(1)).collect();
815 format!("{head}…")
816}
817
818fn read_path(path: &Path) -> Result<Task> {
819 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
820 let task: Task =
821 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
822 if task.schema > SCHEMA {
828 bail!(
829 "task {} was written by a different magi (schema {}, this build \
830 speaks {SCHEMA})",
831 task.id,
832 task.schema
833 );
834 }
835 Ok(task)
836}
837
838fn short(id: &str) -> &str {
839 id.split('-').next_back().unwrap_or(id)
840}
841
842fn new_id() -> String {
843 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
844 let seed = crate::rng::entropy();
845 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851
852 fn queue() -> (tempfile::TempDir, Queue) {
855 let dir = tempfile::tempdir().unwrap();
856 let q = Queue::at(dir.path().join("queue"));
857 (dir, q)
858 }
859
860 fn task(title: &str) -> Task {
861 Task::new(
862 title.to_owned(),
863 format!("do {title}"),
864 PathBuf::from("."),
865 Source::Human,
866 )
867 }
868
869 #[test]
870 fn a_markdown_heading_is_the_title_not_decoration() {
871 assert_eq!(
876 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
877 "Rework the config loader"
878 );
879 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
880 assert_eq!(title_from("> quoted task", 40), "quoted task");
881 assert_eq!(title_from(" \n\n", 40), "(empty task)");
883 assert_eq!(title_from("###\n", 40), "(empty task)");
884 }
885
886 #[test]
887 fn a_long_title_is_elided_by_characters_not_bytes() {
888 let long = "課題".repeat(30);
890 let title = title_from(&long, 10);
891 assert_eq!(title.chars().count(), 10);
892 assert!(title.ends_with('…'));
893 }
894
895 #[test]
896 fn priority_wins_and_ties_break_oldest_first() {
897 let (_dir, q) = queue();
898 let mut a = task("first");
899 let mut b = task("second");
900 let mut c = task("urgent");
901 a.id = "20260101-000001-aaaa".to_owned();
903 b.id = "20260101-000002-bbbb".to_owned();
904 c.id = "20260101-000003-cccc".to_owned();
905 c.priority = 5;
906 for t in [&mut a, &mut b, &mut c] {
907 q.put(t).unwrap();
908 }
909
910 assert_eq!(q.next_runnable().unwrap().id, c.id);
912 c.hold_machine(None);
913 q.put(&mut c).unwrap();
914 assert_eq!(q.next_runnable().unwrap().id, a.id);
916 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
917 }
918
919 #[test]
920 fn a_blocked_task_never_starves_another_runnable_one() {
921 let (_dir, q) = queue();
922 let mut blocked = task("blocked");
923 blocked.block(vec!["something".to_owned()], None);
924 q.put(&mut blocked).unwrap();
925
926 let mut runnable = task("free to go");
927 q.put(&mut runnable).unwrap();
928
929 let next = q.next_runnable().expect("a runnable task is still offered");
930 assert_eq!(next.id, runnable.id);
931 }
932
933 #[test]
934 fn a_held_task_is_never_offered_to_the_loop() {
935 let (_dir, q) = queue();
936 let mut t = task("held");
937 q.put(&mut t).unwrap();
938 assert!(q.next_runnable().is_some());
939
940 t.hold_machine(None);
941 q.put(&mut t).unwrap();
942 assert!(
943 q.next_runnable().is_none(),
944 "a held task must wait for a human"
945 );
946
947 t.status = TaskStatus::Failed;
949 q.put(&mut t).unwrap();
950 assert!(q.next_runnable().is_some());
951 }
952
953 #[test]
954 fn attempts_are_capped_and_then_the_task_is_held() {
955 let mut t = task("doomed");
956
957 t.start("run-1".to_owned());
958 t.fail("gate red", 2);
959 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
960
961 t.start("run-2".to_owned());
962 t.fail("gate red", 2);
963 assert_eq!(
964 t.status,
965 TaskStatus::Held,
966 "out of attempts: stop spending money on it"
967 );
968 assert_eq!(t.runs, ["run-1", "run-2"]);
969 assert_eq!(t.last_error.as_deref(), Some("gate red"));
970 }
971
972 #[test]
973 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
974 let mut t = task("stalled by quota");
975
976 t.start("run-1".to_owned());
977 assert_eq!(t.attempts, 1);
978 t.stall("judge-1, judge-2 out of quota");
979 assert_eq!(
980 t.attempts, 0,
981 "a closed quota window must not spend the task's retry budget"
982 );
983 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
984 assert_eq!(
985 t.last_error.as_deref(),
986 Some("judge-1, judge-2 out of quota")
987 );
988
989 for _ in 0..20 {
992 t.start("run-n".to_owned());
993 t.stall("still out of quota");
994 }
995 t.start("run-real".to_owned());
996 t.fail("gate red", 2);
997 assert_eq!(
998 t.status,
999 TaskStatus::Failed,
1000 "the first attempt that was really judged is attempt one"
1001 );
1002 }
1003
1004 #[test]
1005 fn releasing_a_held_task_gives_it_a_real_second_chance() {
1006 let mut t = task("retry me");
1007 t.start("run-1".to_owned());
1008 t.fail("gate red", 1);
1009 assert_eq!(t.status, TaskStatus::Held);
1010
1011 t.release();
1012 assert_eq!(t.status, TaskStatus::Queued);
1013 assert_eq!(t.attempts, 0);
1016 assert!(t.last_error.is_none());
1017 assert_eq!(
1018 t.runs.len(),
1019 1,
1020 "history is kept: attempts reset, evidence does not"
1021 );
1022 }
1023
1024 #[test]
1025 fn a_hold_reason_survives_and_a_release_clears_it() {
1026 let mut t = task("waiting on something else");
1027 t.hold_manual(Some(
1028 "waiting for 20260101-000000-aaaa to land first".to_owned(),
1029 ));
1030 assert_eq!(t.status, TaskStatus::Held);
1031 assert_eq!(
1032 t.hold_reason.as_deref(),
1033 Some("waiting for 20260101-000000-aaaa to land first")
1034 );
1035
1036 t.hold_manual(None);
1038 assert_eq!(
1039 t.hold_reason.as_deref(),
1040 Some("waiting for 20260101-000000-aaaa to land first"),
1041 "a bare re-hold keeps whatever a human already wrote down"
1042 );
1043
1044 let mut plain = task("no reason given");
1046 plain.hold_manual(None);
1047 assert_eq!(plain.status, TaskStatus::Held);
1048 assert!(plain.hold_reason.is_none());
1049
1050 t.release();
1051 assert_eq!(t.status, TaskStatus::Queued);
1052 assert!(
1053 t.hold_reason.is_none(),
1054 "a stale reason must not greet the next person who holds this task"
1055 );
1056 }
1057
1058 #[test]
1059 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
1060 let mut t = task("landed by hand while held");
1065 t.hold_manual(Some("waiting on 3ed9".to_owned()));
1066 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
1067
1068 t.succeed();
1069 assert_eq!(t.status, TaskStatus::Done);
1070 assert!(
1071 t.hold_reason.is_none(),
1072 "a done task cannot still be waiting on something"
1073 );
1074 }
1075
1076 #[test]
1077 fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
1078 let mut held = task("held straight out of blocked");
1085 held.block(
1086 vec!["20260101-000000-dead".to_owned()],
1087 Some("waiting on the migration script".to_owned()),
1088 );
1089 assert_eq!(held.status, TaskStatus::Blocked);
1090
1091 held.hold_manual(None);
1092 assert_eq!(held.status, TaskStatus::Held);
1093 assert!(
1094 held.blocked_by.is_empty(),
1095 "hold overrides the wait, same as release"
1096 );
1097 assert!(held.block_reason.is_none());
1098
1099 let mut done = task("closed straight out of blocked");
1100 done.block(
1101 vec!["20260101-000000-dead".to_owned()],
1102 Some("waiting on the migration script".to_owned()),
1103 );
1104 done.succeed();
1105 assert_eq!(done.status, TaskStatus::Done);
1106 assert!(
1107 done.blocked_by.is_empty(),
1108 "a done task cannot still be waiting on a dependency"
1109 );
1110 assert!(done.block_reason.is_none());
1111 }
1112
1113 #[test]
1114 fn a_blocked_task_is_never_offered_to_the_loop() {
1115 let mut t = task("blocked");
1116 assert!(t.status.runnable());
1117 t.block(
1118 vec!["dep-id".to_owned()],
1119 Some("waits on dep-id".to_owned()),
1120 );
1121 assert_eq!(t.status, TaskStatus::Blocked);
1122 assert!(!t.status.runnable());
1123 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
1124 }
1125
1126 #[test]
1127 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
1128 let mut t = task("blocked on two");
1129 t.block(
1130 vec!["a".to_owned(), "b".to_owned()],
1131 Some("waits on a and b".to_owned()),
1132 );
1133
1134 t.unblock("a");
1135 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
1136 assert_eq!(t.blocked_by, ["b"]);
1137
1138 t.unblock("b");
1139 assert_eq!(t.status, TaskStatus::Queued);
1140 assert!(t.blocked_by.is_empty());
1141 assert!(t.block_reason.is_none());
1142 }
1143
1144 #[test]
1145 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
1146 let mut t = task("never blocked");
1147 t.unblock("whatever");
1148 assert_eq!(t.status, TaskStatus::Queued);
1149 }
1150
1151 #[test]
1152 fn answering_a_question_is_recorded_and_survives_a_release() {
1153 let mut t = task("asked something");
1154 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
1155 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
1156 t.unblock("q1");
1157 assert_eq!(t.status, TaskStatus::Queued);
1158 assert_eq!(t.answers.len(), 1);
1159 assert_eq!(t.answers[0].answer, "SQLite");
1160
1161 t.release();
1165 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
1166 }
1167
1168 #[test]
1169 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
1170 let mut t = task("blocked run with a surviving branch");
1171 t.start("run-1".to_owned());
1172 t.fail("blocked with major findings", 5);
1173 assert_eq!(t.status, TaskStatus::Failed);
1174
1175 t.request_review("magi/eba2/A".to_owned());
1176 assert_eq!(t.status, TaskStatus::Queued);
1177 assert_eq!(t.attempts, 0);
1178 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
1179
1180 t.release();
1182 assert!(t.review_branch.is_none());
1183 }
1184
1185 #[test]
1186 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
1187 let mut t = task("retry");
1188 t.start("run-1".to_owned());
1189 t.requeue();
1190 assert!(t.fresh_start);
1191
1192 t.release();
1193 assert!(!t.fresh_start);
1194 }
1195
1196 #[test]
1197 fn priority_can_be_changed_while_queued_but_not_while_running() {
1198 let mut t = task("reprioritise me");
1199 t.set_priority(5).unwrap();
1200 assert_eq!(t.priority, 5);
1201
1202 t.start("run-1".to_owned());
1203 let err = t.set_priority(9).unwrap_err().to_string();
1204 assert!(err.contains("running"), "{err}");
1205 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
1206 }
1207
1208 #[test]
1209 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
1210 let (_dir, q) = queue();
1211 let mut a = task("first filed");
1212 let mut b = task("second filed");
1213 a.id = "20260101-000001-aaaa".to_owned();
1214 b.id = "20260101-000002-bbbb".to_owned();
1215 q.put(&mut a).unwrap();
1216 q.put(&mut b).unwrap();
1217
1218 assert_eq!(
1219 q.next_runnable().unwrap().id,
1220 a.id,
1221 "with equal priority the older task goes first, so a burst of \
1222 new work cannot starve it"
1223 );
1224 assert_eq!(
1225 q.list()[0].id,
1226 b.id,
1227 "but the list an operator reads is newest first, the same as \
1228 before priority existed - a's turn to run does not make it the \
1229 newest task"
1230 );
1231
1232 let mut a = q.get(&a.id).unwrap();
1233 a.set_priority(10).unwrap();
1234 q.put(&mut a).unwrap();
1235
1236 assert_eq!(
1237 q.next_runnable().unwrap().id,
1238 a.id,
1239 "a raised priority must be reflected the moment it is saved"
1240 );
1241 assert_eq!(
1245 q.list()[0].id,
1246 a.id,
1247 "the raised task must sort first in the list an operator reads, \
1248 not only in next_runnable's own ordering"
1249 );
1250 }
1251
1252 #[test]
1253 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
1254 let mut t = Task::new(
1255 "old title".to_owned(),
1256 "old instruction".to_owned(),
1257 PathBuf::from("/repo"),
1258 Source::Agent {
1259 run: "20260101-000000-beef".to_owned(),
1260 node: "implement".to_owned(),
1261 },
1262 );
1263 let id = t.id.clone();
1264 let created_at = t.created_at;
1265 t.runs.push("20260101-000000-beef".to_owned());
1266
1267 t.edit("new title".to_owned(), "new instruction".to_owned())
1268 .unwrap();
1269
1270 assert_eq!(t.title, "new title");
1271 assert_eq!(t.instruction, "new instruction");
1272 assert_eq!(t.id, id, "editing must not mint a new id");
1273 assert_eq!(t.created_at, created_at);
1274 assert_eq!(
1275 t.source,
1276 Source::Agent {
1277 run: "20260101-000000-beef".to_owned(),
1278 node: "implement".to_owned(),
1279 },
1280 "editing must not turn agent attribution into human"
1281 );
1282 assert_eq!(t.runs, ["20260101-000000-beef"]);
1283 }
1284
1285 #[test]
1286 fn editing_is_refused_once_a_task_is_running_or_finished() {
1287 let mut running = task("in flight");
1288 running.start("run-1".to_owned());
1289 let err = running
1290 .edit("x".to_owned(), "y".to_owned())
1291 .unwrap_err()
1292 .to_string();
1293 assert!(err.contains("running"), "{err}");
1294
1295 let mut done = task("finished");
1296 done.succeed();
1297 let err = done
1298 .edit("x".to_owned(), "y".to_owned())
1299 .unwrap_err()
1300 .to_string();
1301 assert!(err.contains("done"), "{err}");
1302
1303 let mut queued = task("waiting");
1305 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
1306 let mut held = task("parked");
1307 held.hold_machine(None);
1308 held.edit("x".to_owned(), "y".to_owned()).unwrap();
1309 }
1310
1311 #[test]
1312 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
1313 let (_dir, q) = queue();
1314 let path = q.path_of("20260101-000000-aaaa");
1315 std::fs::create_dir_all(q.root()).unwrap();
1316 std::fs::write(
1317 &path,
1318 serde_json::json!({
1319 "schema": SCHEMA,
1320 "id": "20260101-000000-aaaa",
1321 "title": "from before hold reasons existed",
1322 "instruction": "from before hold reasons existed",
1323 "repo": ".",
1324 "source": { "kind": "human" },
1325 "status": "held",
1326 "created_at": Timestamp::now().to_string(),
1327 "updated_at": Timestamp::now().to_string(),
1328 })
1329 .to_string(),
1330 )
1331 .unwrap();
1332
1333 let task = q.get("20260101-000000-aaaa").expect("must still read");
1334 assert!(task.hold_reason.is_none());
1335 assert!(task.operator_held());
1336 }
1337
1338 #[test]
1339 fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
1340 let (_dir, q) = queue();
1341 let path = q.path_of("20260101-000000-bbbb");
1342 std::fs::create_dir_all(q.root()).unwrap();
1343 std::fs::write(
1344 &path,
1345 serde_json::json!({
1346 "schema": 2,
1347 "id": "20260101-000000-bbbb",
1348 "title": "old manual recovery",
1349 "instruction": "old manual recovery",
1350 "repo": ".",
1351 "source": { "kind": "human" },
1352 "status": "held",
1353 "hold_reason": "active manual recovery run20260912-224242-daf5",
1354 "created_at": Timestamp::now().to_string(),
1355 "updated_at": Timestamp::now().to_string(),
1356 })
1357 .to_string(),
1358 )
1359 .unwrap();
1360
1361 let task = q.get("20260101-000000-bbbb").expect("must still read");
1362 assert_eq!(task.hold_source, None);
1363 assert!(task.operator_held());
1364 }
1365
1366 #[test]
1367 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
1368 let (_dir, q) = queue();
1369 let path = q.path_of("20260101-000000-aaaa");
1370 std::fs::create_dir_all(q.root()).unwrap();
1371 std::fs::write(
1372 &path,
1373 serde_json::json!({
1374 "schema": SCHEMA,
1375 "id": "20260101-000000-aaaa",
1376 "title": "from before diagnostics existed",
1377 "instruction": "from before diagnostics existed",
1378 "repo": ".",
1379 "source": { "kind": "human" },
1380 "status": "held",
1381 "created_at": Timestamp::now().to_string(),
1382 "updated_at": Timestamp::now().to_string(),
1383 })
1384 .to_string(),
1385 )
1386 .unwrap();
1387
1388 let task = q.get("20260101-000000-aaaa").expect("must still read");
1389 assert!(task.diagnostic.is_none());
1390 }
1391
1392 #[test]
1393 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
1394 let (_dir, q) = queue();
1398 let path = q.path_of("20260101-000000-aaaa");
1399 std::fs::create_dir_all(q.root()).unwrap();
1400 std::fs::write(
1401 &path,
1402 serde_json::json!({
1403 "schema": 1,
1404 "id": "20260101-000000-aaaa",
1405 "title": "from before blocking existed",
1406 "instruction": "from before blocking existed",
1407 "repo": ".",
1408 "source": { "kind": "human" },
1409 "status": "queued",
1410 "created_at": Timestamp::now().to_string(),
1411 "updated_at": Timestamp::now().to_string(),
1412 })
1413 .to_string(),
1414 )
1415 .unwrap();
1416
1417 let task = q.get("20260101-000000-aaaa").expect("must still read");
1418 assert!(task.blocked_by.is_empty());
1419 assert!(task.block_reason.is_none());
1420 assert!(task.answers.is_empty());
1421 assert!(task.review_branch.is_none());
1422 }
1423
1424 #[test]
1425 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
1426 let mut held = task("diagnosed");
1431 held.start("run-1".to_owned());
1432 held.fail("gate red", 1);
1433 held.diagnostic = Some("cargo test failed: ...".to_owned());
1434 assert_eq!(held.status, TaskStatus::Held);
1435
1436 held.release();
1437 assert!(held.diagnostic.is_none());
1438
1439 held.diagnostic = Some("cargo test failed: ...".to_owned());
1440 held.succeed();
1441 assert!(held.diagnostic.is_none());
1442 }
1443
1444 #[test]
1445 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
1446 let mut t = task("retried");
1447 t.start("run-1".to_owned());
1448 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
1449 t.fail("unrelated config error", 5);
1450 assert_eq!(t.status, TaskStatus::Failed);
1451 assert!(
1452 t.diagnostic.is_none(),
1453 "fail() must not let an old diagnostic outlive the run that produced it"
1454 );
1455 }
1456
1457 #[test]
1458 fn a_claim_is_exclusive_and_releases_on_drop() {
1459 let (_dir, q) = queue();
1460 let mut t = task("contended");
1461 q.put(&mut t).unwrap();
1462
1463 let held = q.claim(&t.id).unwrap();
1464 assert!(
1465 q.claim(&t.id).is_err(),
1466 "two daemons must not drive one task into two runs"
1467 );
1468 drop(held);
1469 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
1470 }
1471
1472 #[test]
1473 fn a_round_trip_survives_disk() {
1474 let (_dir, q) = queue();
1475 let mut t = Task::new(
1476 "titled".to_owned(),
1477 "body".to_owned(),
1478 PathBuf::from("/repo"),
1479 Source::Agent {
1480 run: "20260101-000000-beef".to_owned(),
1481 node: "implement".to_owned(),
1482 },
1483 );
1484 t.priority = 3;
1485 q.put(&mut t).unwrap();
1486
1487 let back = q.get(&t.id).unwrap();
1488 assert_eq!(back.id, t.id);
1489 assert_eq!(back.priority, 3);
1490 assert_eq!(back.source.label(), "implement@beef");
1491 assert_eq!(q.get(t.short()).unwrap().id, t.id);
1493 }
1494
1495 #[test]
1496 fn an_unreadable_task_does_not_take_the_queue_down() {
1497 let (_dir, q) = queue();
1498 let mut t = task("fine");
1499 q.put(&mut t).unwrap();
1500 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
1501
1502 let listed = q.list();
1503 assert_eq!(listed.len(), 1, "the readable task still lists");
1504 assert_eq!(listed[0].id, t.id);
1505 }
1506
1507 #[test]
1508 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
1509 let (_dir, q) = queue();
1510 let path = q.path_of("20260101-000000-aaaa");
1511 std::fs::create_dir_all(q.root()).unwrap();
1512 std::fs::write(
1513 &path,
1514 serde_json::json!({
1515 "schema": SCHEMA,
1516 "id": "20260101-000000-aaaa",
1517 "title": "from before solo existed",
1518 "instruction": "from before solo existed",
1519 "repo": ".",
1520 "source": { "kind": "human" },
1521 "status": "queued",
1522 "created_at": Timestamp::now().to_string(),
1523 "updated_at": Timestamp::now().to_string(),
1524 })
1525 .to_string(),
1526 )
1527 .unwrap();
1528
1529 let task = q.get("20260101-000000-aaaa").expect("must still read");
1530 assert!(!task.solo, "a queue file with no `solo` field means false");
1531 }
1532
1533 #[test]
1534 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
1535 let (_dir, q) = queue();
1536 let mut t = task("from the future");
1537 q.put(&mut t).unwrap();
1538 let path = q.path_of(&t.id);
1539 let body = std::fs::read_to_string(&path)
1540 .unwrap()
1541 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
1542 std::fs::write(&path, body).unwrap();
1543
1544 let err = q.get(&t.id).unwrap_err().to_string();
1545 assert!(err.contains("schema 99"), "{err}");
1546 }
1547
1548 #[test]
1549 fn revision_moves_when_the_queue_changes() {
1550 let (_dir, q) = queue();
1551 assert_eq!(q.revision(), 0, "an empty queue has no revision");
1552 let mut t = task("first");
1553 q.put(&mut t).unwrap();
1554 assert!(q.revision() > 0, "a written task moves the revision");
1555 }
1556
1557 #[test]
1558 fn revision_moves_when_deleting_an_older_task() {
1559 let (_dir, q) = queue();
1560 let mut t1 = task("older");
1561 q.put(&mut t1).unwrap();
1562 std::thread::sleep(std::time::Duration::from_millis(10));
1564 let mut t2 = task("newer");
1565 q.put(&mut t2).unwrap();
1566
1567 let rev_before = q.revision();
1568 q.remove(&t1.id, false).unwrap();
1569 let rev_after = q.revision();
1570
1571 assert_ne!(
1572 rev_before, rev_after,
1573 "deleting an older task must change the revision so other clients see the deletion"
1574 );
1575 }
1576
1577 #[test]
1578 fn removing_a_task_takes_it_out_of_the_listing() {
1579 let (_dir, q) = queue();
1580 let mut t = task("delete me");
1581 q.put(&mut t).unwrap();
1582 let removed = q.remove(t.short(), false).unwrap();
1583 assert_eq!(removed, t.id, "a prefix resolves before deleting");
1584 assert!(q.list().is_empty());
1585 assert!(
1586 q.remove(&t.id, false).is_err(),
1587 "removing twice is an error"
1588 );
1589 }
1590
1591 #[test]
1592 fn removing_a_task_takes_its_stale_lock_with_it() {
1593 let (_dir, q) = queue();
1594 let mut t = task("interrupted");
1595 q.put(&mut t).unwrap();
1596
1597 let claim = q.claim(&t.id).unwrap();
1600 std::mem::forget(claim);
1601 assert!(
1602 q.claim(&t.id).is_err(),
1603 "the orphaned lock is what makes the task look claimed"
1604 );
1605
1606 let err = q.remove(&t.id, true).unwrap_err().to_string();
1608 assert!(err.contains("live daemon"), "{err}");
1609 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
1610
1611 q.remove(&t.id, false).unwrap();
1613 assert!(q.list().is_empty());
1614 let mut again = task("interrupted");
1615 again.id = t.id.clone();
1616 q.put(&mut again).unwrap();
1617 assert!(
1618 q.claim(&t.id).is_ok(),
1619 "a task that comes back must be claimable, which a left-behind lock would prevent"
1620 );
1621 }
1622}