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) {
337 self.status = TaskStatus::Done;
338 self.last_error = None;
339 self.hold_reason = None;
340 self.hold_source = None;
341 self.diagnostic = None;
342 }
343
344 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
355 self.last_error = Some(why.into());
356 self.diagnostic = None;
357 self.status = if self.attempts >= max_attempts {
358 self.hold_source = Some(HoldSource::Machine);
359 TaskStatus::Held
360 } else {
361 TaskStatus::Failed
362 };
363 }
364
365 pub fn stall(&mut self, why: impl Into<String>) {
375 self.last_error = Some(why.into());
376 self.diagnostic = None;
377 self.attempts = self.attempts.saturating_sub(1);
378 self.status = TaskStatus::Failed;
379 }
380
381 pub fn operator_held(&self) -> bool {
387 self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
388 }
389
390 pub fn hold_manual(&mut self, reason: Option<String>) {
392 self.status = TaskStatus::Held;
393 if reason.is_some() {
394 self.hold_reason = reason;
395 }
396 self.hold_source = Some(HoldSource::Manual);
397 }
398
399 pub fn hold_machine(&mut self, reason: Option<String>) {
401 self.status = TaskStatus::Held;
402 if reason.is_some() {
403 self.hold_reason = reason;
404 }
405 self.hold_source = Some(HoldSource::Machine);
406 }
407
408 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
412 self.status = TaskStatus::Blocked;
413 self.blocked_by = blocked_by;
414 self.block_reason = reason;
415 }
416
417 pub fn unblock(&mut self, resolved_id: &str) {
429 if self.status != TaskStatus::Blocked {
430 return;
431 }
432 self.blocked_by.retain(|id| id != resolved_id);
433 if self.blocked_by.is_empty() {
434 self.status = TaskStatus::Queued;
435 self.block_reason = None;
436 }
437 }
438
439 pub fn record_answer(&mut self, question: String, answer: String) {
444 self.answers.push(AnsweredQuestion { question, answer });
445 }
446
447 pub fn request_review(&mut self, branch: String) {
451 self.release();
452 self.review_branch = Some(branch);
453 }
454
455 pub fn requeue(&mut self) {
458 self.release();
459 self.fresh_start = true;
460 }
461
462 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
471 if self.status == TaskStatus::Running {
472 bail!(
473 "task {} is running; its priority cannot be changed until \
474 this attempt finishes",
475 self.short()
476 );
477 }
478 self.priority = priority;
479 Ok(())
480 }
481
482 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
494 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
495 bail!(
496 "task {} is {}; only a queued or held task's instruction can \
497 be edited",
498 self.short(),
499 self.status.as_str()
500 );
501 }
502 self.title = title;
503 self.instruction = instruction;
504 Ok(())
505 }
506
507 pub fn handed_off(&mut self, why: impl Into<String>) {
519 self.last_error = Some(why.into());
520 self.diagnostic = None;
521 self.status = TaskStatus::Held;
522 self.hold_source = Some(HoldSource::Machine);
523 }
524
525 pub fn release(&mut self) {
529 self.status = TaskStatus::Queued;
530 self.attempts = 0;
531 self.last_error = None;
532 self.hold_reason = None;
535 self.hold_source = None;
536 self.diagnostic = None;
537 self.blocked_by.clear();
542 self.block_reason = None;
543 self.review_branch = None;
544 self.fresh_start = false;
545 }
546}
547
548#[derive(Debug, Clone)]
550pub struct Queue {
551 root: PathBuf,
552}
553
554impl Queue {
555 pub fn open() -> Self {
557 Self::at(crate::run::home().join("queue"))
558 }
559
560 pub fn at(root: PathBuf) -> Self {
563 Self { root }
564 }
565
566 pub fn root(&self) -> &Path {
568 &self.root
569 }
570
571 pub fn path_of(&self, id: &str) -> PathBuf {
573 self.root.join(format!("{id}.json"))
574 }
575
576 pub fn put(&self, task: &mut Task) -> Result<()> {
579 task.updated_at = Timestamp::now();
580 std::fs::create_dir_all(&self.root)
581 .with_context(|| format!("create {}", self.root.display()))?;
582 let body = serde_json::to_string_pretty(task).context("serialize task")?;
583 let path = self.path_of(&task.id);
584 let tmp = path.with_extension("json.tmp");
585 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
586 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
587 Ok(())
588 }
589
590 pub fn get(&self, id: &str) -> Result<Task> {
592 let resolved = self.resolve_id(id)?;
593 read_path(&self.path_of(&resolved))
594 }
595
596 pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
610 let resolved = self.resolve_id(id)?;
611 if in_flight {
612 bail!("task {resolved} is being run by a live daemon right now");
613 }
614 let path = self.path_of(&resolved);
615 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
616 let lock = self.lock_path(&resolved);
617 if let Err(e) = std::fs::remove_file(&lock) {
618 if e.kind() != std::io::ErrorKind::NotFound {
619 return Err(e).with_context(|| format!("remove {}", lock.display()));
620 }
621 }
622 Ok(resolved)
623 }
624
625 fn lock_path(&self, id: &str) -> PathBuf {
628 self.root.join(format!("{id}.lock"))
629 }
630
631 pub fn list(&self) -> Vec<Task> {
644 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
645 .into_iter()
646 .flatten()
647 .flatten()
648 .map(|e| e.path())
649 .filter(|p| p.extension().is_some_and(|x| x == "json"))
650 .filter_map(|p| read_path(&p).ok())
651 .collect();
652 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
653 tasks
654 }
655
656 pub fn next_runnable(&self) -> Option<Task> {
661 let mut runnable: Vec<Task> = self
662 .list()
663 .into_iter()
664 .filter(|t| t.status.runnable())
665 .collect();
666 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
667 runnable.into_iter().next()
668 }
669
670 pub fn claim(&self, id: &str) -> Result<Claim> {
677 std::fs::create_dir_all(&self.root)
678 .with_context(|| format!("create {}", self.root.display()))?;
679 let path = self.lock_path(id);
680 match std::fs::OpenOptions::new()
681 .write(true)
682 .create_new(true)
683 .open(&path)
684 {
685 Ok(mut f) => {
686 use std::io::Write as _;
687 let _ = writeln!(f, "{}", std::process::id());
689 Ok(Claim { path })
690 }
691 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
692 bail!("task {id} is already claimed ({} exists)", path.display())
693 }
694 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
695 }
696 }
697
698 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
700 if self.path_of(prefix).is_file() {
701 return Ok(prefix.to_owned());
702 }
703 let hits: Vec<String> = self
704 .list()
705 .into_iter()
706 .map(|t| t.id)
707 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
708 .collect();
709 match hits.len() {
710 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
711 0 => bail!("no task matches `{prefix}`"),
712 _ => bail!(
713 "`{prefix}` matches {} tasks: {}",
714 hits.len(),
715 hits.join(", ")
716 ),
717 }
718 }
719
720 pub fn revision(&self) -> u64 {
727 use std::hash::{Hash as _, Hasher as _};
728
729 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
730 .into_iter()
731 .flatten()
732 .flatten()
733 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
734 .filter_map(|e| {
735 let name = e.file_name().to_string_lossy().into_owned();
736 let mtime = e
737 .metadata()
738 .ok()?
739 .modified()
740 .ok()?
741 .duration_since(std::time::UNIX_EPOCH)
742 .ok()?
743 .as_millis() as u64;
744 Some((name, mtime))
745 })
746 .collect();
747
748 if entries.is_empty() {
749 return 0;
750 }
751
752 entries.sort_unstable();
753 let mut hasher = std::hash::DefaultHasher::new();
754 for (name, mtime) in &entries {
755 name.hash(&mut hasher);
756 mtime.hash(&mut hasher);
757 }
758 let h = hasher.finish();
759 if h == 0 { 1 } else { h }
760 }
761}
762
763#[derive(Debug)]
765pub struct Claim {
766 path: PathBuf,
767}
768
769impl Drop for Claim {
770 fn drop(&mut self) {
771 let _ = std::fs::remove_file(&self.path);
772 }
773}
774
775pub fn title_from(instruction: &str, max: usize) -> String {
778 let line = instruction
784 .lines()
785 .map(str::trim)
786 .find(|l| !l.is_empty())
787 .unwrap_or("(empty task)")
788 .trim_start_matches(['#', '-', '*', '>', ' '])
789 .trim();
790 if line.is_empty() {
791 return "(empty task)".to_owned();
792 }
793 if line.chars().count() <= max {
794 return line.to_owned();
795 }
796 let head: String = line.chars().take(max.saturating_sub(1)).collect();
797 format!("{head}…")
798}
799
800fn read_path(path: &Path) -> Result<Task> {
801 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
802 let task: Task =
803 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
804 if task.schema > SCHEMA {
810 bail!(
811 "task {} was written by a different magi (schema {}, this build \
812 speaks {SCHEMA})",
813 task.id,
814 task.schema
815 );
816 }
817 Ok(task)
818}
819
820fn short(id: &str) -> &str {
821 id.split('-').next_back().unwrap_or(id)
822}
823
824fn new_id() -> String {
825 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
826 let seed = crate::rng::entropy();
827 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833
834 fn queue() -> (tempfile::TempDir, Queue) {
837 let dir = tempfile::tempdir().unwrap();
838 let q = Queue::at(dir.path().join("queue"));
839 (dir, q)
840 }
841
842 fn task(title: &str) -> Task {
843 Task::new(
844 title.to_owned(),
845 format!("do {title}"),
846 PathBuf::from("."),
847 Source::Human,
848 )
849 }
850
851 #[test]
852 fn a_markdown_heading_is_the_title_not_decoration() {
853 assert_eq!(
858 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
859 "Rework the config loader"
860 );
861 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
862 assert_eq!(title_from("> quoted task", 40), "quoted task");
863 assert_eq!(title_from(" \n\n", 40), "(empty task)");
865 assert_eq!(title_from("###\n", 40), "(empty task)");
866 }
867
868 #[test]
869 fn a_long_title_is_elided_by_characters_not_bytes() {
870 let long = "課題".repeat(30);
872 let title = title_from(&long, 10);
873 assert_eq!(title.chars().count(), 10);
874 assert!(title.ends_with('…'));
875 }
876
877 #[test]
878 fn priority_wins_and_ties_break_oldest_first() {
879 let (_dir, q) = queue();
880 let mut a = task("first");
881 let mut b = task("second");
882 let mut c = task("urgent");
883 a.id = "20260101-000001-aaaa".to_owned();
885 b.id = "20260101-000002-bbbb".to_owned();
886 c.id = "20260101-000003-cccc".to_owned();
887 c.priority = 5;
888 for t in [&mut a, &mut b, &mut c] {
889 q.put(t).unwrap();
890 }
891
892 assert_eq!(q.next_runnable().unwrap().id, c.id);
894 c.hold_machine(None);
895 q.put(&mut c).unwrap();
896 assert_eq!(q.next_runnable().unwrap().id, a.id);
898 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
899 }
900
901 #[test]
902 fn a_blocked_task_never_starves_another_runnable_one() {
903 let (_dir, q) = queue();
904 let mut blocked = task("blocked");
905 blocked.block(vec!["something".to_owned()], None);
906 q.put(&mut blocked).unwrap();
907
908 let mut runnable = task("free to go");
909 q.put(&mut runnable).unwrap();
910
911 let next = q.next_runnable().expect("a runnable task is still offered");
912 assert_eq!(next.id, runnable.id);
913 }
914
915 #[test]
916 fn a_held_task_is_never_offered_to_the_loop() {
917 let (_dir, q) = queue();
918 let mut t = task("held");
919 q.put(&mut t).unwrap();
920 assert!(q.next_runnable().is_some());
921
922 t.hold_machine(None);
923 q.put(&mut t).unwrap();
924 assert!(
925 q.next_runnable().is_none(),
926 "a held task must wait for a human"
927 );
928
929 t.status = TaskStatus::Failed;
931 q.put(&mut t).unwrap();
932 assert!(q.next_runnable().is_some());
933 }
934
935 #[test]
936 fn attempts_are_capped_and_then_the_task_is_held() {
937 let mut t = task("doomed");
938
939 t.start("run-1".to_owned());
940 t.fail("gate red", 2);
941 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
942
943 t.start("run-2".to_owned());
944 t.fail("gate red", 2);
945 assert_eq!(
946 t.status,
947 TaskStatus::Held,
948 "out of attempts: stop spending money on it"
949 );
950 assert_eq!(t.runs, ["run-1", "run-2"]);
951 assert_eq!(t.last_error.as_deref(), Some("gate red"));
952 }
953
954 #[test]
955 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
956 let mut t = task("stalled by quota");
957
958 t.start("run-1".to_owned());
959 assert_eq!(t.attempts, 1);
960 t.stall("judge-1, judge-2 out of quota");
961 assert_eq!(
962 t.attempts, 0,
963 "a closed quota window must not spend the task's retry budget"
964 );
965 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
966 assert_eq!(
967 t.last_error.as_deref(),
968 Some("judge-1, judge-2 out of quota")
969 );
970
971 for _ in 0..20 {
974 t.start("run-n".to_owned());
975 t.stall("still out of quota");
976 }
977 t.start("run-real".to_owned());
978 t.fail("gate red", 2);
979 assert_eq!(
980 t.status,
981 TaskStatus::Failed,
982 "the first attempt that was really judged is attempt one"
983 );
984 }
985
986 #[test]
987 fn releasing_a_held_task_gives_it_a_real_second_chance() {
988 let mut t = task("retry me");
989 t.start("run-1".to_owned());
990 t.fail("gate red", 1);
991 assert_eq!(t.status, TaskStatus::Held);
992
993 t.release();
994 assert_eq!(t.status, TaskStatus::Queued);
995 assert_eq!(t.attempts, 0);
998 assert!(t.last_error.is_none());
999 assert_eq!(
1000 t.runs.len(),
1001 1,
1002 "history is kept: attempts reset, evidence does not"
1003 );
1004 }
1005
1006 #[test]
1007 fn a_hold_reason_survives_and_a_release_clears_it() {
1008 let mut t = task("waiting on something else");
1009 t.hold_manual(Some(
1010 "waiting for 20260101-000000-aaaa to land first".to_owned(),
1011 ));
1012 assert_eq!(t.status, TaskStatus::Held);
1013 assert_eq!(
1014 t.hold_reason.as_deref(),
1015 Some("waiting for 20260101-000000-aaaa to land first")
1016 );
1017
1018 t.hold_manual(None);
1020 assert_eq!(
1021 t.hold_reason.as_deref(),
1022 Some("waiting for 20260101-000000-aaaa to land first"),
1023 "a bare re-hold keeps whatever a human already wrote down"
1024 );
1025
1026 let mut plain = task("no reason given");
1028 plain.hold_manual(None);
1029 assert_eq!(plain.status, TaskStatus::Held);
1030 assert!(plain.hold_reason.is_none());
1031
1032 t.release();
1033 assert_eq!(t.status, TaskStatus::Queued);
1034 assert!(
1035 t.hold_reason.is_none(),
1036 "a stale reason must not greet the next person who holds this task"
1037 );
1038 }
1039
1040 #[test]
1041 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
1042 let mut t = task("landed by hand while held");
1047 t.hold_manual(Some("waiting on 3ed9".to_owned()));
1048 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
1049
1050 t.succeed();
1051 assert_eq!(t.status, TaskStatus::Done);
1052 assert!(
1053 t.hold_reason.is_none(),
1054 "a done task cannot still be waiting on something"
1055 );
1056 }
1057
1058 #[test]
1059 fn a_blocked_task_is_never_offered_to_the_loop() {
1060 let mut t = task("blocked");
1061 assert!(t.status.runnable());
1062 t.block(
1063 vec!["dep-id".to_owned()],
1064 Some("waits on dep-id".to_owned()),
1065 );
1066 assert_eq!(t.status, TaskStatus::Blocked);
1067 assert!(!t.status.runnable());
1068 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
1069 }
1070
1071 #[test]
1072 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
1073 let mut t = task("blocked on two");
1074 t.block(
1075 vec!["a".to_owned(), "b".to_owned()],
1076 Some("waits on a and b".to_owned()),
1077 );
1078
1079 t.unblock("a");
1080 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
1081 assert_eq!(t.blocked_by, ["b"]);
1082
1083 t.unblock("b");
1084 assert_eq!(t.status, TaskStatus::Queued);
1085 assert!(t.blocked_by.is_empty());
1086 assert!(t.block_reason.is_none());
1087 }
1088
1089 #[test]
1090 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
1091 let mut t = task("never blocked");
1092 t.unblock("whatever");
1093 assert_eq!(t.status, TaskStatus::Queued);
1094 }
1095
1096 #[test]
1097 fn answering_a_question_is_recorded_and_survives_a_release() {
1098 let mut t = task("asked something");
1099 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
1100 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
1101 t.unblock("q1");
1102 assert_eq!(t.status, TaskStatus::Queued);
1103 assert_eq!(t.answers.len(), 1);
1104 assert_eq!(t.answers[0].answer, "SQLite");
1105
1106 t.release();
1110 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
1111 }
1112
1113 #[test]
1114 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
1115 let mut t = task("blocked run with a surviving branch");
1116 t.start("run-1".to_owned());
1117 t.fail("blocked with major findings", 5);
1118 assert_eq!(t.status, TaskStatus::Failed);
1119
1120 t.request_review("magi/eba2/A".to_owned());
1121 assert_eq!(t.status, TaskStatus::Queued);
1122 assert_eq!(t.attempts, 0);
1123 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
1124
1125 t.release();
1127 assert!(t.review_branch.is_none());
1128 }
1129
1130 #[test]
1131 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
1132 let mut t = task("retry");
1133 t.start("run-1".to_owned());
1134 t.requeue();
1135 assert!(t.fresh_start);
1136
1137 t.release();
1138 assert!(!t.fresh_start);
1139 }
1140
1141 #[test]
1142 fn priority_can_be_changed_while_queued_but_not_while_running() {
1143 let mut t = task("reprioritise me");
1144 t.set_priority(5).unwrap();
1145 assert_eq!(t.priority, 5);
1146
1147 t.start("run-1".to_owned());
1148 let err = t.set_priority(9).unwrap_err().to_string();
1149 assert!(err.contains("running"), "{err}");
1150 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
1151 }
1152
1153 #[test]
1154 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
1155 let (_dir, q) = queue();
1156 let mut a = task("first filed");
1157 let mut b = task("second filed");
1158 a.id = "20260101-000001-aaaa".to_owned();
1159 b.id = "20260101-000002-bbbb".to_owned();
1160 q.put(&mut a).unwrap();
1161 q.put(&mut b).unwrap();
1162
1163 assert_eq!(
1164 q.next_runnable().unwrap().id,
1165 a.id,
1166 "with equal priority the older task goes first, so a burst of \
1167 new work cannot starve it"
1168 );
1169 assert_eq!(
1170 q.list()[0].id,
1171 b.id,
1172 "but the list an operator reads is newest first, the same as \
1173 before priority existed - a's turn to run does not make it the \
1174 newest task"
1175 );
1176
1177 let mut a = q.get(&a.id).unwrap();
1178 a.set_priority(10).unwrap();
1179 q.put(&mut a).unwrap();
1180
1181 assert_eq!(
1182 q.next_runnable().unwrap().id,
1183 a.id,
1184 "a raised priority must be reflected the moment it is saved"
1185 );
1186 assert_eq!(
1190 q.list()[0].id,
1191 a.id,
1192 "the raised task must sort first in the list an operator reads, \
1193 not only in next_runnable's own ordering"
1194 );
1195 }
1196
1197 #[test]
1198 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
1199 let mut t = Task::new(
1200 "old title".to_owned(),
1201 "old instruction".to_owned(),
1202 PathBuf::from("/repo"),
1203 Source::Agent {
1204 run: "20260101-000000-beef".to_owned(),
1205 node: "implement".to_owned(),
1206 },
1207 );
1208 let id = t.id.clone();
1209 let created_at = t.created_at;
1210 t.runs.push("20260101-000000-beef".to_owned());
1211
1212 t.edit("new title".to_owned(), "new instruction".to_owned())
1213 .unwrap();
1214
1215 assert_eq!(t.title, "new title");
1216 assert_eq!(t.instruction, "new instruction");
1217 assert_eq!(t.id, id, "editing must not mint a new id");
1218 assert_eq!(t.created_at, created_at);
1219 assert_eq!(
1220 t.source,
1221 Source::Agent {
1222 run: "20260101-000000-beef".to_owned(),
1223 node: "implement".to_owned(),
1224 },
1225 "editing must not turn agent attribution into human"
1226 );
1227 assert_eq!(t.runs, ["20260101-000000-beef"]);
1228 }
1229
1230 #[test]
1231 fn editing_is_refused_once_a_task_is_running_or_finished() {
1232 let mut running = task("in flight");
1233 running.start("run-1".to_owned());
1234 let err = running
1235 .edit("x".to_owned(), "y".to_owned())
1236 .unwrap_err()
1237 .to_string();
1238 assert!(err.contains("running"), "{err}");
1239
1240 let mut done = task("finished");
1241 done.succeed();
1242 let err = done
1243 .edit("x".to_owned(), "y".to_owned())
1244 .unwrap_err()
1245 .to_string();
1246 assert!(err.contains("done"), "{err}");
1247
1248 let mut queued = task("waiting");
1250 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
1251 let mut held = task("parked");
1252 held.hold_machine(None);
1253 held.edit("x".to_owned(), "y".to_owned()).unwrap();
1254 }
1255
1256 #[test]
1257 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
1258 let (_dir, q) = queue();
1259 let path = q.path_of("20260101-000000-aaaa");
1260 std::fs::create_dir_all(q.root()).unwrap();
1261 std::fs::write(
1262 &path,
1263 serde_json::json!({
1264 "schema": SCHEMA,
1265 "id": "20260101-000000-aaaa",
1266 "title": "from before hold reasons existed",
1267 "instruction": "from before hold reasons existed",
1268 "repo": ".",
1269 "source": { "kind": "human" },
1270 "status": "held",
1271 "created_at": Timestamp::now().to_string(),
1272 "updated_at": Timestamp::now().to_string(),
1273 })
1274 .to_string(),
1275 )
1276 .unwrap();
1277
1278 let task = q.get("20260101-000000-aaaa").expect("must still read");
1279 assert!(task.hold_reason.is_none());
1280 assert!(task.operator_held());
1281 }
1282
1283 #[test]
1284 fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
1285 let (_dir, q) = queue();
1286 let path = q.path_of("20260101-000000-bbbb");
1287 std::fs::create_dir_all(q.root()).unwrap();
1288 std::fs::write(
1289 &path,
1290 serde_json::json!({
1291 "schema": 2,
1292 "id": "20260101-000000-bbbb",
1293 "title": "old manual recovery",
1294 "instruction": "old manual recovery",
1295 "repo": ".",
1296 "source": { "kind": "human" },
1297 "status": "held",
1298 "hold_reason": "active manual recovery run20260912-224242-daf5",
1299 "created_at": Timestamp::now().to_string(),
1300 "updated_at": Timestamp::now().to_string(),
1301 })
1302 .to_string(),
1303 )
1304 .unwrap();
1305
1306 let task = q.get("20260101-000000-bbbb").expect("must still read");
1307 assert_eq!(task.hold_source, None);
1308 assert!(task.operator_held());
1309 }
1310
1311 #[test]
1312 fn a_task_recorded_without_a_diagnostic_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 diagnostics existed",
1322 "instruction": "from before diagnostics 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.diagnostic.is_none());
1335 }
1336
1337 #[test]
1338 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
1339 let (_dir, q) = queue();
1343 let path = q.path_of("20260101-000000-aaaa");
1344 std::fs::create_dir_all(q.root()).unwrap();
1345 std::fs::write(
1346 &path,
1347 serde_json::json!({
1348 "schema": 1,
1349 "id": "20260101-000000-aaaa",
1350 "title": "from before blocking existed",
1351 "instruction": "from before blocking existed",
1352 "repo": ".",
1353 "source": { "kind": "human" },
1354 "status": "queued",
1355 "created_at": Timestamp::now().to_string(),
1356 "updated_at": Timestamp::now().to_string(),
1357 })
1358 .to_string(),
1359 )
1360 .unwrap();
1361
1362 let task = q.get("20260101-000000-aaaa").expect("must still read");
1363 assert!(task.blocked_by.is_empty());
1364 assert!(task.block_reason.is_none());
1365 assert!(task.answers.is_empty());
1366 assert!(task.review_branch.is_none());
1367 }
1368
1369 #[test]
1370 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
1371 let mut held = task("diagnosed");
1376 held.start("run-1".to_owned());
1377 held.fail("gate red", 1);
1378 held.diagnostic = Some("cargo test failed: ...".to_owned());
1379 assert_eq!(held.status, TaskStatus::Held);
1380
1381 held.release();
1382 assert!(held.diagnostic.is_none());
1383
1384 held.diagnostic = Some("cargo test failed: ...".to_owned());
1385 held.succeed();
1386 assert!(held.diagnostic.is_none());
1387 }
1388
1389 #[test]
1390 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
1391 let mut t = task("retried");
1392 t.start("run-1".to_owned());
1393 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
1394 t.fail("unrelated config error", 5);
1395 assert_eq!(t.status, TaskStatus::Failed);
1396 assert!(
1397 t.diagnostic.is_none(),
1398 "fail() must not let an old diagnostic outlive the run that produced it"
1399 );
1400 }
1401
1402 #[test]
1403 fn a_claim_is_exclusive_and_releases_on_drop() {
1404 let (_dir, q) = queue();
1405 let mut t = task("contended");
1406 q.put(&mut t).unwrap();
1407
1408 let held = q.claim(&t.id).unwrap();
1409 assert!(
1410 q.claim(&t.id).is_err(),
1411 "two daemons must not drive one task into two runs"
1412 );
1413 drop(held);
1414 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
1415 }
1416
1417 #[test]
1418 fn a_round_trip_survives_disk() {
1419 let (_dir, q) = queue();
1420 let mut t = Task::new(
1421 "titled".to_owned(),
1422 "body".to_owned(),
1423 PathBuf::from("/repo"),
1424 Source::Agent {
1425 run: "20260101-000000-beef".to_owned(),
1426 node: "implement".to_owned(),
1427 },
1428 );
1429 t.priority = 3;
1430 q.put(&mut t).unwrap();
1431
1432 let back = q.get(&t.id).unwrap();
1433 assert_eq!(back.id, t.id);
1434 assert_eq!(back.priority, 3);
1435 assert_eq!(back.source.label(), "implement@beef");
1436 assert_eq!(q.get(t.short()).unwrap().id, t.id);
1438 }
1439
1440 #[test]
1441 fn an_unreadable_task_does_not_take_the_queue_down() {
1442 let (_dir, q) = queue();
1443 let mut t = task("fine");
1444 q.put(&mut t).unwrap();
1445 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
1446
1447 let listed = q.list();
1448 assert_eq!(listed.len(), 1, "the readable task still lists");
1449 assert_eq!(listed[0].id, t.id);
1450 }
1451
1452 #[test]
1453 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
1454 let (_dir, q) = queue();
1455 let path = q.path_of("20260101-000000-aaaa");
1456 std::fs::create_dir_all(q.root()).unwrap();
1457 std::fs::write(
1458 &path,
1459 serde_json::json!({
1460 "schema": SCHEMA,
1461 "id": "20260101-000000-aaaa",
1462 "title": "from before solo existed",
1463 "instruction": "from before solo existed",
1464 "repo": ".",
1465 "source": { "kind": "human" },
1466 "status": "queued",
1467 "created_at": Timestamp::now().to_string(),
1468 "updated_at": Timestamp::now().to_string(),
1469 })
1470 .to_string(),
1471 )
1472 .unwrap();
1473
1474 let task = q.get("20260101-000000-aaaa").expect("must still read");
1475 assert!(!task.solo, "a queue file with no `solo` field means false");
1476 }
1477
1478 #[test]
1479 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
1480 let (_dir, q) = queue();
1481 let mut t = task("from the future");
1482 q.put(&mut t).unwrap();
1483 let path = q.path_of(&t.id);
1484 let body = std::fs::read_to_string(&path)
1485 .unwrap()
1486 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
1487 std::fs::write(&path, body).unwrap();
1488
1489 let err = q.get(&t.id).unwrap_err().to_string();
1490 assert!(err.contains("schema 99"), "{err}");
1491 }
1492
1493 #[test]
1494 fn revision_moves_when_the_queue_changes() {
1495 let (_dir, q) = queue();
1496 assert_eq!(q.revision(), 0, "an empty queue has no revision");
1497 let mut t = task("first");
1498 q.put(&mut t).unwrap();
1499 assert!(q.revision() > 0, "a written task moves the revision");
1500 }
1501
1502 #[test]
1503 fn revision_moves_when_deleting_an_older_task() {
1504 let (_dir, q) = queue();
1505 let mut t1 = task("older");
1506 q.put(&mut t1).unwrap();
1507 std::thread::sleep(std::time::Duration::from_millis(10));
1509 let mut t2 = task("newer");
1510 q.put(&mut t2).unwrap();
1511
1512 let rev_before = q.revision();
1513 q.remove(&t1.id, false).unwrap();
1514 let rev_after = q.revision();
1515
1516 assert_ne!(
1517 rev_before, rev_after,
1518 "deleting an older task must change the revision so other clients see the deletion"
1519 );
1520 }
1521
1522 #[test]
1523 fn removing_a_task_takes_it_out_of_the_listing() {
1524 let (_dir, q) = queue();
1525 let mut t = task("delete me");
1526 q.put(&mut t).unwrap();
1527 let removed = q.remove(t.short(), false).unwrap();
1528 assert_eq!(removed, t.id, "a prefix resolves before deleting");
1529 assert!(q.list().is_empty());
1530 assert!(
1531 q.remove(&t.id, false).is_err(),
1532 "removing twice is an error"
1533 );
1534 }
1535
1536 #[test]
1537 fn removing_a_task_takes_its_stale_lock_with_it() {
1538 let (_dir, q) = queue();
1539 let mut t = task("interrupted");
1540 q.put(&mut t).unwrap();
1541
1542 let claim = q.claim(&t.id).unwrap();
1545 std::mem::forget(claim);
1546 assert!(
1547 q.claim(&t.id).is_err(),
1548 "the orphaned lock is what makes the task look claimed"
1549 );
1550
1551 let err = q.remove(&t.id, true).unwrap_err().to_string();
1553 assert!(err.contains("live daemon"), "{err}");
1554 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
1555
1556 q.remove(&t.id, false).unwrap();
1558 assert!(q.list().is_empty());
1559 let mut again = task("interrupted");
1560 again.id = t.id.clone();
1561 q.put(&mut again).unwrap();
1562 assert!(
1563 q.claim(&t.id).is_ok(),
1564 "a task that comes back must be claimable, which a left-behind lock would prevent"
1565 );
1566 }
1567}