1use std::path::{Path, PathBuf};
34
35use anyhow::{Context, Result, bail};
36use jiff::Timestamp;
37use serde::{Deserialize, Serialize};
38
39pub const SCHEMA: u32 = 2;
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(tag = "kind", rename_all = "lowercase")]
56pub enum Source {
57 Human,
59 Agent {
62 run: String,
64 node: String,
66 },
67 Issue {
69 number: u64,
71 repo: String,
73 },
74}
75
76impl Source {
77 pub fn label(&self) -> String {
79 match self {
80 Self::Human => "human".to_owned(),
81 Self::Agent { run, node } => format!("{node}@{}", short(run)),
82 Self::Issue { number, .. } => format!("issue #{number}"),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum TaskStatus {
91 Queued,
93 Running,
95 Done,
97 Failed,
99 Held,
101 Blocked,
105}
106
107impl TaskStatus {
108 pub fn runnable(self) -> bool {
110 matches!(self, Self::Queued | Self::Failed)
111 }
112
113 pub fn as_str(self) -> &'static str {
115 match self {
116 Self::Queued => "queued",
117 Self::Running => "running",
118 Self::Done => "done",
119 Self::Failed => "failed",
120 Self::Held => "held",
121 Self::Blocked => "blocked",
122 }
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct Task {
130 pub schema: u32,
132 pub id: String,
134 pub title: String,
136 pub instruction: String,
138 pub repo: PathBuf,
140 pub source: Source,
142 #[serde(default)]
144 pub priority: i32,
145 #[serde(default)]
156 pub solo: bool,
157 pub status: TaskStatus,
159 #[serde(default)]
161 pub attempts: usize,
162 #[serde(default)]
164 pub runs: Vec<String>,
165 #[serde(default)]
167 pub last_error: Option<String>,
168 #[serde(default)]
181 pub hold_reason: Option<String>,
182 #[serde(default)]
194 pub diagnostic: Option<String>,
195 #[serde(default)]
205 pub blocked_by: Vec<String>,
206 #[serde(default)]
209 pub block_reason: Option<String>,
210 #[serde(default)]
221 pub answers: Vec<AnsweredQuestion>,
222 #[serde(default)]
234 pub review_branch: Option<String>,
235 #[serde(default)]
238 pub fresh_start: bool,
239 pub created_at: Timestamp,
241 pub updated_at: Timestamp,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct AnsweredQuestion {
249 pub question: String,
251 pub answer: String,
253}
254
255impl Task {
256 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
258 let now = Timestamp::now();
259 Self {
260 schema: SCHEMA,
261 id: new_id(),
262 title,
263 instruction,
264 repo,
265 source,
266 priority: 0,
267 solo: false,
268 status: TaskStatus::Queued,
269 attempts: 0,
270 runs: Vec::new(),
271 last_error: None,
272 hold_reason: None,
273 diagnostic: None,
274 blocked_by: Vec::new(),
275 block_reason: None,
276 answers: Vec::new(),
277 review_branch: None,
278 fresh_start: false,
279 created_at: now,
280 updated_at: now,
281 }
282 }
283
284 pub fn short(&self) -> &str {
286 short(&self.id)
287 }
288
289 pub fn start(&mut self, run: String) {
291 self.status = TaskStatus::Running;
292 self.attempts += 1;
293 self.runs.push(run);
294 self.last_error = None;
295 self.fresh_start = false;
296 }
297
298 pub fn succeed(&mut self) {
307 self.status = TaskStatus::Done;
308 self.last_error = None;
309 self.hold_reason = None;
310 self.diagnostic = None;
311 }
312
313 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
324 self.last_error = Some(why.into());
325 self.diagnostic = None;
326 self.status = if self.attempts >= max_attempts {
327 TaskStatus::Held
328 } else {
329 TaskStatus::Failed
330 };
331 }
332
333 pub fn stall(&mut self, why: impl Into<String>) {
343 self.last_error = Some(why.into());
344 self.diagnostic = None;
345 self.attempts = self.attempts.saturating_sub(1);
346 self.status = TaskStatus::Failed;
347 }
348
349 pub fn hold(&mut self, reason: Option<String>) {
356 self.status = TaskStatus::Held;
357 if reason.is_some() {
358 self.hold_reason = reason;
359 }
360 }
361
362 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
366 self.status = TaskStatus::Blocked;
367 self.blocked_by = blocked_by;
368 self.block_reason = reason;
369 }
370
371 pub fn unblock(&mut self, resolved_id: &str) {
383 if self.status != TaskStatus::Blocked {
384 return;
385 }
386 self.blocked_by.retain(|id| id != resolved_id);
387 if self.blocked_by.is_empty() {
388 self.status = TaskStatus::Queued;
389 self.block_reason = None;
390 }
391 }
392
393 pub fn record_answer(&mut self, question: String, answer: String) {
398 self.answers.push(AnsweredQuestion { question, answer });
399 }
400
401 pub fn request_review(&mut self, branch: String) {
405 self.release();
406 self.review_branch = Some(branch);
407 }
408
409 pub fn requeue(&mut self) {
412 self.release();
413 self.fresh_start = true;
414 }
415
416 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
425 if self.status == TaskStatus::Running {
426 bail!(
427 "task {} is running; its priority cannot be changed until \
428 this attempt finishes",
429 self.short()
430 );
431 }
432 self.priority = priority;
433 Ok(())
434 }
435
436 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
448 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
449 bail!(
450 "task {} is {}; only a queued or held task's instruction can \
451 be edited",
452 self.short(),
453 self.status.as_str()
454 );
455 }
456 self.title = title;
457 self.instruction = instruction;
458 Ok(())
459 }
460
461 pub fn handed_off(&mut self, why: impl Into<String>) {
473 self.last_error = Some(why.into());
474 self.diagnostic = None;
475 self.status = TaskStatus::Held;
476 }
477
478 pub fn release(&mut self) {
482 self.status = TaskStatus::Queued;
483 self.attempts = 0;
484 self.last_error = None;
485 self.hold_reason = None;
488 self.diagnostic = None;
489 self.blocked_by.clear();
494 self.block_reason = None;
495 self.review_branch = None;
496 self.fresh_start = false;
497 }
498}
499
500#[derive(Debug, Clone)]
502pub struct Queue {
503 root: PathBuf,
504}
505
506impl Queue {
507 pub fn open() -> Self {
509 Self::at(crate::run::home().join("queue"))
510 }
511
512 pub fn at(root: PathBuf) -> Self {
515 Self { root }
516 }
517
518 pub fn root(&self) -> &Path {
520 &self.root
521 }
522
523 pub fn path_of(&self, id: &str) -> PathBuf {
525 self.root.join(format!("{id}.json"))
526 }
527
528 pub fn put(&self, task: &mut Task) -> Result<()> {
531 task.updated_at = Timestamp::now();
532 std::fs::create_dir_all(&self.root)
533 .with_context(|| format!("create {}", self.root.display()))?;
534 let body = serde_json::to_string_pretty(task).context("serialize task")?;
535 let path = self.path_of(&task.id);
536 let tmp = path.with_extension("json.tmp");
537 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
538 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
539 Ok(())
540 }
541
542 pub fn get(&self, id: &str) -> Result<Task> {
544 let resolved = self.resolve_id(id)?;
545 read_path(&self.path_of(&resolved))
546 }
547
548 pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
562 let resolved = self.resolve_id(id)?;
563 if in_flight {
564 bail!("task {resolved} is being run by a live daemon right now");
565 }
566 let path = self.path_of(&resolved);
567 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
568 let lock = self.lock_path(&resolved);
569 if let Err(e) = std::fs::remove_file(&lock) {
570 if e.kind() != std::io::ErrorKind::NotFound {
571 return Err(e).with_context(|| format!("remove {}", lock.display()));
572 }
573 }
574 Ok(resolved)
575 }
576
577 fn lock_path(&self, id: &str) -> PathBuf {
580 self.root.join(format!("{id}.lock"))
581 }
582
583 pub fn list(&self) -> Vec<Task> {
596 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
597 .into_iter()
598 .flatten()
599 .flatten()
600 .map(|e| e.path())
601 .filter(|p| p.extension().is_some_and(|x| x == "json"))
602 .filter_map(|p| read_path(&p).ok())
603 .collect();
604 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
605 tasks
606 }
607
608 pub fn next_runnable(&self) -> Option<Task> {
613 let mut runnable: Vec<Task> = self
614 .list()
615 .into_iter()
616 .filter(|t| t.status.runnable())
617 .collect();
618 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
619 runnable.into_iter().next()
620 }
621
622 pub fn claim(&self, id: &str) -> Result<Claim> {
629 std::fs::create_dir_all(&self.root)
630 .with_context(|| format!("create {}", self.root.display()))?;
631 let path = self.lock_path(id);
632 match std::fs::OpenOptions::new()
633 .write(true)
634 .create_new(true)
635 .open(&path)
636 {
637 Ok(mut f) => {
638 use std::io::Write as _;
639 let _ = writeln!(f, "{}", std::process::id());
641 Ok(Claim { path })
642 }
643 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
644 bail!("task {id} is already claimed ({} exists)", path.display())
645 }
646 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
647 }
648 }
649
650 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
652 if self.path_of(prefix).is_file() {
653 return Ok(prefix.to_owned());
654 }
655 let hits: Vec<String> = self
656 .list()
657 .into_iter()
658 .map(|t| t.id)
659 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
660 .collect();
661 match hits.len() {
662 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
663 0 => bail!("no task matches `{prefix}`"),
664 _ => bail!(
665 "`{prefix}` matches {} tasks: {}",
666 hits.len(),
667 hits.join(", ")
668 ),
669 }
670 }
671
672 pub fn revision(&self) -> u64 {
679 use std::hash::{Hash as _, Hasher as _};
680
681 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
682 .into_iter()
683 .flatten()
684 .flatten()
685 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
686 .filter_map(|e| {
687 let name = e.file_name().to_string_lossy().into_owned();
688 let mtime = e
689 .metadata()
690 .ok()?
691 .modified()
692 .ok()?
693 .duration_since(std::time::UNIX_EPOCH)
694 .ok()?
695 .as_millis() as u64;
696 Some((name, mtime))
697 })
698 .collect();
699
700 if entries.is_empty() {
701 return 0;
702 }
703
704 entries.sort_unstable();
705 let mut hasher = std::hash::DefaultHasher::new();
706 for (name, mtime) in &entries {
707 name.hash(&mut hasher);
708 mtime.hash(&mut hasher);
709 }
710 let h = hasher.finish();
711 if h == 0 { 1 } else { h }
712 }
713}
714
715#[derive(Debug)]
717pub struct Claim {
718 path: PathBuf,
719}
720
721impl Drop for Claim {
722 fn drop(&mut self) {
723 let _ = std::fs::remove_file(&self.path);
724 }
725}
726
727pub fn title_from(instruction: &str, max: usize) -> String {
730 let line = instruction
736 .lines()
737 .map(str::trim)
738 .find(|l| !l.is_empty())
739 .unwrap_or("(empty task)")
740 .trim_start_matches(['#', '-', '*', '>', ' '])
741 .trim();
742 if line.is_empty() {
743 return "(empty task)".to_owned();
744 }
745 if line.chars().count() <= max {
746 return line.to_owned();
747 }
748 let head: String = line.chars().take(max.saturating_sub(1)).collect();
749 format!("{head}…")
750}
751
752fn read_path(path: &Path) -> Result<Task> {
753 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
754 let task: Task =
755 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
756 if task.schema > SCHEMA {
762 bail!(
763 "task {} was written by a different magi (schema {}, this build \
764 speaks {SCHEMA})",
765 task.id,
766 task.schema
767 );
768 }
769 Ok(task)
770}
771
772fn short(id: &str) -> &str {
773 id.split('-').next_back().unwrap_or(id)
774}
775
776fn new_id() -> String {
777 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
778 let seed = crate::rng::entropy();
779 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 fn queue() -> (tempfile::TempDir, Queue) {
789 let dir = tempfile::tempdir().unwrap();
790 let q = Queue::at(dir.path().join("queue"));
791 (dir, q)
792 }
793
794 fn task(title: &str) -> Task {
795 Task::new(
796 title.to_owned(),
797 format!("do {title}"),
798 PathBuf::from("."),
799 Source::Human,
800 )
801 }
802
803 #[test]
804 fn a_markdown_heading_is_the_title_not_decoration() {
805 assert_eq!(
810 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
811 "Rework the config loader"
812 );
813 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
814 assert_eq!(title_from("> quoted task", 40), "quoted task");
815 assert_eq!(title_from(" \n\n", 40), "(empty task)");
817 assert_eq!(title_from("###\n", 40), "(empty task)");
818 }
819
820 #[test]
821 fn a_long_title_is_elided_by_characters_not_bytes() {
822 let long = "課題".repeat(30);
824 let title = title_from(&long, 10);
825 assert_eq!(title.chars().count(), 10);
826 assert!(title.ends_with('…'));
827 }
828
829 #[test]
830 fn priority_wins_and_ties_break_oldest_first() {
831 let (_dir, q) = queue();
832 let mut a = task("first");
833 let mut b = task("second");
834 let mut c = task("urgent");
835 a.id = "20260101-000001-aaaa".to_owned();
837 b.id = "20260101-000002-bbbb".to_owned();
838 c.id = "20260101-000003-cccc".to_owned();
839 c.priority = 5;
840 for t in [&mut a, &mut b, &mut c] {
841 q.put(t).unwrap();
842 }
843
844 assert_eq!(q.next_runnable().unwrap().id, c.id);
846 c.hold(None);
847 q.put(&mut c).unwrap();
848 assert_eq!(q.next_runnable().unwrap().id, a.id);
850 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
851 }
852
853 #[test]
854 fn a_blocked_task_never_starves_another_runnable_one() {
855 let (_dir, q) = queue();
856 let mut blocked = task("blocked");
857 blocked.block(vec!["something".to_owned()], None);
858 q.put(&mut blocked).unwrap();
859
860 let mut runnable = task("free to go");
861 q.put(&mut runnable).unwrap();
862
863 let next = q.next_runnable().expect("a runnable task is still offered");
864 assert_eq!(next.id, runnable.id);
865 }
866
867 #[test]
868 fn a_held_task_is_never_offered_to_the_loop() {
869 let (_dir, q) = queue();
870 let mut t = task("held");
871 q.put(&mut t).unwrap();
872 assert!(q.next_runnable().is_some());
873
874 t.hold(None);
875 q.put(&mut t).unwrap();
876 assert!(
877 q.next_runnable().is_none(),
878 "a held task must wait for a human"
879 );
880
881 t.status = TaskStatus::Failed;
883 q.put(&mut t).unwrap();
884 assert!(q.next_runnable().is_some());
885 }
886
887 #[test]
888 fn attempts_are_capped_and_then_the_task_is_held() {
889 let mut t = task("doomed");
890
891 t.start("run-1".to_owned());
892 t.fail("gate red", 2);
893 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
894
895 t.start("run-2".to_owned());
896 t.fail("gate red", 2);
897 assert_eq!(
898 t.status,
899 TaskStatus::Held,
900 "out of attempts: stop spending money on it"
901 );
902 assert_eq!(t.runs, ["run-1", "run-2"]);
903 assert_eq!(t.last_error.as_deref(), Some("gate red"));
904 }
905
906 #[test]
907 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
908 let mut t = task("stalled by quota");
909
910 t.start("run-1".to_owned());
911 assert_eq!(t.attempts, 1);
912 t.stall("judge-1, judge-2 out of quota");
913 assert_eq!(
914 t.attempts, 0,
915 "a closed quota window must not spend the task's retry budget"
916 );
917 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
918 assert_eq!(
919 t.last_error.as_deref(),
920 Some("judge-1, judge-2 out of quota")
921 );
922
923 for _ in 0..20 {
926 t.start("run-n".to_owned());
927 t.stall("still out of quota");
928 }
929 t.start("run-real".to_owned());
930 t.fail("gate red", 2);
931 assert_eq!(
932 t.status,
933 TaskStatus::Failed,
934 "the first attempt that was really judged is attempt one"
935 );
936 }
937
938 #[test]
939 fn releasing_a_held_task_gives_it_a_real_second_chance() {
940 let mut t = task("retry me");
941 t.start("run-1".to_owned());
942 t.fail("gate red", 1);
943 assert_eq!(t.status, TaskStatus::Held);
944
945 t.release();
946 assert_eq!(t.status, TaskStatus::Queued);
947 assert_eq!(t.attempts, 0);
950 assert!(t.last_error.is_none());
951 assert_eq!(
952 t.runs.len(),
953 1,
954 "history is kept: attempts reset, evidence does not"
955 );
956 }
957
958 #[test]
959 fn a_hold_reason_survives_and_a_release_clears_it() {
960 let mut t = task("waiting on something else");
961 t.hold(Some(
962 "waiting for 20260101-000000-aaaa to land first".to_owned(),
963 ));
964 assert_eq!(t.status, TaskStatus::Held);
965 assert_eq!(
966 t.hold_reason.as_deref(),
967 Some("waiting for 20260101-000000-aaaa to land first")
968 );
969
970 t.hold(None);
972 assert_eq!(
973 t.hold_reason.as_deref(),
974 Some("waiting for 20260101-000000-aaaa to land first"),
975 "a bare re-hold keeps whatever a human already wrote down"
976 );
977
978 let mut plain = task("no reason given");
980 plain.hold(None);
981 assert_eq!(plain.status, TaskStatus::Held);
982 assert!(plain.hold_reason.is_none());
983
984 t.release();
985 assert_eq!(t.status, TaskStatus::Queued);
986 assert!(
987 t.hold_reason.is_none(),
988 "a stale reason must not greet the next person who holds this task"
989 );
990 }
991
992 #[test]
993 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
994 let mut t = task("landed by hand while held");
999 t.hold(Some("waiting on 3ed9".to_owned()));
1000 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
1001
1002 t.succeed();
1003 assert_eq!(t.status, TaskStatus::Done);
1004 assert!(
1005 t.hold_reason.is_none(),
1006 "a done task cannot still be waiting on something"
1007 );
1008 }
1009
1010 #[test]
1011 fn a_blocked_task_is_never_offered_to_the_loop() {
1012 let mut t = task("blocked");
1013 assert!(t.status.runnable());
1014 t.block(
1015 vec!["dep-id".to_owned()],
1016 Some("waits on dep-id".to_owned()),
1017 );
1018 assert_eq!(t.status, TaskStatus::Blocked);
1019 assert!(!t.status.runnable());
1020 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
1021 }
1022
1023 #[test]
1024 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
1025 let mut t = task("blocked on two");
1026 t.block(
1027 vec!["a".to_owned(), "b".to_owned()],
1028 Some("waits on a and b".to_owned()),
1029 );
1030
1031 t.unblock("a");
1032 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
1033 assert_eq!(t.blocked_by, ["b"]);
1034
1035 t.unblock("b");
1036 assert_eq!(t.status, TaskStatus::Queued);
1037 assert!(t.blocked_by.is_empty());
1038 assert!(t.block_reason.is_none());
1039 }
1040
1041 #[test]
1042 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
1043 let mut t = task("never blocked");
1044 t.unblock("whatever");
1045 assert_eq!(t.status, TaskStatus::Queued);
1046 }
1047
1048 #[test]
1049 fn answering_a_question_is_recorded_and_survives_a_release() {
1050 let mut t = task("asked something");
1051 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
1052 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
1053 t.unblock("q1");
1054 assert_eq!(t.status, TaskStatus::Queued);
1055 assert_eq!(t.answers.len(), 1);
1056 assert_eq!(t.answers[0].answer, "SQLite");
1057
1058 t.release();
1062 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
1063 }
1064
1065 #[test]
1066 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
1067 let mut t = task("blocked run with a surviving branch");
1068 t.start("run-1".to_owned());
1069 t.fail("blocked with major findings", 5);
1070 assert_eq!(t.status, TaskStatus::Failed);
1071
1072 t.request_review("magi/eba2/A".to_owned());
1073 assert_eq!(t.status, TaskStatus::Queued);
1074 assert_eq!(t.attempts, 0);
1075 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
1076
1077 t.release();
1079 assert!(t.review_branch.is_none());
1080 }
1081
1082 #[test]
1083 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
1084 let mut t = task("retry");
1085 t.start("run-1".to_owned());
1086 t.requeue();
1087 assert!(t.fresh_start);
1088
1089 t.release();
1090 assert!(!t.fresh_start);
1091 }
1092
1093 #[test]
1094 fn priority_can_be_changed_while_queued_but_not_while_running() {
1095 let mut t = task("reprioritise me");
1096 t.set_priority(5).unwrap();
1097 assert_eq!(t.priority, 5);
1098
1099 t.start("run-1".to_owned());
1100 let err = t.set_priority(9).unwrap_err().to_string();
1101 assert!(err.contains("running"), "{err}");
1102 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
1103 }
1104
1105 #[test]
1106 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
1107 let (_dir, q) = queue();
1108 let mut a = task("first filed");
1109 let mut b = task("second filed");
1110 a.id = "20260101-000001-aaaa".to_owned();
1111 b.id = "20260101-000002-bbbb".to_owned();
1112 q.put(&mut a).unwrap();
1113 q.put(&mut b).unwrap();
1114
1115 assert_eq!(
1116 q.next_runnable().unwrap().id,
1117 a.id,
1118 "with equal priority the older task goes first, so a burst of \
1119 new work cannot starve it"
1120 );
1121 assert_eq!(
1122 q.list()[0].id,
1123 b.id,
1124 "but the list an operator reads is newest first, the same as \
1125 before priority existed - a's turn to run does not make it the \
1126 newest task"
1127 );
1128
1129 let mut a = q.get(&a.id).unwrap();
1130 a.set_priority(10).unwrap();
1131 q.put(&mut a).unwrap();
1132
1133 assert_eq!(
1134 q.next_runnable().unwrap().id,
1135 a.id,
1136 "a raised priority must be reflected the moment it is saved"
1137 );
1138 assert_eq!(
1142 q.list()[0].id,
1143 a.id,
1144 "the raised task must sort first in the list an operator reads, \
1145 not only in next_runnable's own ordering"
1146 );
1147 }
1148
1149 #[test]
1150 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
1151 let mut t = Task::new(
1152 "old title".to_owned(),
1153 "old instruction".to_owned(),
1154 PathBuf::from("/repo"),
1155 Source::Agent {
1156 run: "20260101-000000-beef".to_owned(),
1157 node: "implement".to_owned(),
1158 },
1159 );
1160 let id = t.id.clone();
1161 let created_at = t.created_at;
1162 t.runs.push("20260101-000000-beef".to_owned());
1163
1164 t.edit("new title".to_owned(), "new instruction".to_owned())
1165 .unwrap();
1166
1167 assert_eq!(t.title, "new title");
1168 assert_eq!(t.instruction, "new instruction");
1169 assert_eq!(t.id, id, "editing must not mint a new id");
1170 assert_eq!(t.created_at, created_at);
1171 assert_eq!(
1172 t.source,
1173 Source::Agent {
1174 run: "20260101-000000-beef".to_owned(),
1175 node: "implement".to_owned(),
1176 },
1177 "editing must not turn agent attribution into human"
1178 );
1179 assert_eq!(t.runs, ["20260101-000000-beef"]);
1180 }
1181
1182 #[test]
1183 fn editing_is_refused_once_a_task_is_running_or_finished() {
1184 let mut running = task("in flight");
1185 running.start("run-1".to_owned());
1186 let err = running
1187 .edit("x".to_owned(), "y".to_owned())
1188 .unwrap_err()
1189 .to_string();
1190 assert!(err.contains("running"), "{err}");
1191
1192 let mut done = task("finished");
1193 done.succeed();
1194 let err = done
1195 .edit("x".to_owned(), "y".to_owned())
1196 .unwrap_err()
1197 .to_string();
1198 assert!(err.contains("done"), "{err}");
1199
1200 let mut queued = task("waiting");
1202 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
1203 let mut held = task("parked");
1204 held.hold(None);
1205 held.edit("x".to_owned(), "y".to_owned()).unwrap();
1206 }
1207
1208 #[test]
1209 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
1210 let (_dir, q) = queue();
1211 let path = q.path_of("20260101-000000-aaaa");
1212 std::fs::create_dir_all(q.root()).unwrap();
1213 std::fs::write(
1214 &path,
1215 serde_json::json!({
1216 "schema": SCHEMA,
1217 "id": "20260101-000000-aaaa",
1218 "title": "from before hold reasons existed",
1219 "instruction": "from before hold reasons existed",
1220 "repo": ".",
1221 "source": { "kind": "human" },
1222 "status": "held",
1223 "created_at": Timestamp::now().to_string(),
1224 "updated_at": Timestamp::now().to_string(),
1225 })
1226 .to_string(),
1227 )
1228 .unwrap();
1229
1230 let task = q.get("20260101-000000-aaaa").expect("must still read");
1231 assert!(task.hold_reason.is_none());
1232 }
1233
1234 #[test]
1235 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
1236 let (_dir, q) = queue();
1237 let path = q.path_of("20260101-000000-aaaa");
1238 std::fs::create_dir_all(q.root()).unwrap();
1239 std::fs::write(
1240 &path,
1241 serde_json::json!({
1242 "schema": SCHEMA,
1243 "id": "20260101-000000-aaaa",
1244 "title": "from before diagnostics existed",
1245 "instruction": "from before diagnostics existed",
1246 "repo": ".",
1247 "source": { "kind": "human" },
1248 "status": "held",
1249 "created_at": Timestamp::now().to_string(),
1250 "updated_at": Timestamp::now().to_string(),
1251 })
1252 .to_string(),
1253 )
1254 .unwrap();
1255
1256 let task = q.get("20260101-000000-aaaa").expect("must still read");
1257 assert!(task.diagnostic.is_none());
1258 }
1259
1260 #[test]
1261 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
1262 let (_dir, q) = queue();
1266 let path = q.path_of("20260101-000000-aaaa");
1267 std::fs::create_dir_all(q.root()).unwrap();
1268 std::fs::write(
1269 &path,
1270 serde_json::json!({
1271 "schema": 1,
1272 "id": "20260101-000000-aaaa",
1273 "title": "from before blocking existed",
1274 "instruction": "from before blocking existed",
1275 "repo": ".",
1276 "source": { "kind": "human" },
1277 "status": "queued",
1278 "created_at": Timestamp::now().to_string(),
1279 "updated_at": Timestamp::now().to_string(),
1280 })
1281 .to_string(),
1282 )
1283 .unwrap();
1284
1285 let task = q.get("20260101-000000-aaaa").expect("must still read");
1286 assert!(task.blocked_by.is_empty());
1287 assert!(task.block_reason.is_none());
1288 assert!(task.answers.is_empty());
1289 assert!(task.review_branch.is_none());
1290 }
1291
1292 #[test]
1293 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
1294 let mut held = task("diagnosed");
1299 held.start("run-1".to_owned());
1300 held.fail("gate red", 1);
1301 held.diagnostic = Some("cargo test failed: ...".to_owned());
1302 assert_eq!(held.status, TaskStatus::Held);
1303
1304 held.release();
1305 assert!(held.diagnostic.is_none());
1306
1307 held.diagnostic = Some("cargo test failed: ...".to_owned());
1308 held.succeed();
1309 assert!(held.diagnostic.is_none());
1310 }
1311
1312 #[test]
1313 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
1314 let mut t = task("retried");
1315 t.start("run-1".to_owned());
1316 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
1317 t.fail("unrelated config error", 5);
1318 assert_eq!(t.status, TaskStatus::Failed);
1319 assert!(
1320 t.diagnostic.is_none(),
1321 "fail() must not let an old diagnostic outlive the run that produced it"
1322 );
1323 }
1324
1325 #[test]
1326 fn a_claim_is_exclusive_and_releases_on_drop() {
1327 let (_dir, q) = queue();
1328 let mut t = task("contended");
1329 q.put(&mut t).unwrap();
1330
1331 let held = q.claim(&t.id).unwrap();
1332 assert!(
1333 q.claim(&t.id).is_err(),
1334 "two daemons must not drive one task into two runs"
1335 );
1336 drop(held);
1337 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
1338 }
1339
1340 #[test]
1341 fn a_round_trip_survives_disk() {
1342 let (_dir, q) = queue();
1343 let mut t = Task::new(
1344 "titled".to_owned(),
1345 "body".to_owned(),
1346 PathBuf::from("/repo"),
1347 Source::Agent {
1348 run: "20260101-000000-beef".to_owned(),
1349 node: "implement".to_owned(),
1350 },
1351 );
1352 t.priority = 3;
1353 q.put(&mut t).unwrap();
1354
1355 let back = q.get(&t.id).unwrap();
1356 assert_eq!(back.id, t.id);
1357 assert_eq!(back.priority, 3);
1358 assert_eq!(back.source.label(), "implement@beef");
1359 assert_eq!(q.get(t.short()).unwrap().id, t.id);
1361 }
1362
1363 #[test]
1364 fn an_unreadable_task_does_not_take_the_queue_down() {
1365 let (_dir, q) = queue();
1366 let mut t = task("fine");
1367 q.put(&mut t).unwrap();
1368 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
1369
1370 let listed = q.list();
1371 assert_eq!(listed.len(), 1, "the readable task still lists");
1372 assert_eq!(listed[0].id, t.id);
1373 }
1374
1375 #[test]
1376 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
1377 let (_dir, q) = queue();
1378 let path = q.path_of("20260101-000000-aaaa");
1379 std::fs::create_dir_all(q.root()).unwrap();
1380 std::fs::write(
1381 &path,
1382 serde_json::json!({
1383 "schema": SCHEMA,
1384 "id": "20260101-000000-aaaa",
1385 "title": "from before solo existed",
1386 "instruction": "from before solo existed",
1387 "repo": ".",
1388 "source": { "kind": "human" },
1389 "status": "queued",
1390 "created_at": Timestamp::now().to_string(),
1391 "updated_at": Timestamp::now().to_string(),
1392 })
1393 .to_string(),
1394 )
1395 .unwrap();
1396
1397 let task = q.get("20260101-000000-aaaa").expect("must still read");
1398 assert!(!task.solo, "a queue file with no `solo` field means false");
1399 }
1400
1401 #[test]
1402 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
1403 let (_dir, q) = queue();
1404 let mut t = task("from the future");
1405 q.put(&mut t).unwrap();
1406 let path = q.path_of(&t.id);
1407 let body = std::fs::read_to_string(&path)
1408 .unwrap()
1409 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
1410 std::fs::write(&path, body).unwrap();
1411
1412 let err = q.get(&t.id).unwrap_err().to_string();
1413 assert!(err.contains("schema 99"), "{err}");
1414 }
1415
1416 #[test]
1417 fn revision_moves_when_the_queue_changes() {
1418 let (_dir, q) = queue();
1419 assert_eq!(q.revision(), 0, "an empty queue has no revision");
1420 let mut t = task("first");
1421 q.put(&mut t).unwrap();
1422 assert!(q.revision() > 0, "a written task moves the revision");
1423 }
1424
1425 #[test]
1426 fn revision_moves_when_deleting_an_older_task() {
1427 let (_dir, q) = queue();
1428 let mut t1 = task("older");
1429 q.put(&mut t1).unwrap();
1430 std::thread::sleep(std::time::Duration::from_millis(10));
1432 let mut t2 = task("newer");
1433 q.put(&mut t2).unwrap();
1434
1435 let rev_before = q.revision();
1436 q.remove(&t1.id, false).unwrap();
1437 let rev_after = q.revision();
1438
1439 assert_ne!(
1440 rev_before, rev_after,
1441 "deleting an older task must change the revision so other clients see the deletion"
1442 );
1443 }
1444
1445 #[test]
1446 fn removing_a_task_takes_it_out_of_the_listing() {
1447 let (_dir, q) = queue();
1448 let mut t = task("delete me");
1449 q.put(&mut t).unwrap();
1450 let removed = q.remove(t.short(), false).unwrap();
1451 assert_eq!(removed, t.id, "a prefix resolves before deleting");
1452 assert!(q.list().is_empty());
1453 assert!(
1454 q.remove(&t.id, false).is_err(),
1455 "removing twice is an error"
1456 );
1457 }
1458
1459 #[test]
1460 fn removing_a_task_takes_its_stale_lock_with_it() {
1461 let (_dir, q) = queue();
1462 let mut t = task("interrupted");
1463 q.put(&mut t).unwrap();
1464
1465 let claim = q.claim(&t.id).unwrap();
1468 std::mem::forget(claim);
1469 assert!(
1470 q.claim(&t.id).is_err(),
1471 "the orphaned lock is what makes the task look claimed"
1472 );
1473
1474 let err = q.remove(&t.id, true).unwrap_err().to_string();
1476 assert!(err.contains("live daemon"), "{err}");
1477 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
1478
1479 q.remove(&t.id, false).unwrap();
1481 assert!(q.list().is_empty());
1482 let mut again = task("interrupted");
1483 again.id = t.id.clone();
1484 q.put(&mut again).unwrap();
1485 assert!(
1486 q.claim(&t.id).is_ok(),
1487 "a task that comes back must be claimable, which a left-behind lock would prevent"
1488 );
1489 }
1490}