1use std::path::{Path, PathBuf};
34
35use anyhow::{Context, Result, bail};
36use jiff::Timestamp;
37use serde::{Deserialize, Serialize};
38
39pub const SCHEMA: u32 = 1;
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "kind", rename_all = "lowercase")]
46pub enum Source {
47 Human,
49 Agent {
52 run: String,
54 node: String,
56 },
57 Issue {
59 number: u64,
61 repo: String,
63 },
64}
65
66impl Source {
67 pub fn label(&self) -> String {
69 match self {
70 Self::Human => "human".to_owned(),
71 Self::Agent { run, node } => format!("{node}@{}", short(run)),
72 Self::Issue { number, .. } => format!("issue #{number}"),
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum TaskStatus {
81 Queued,
83 Running,
85 Done,
87 Failed,
89 Held,
91}
92
93impl TaskStatus {
94 pub fn runnable(self) -> bool {
96 matches!(self, Self::Queued | Self::Failed)
97 }
98
99 pub fn as_str(self) -> &'static str {
101 match self {
102 Self::Queued => "queued",
103 Self::Running => "running",
104 Self::Done => "done",
105 Self::Failed => "failed",
106 Self::Held => "held",
107 }
108 }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct Task {
115 pub schema: u32,
117 pub id: String,
119 pub title: String,
121 pub instruction: String,
123 pub repo: PathBuf,
125 pub source: Source,
127 #[serde(default)]
129 pub priority: i32,
130 #[serde(default)]
141 pub solo: bool,
142 pub status: TaskStatus,
144 #[serde(default)]
146 pub attempts: usize,
147 #[serde(default)]
149 pub runs: Vec<String>,
150 #[serde(default)]
152 pub last_error: Option<String>,
153 #[serde(default)]
166 pub hold_reason: Option<String>,
167 #[serde(default)]
179 pub diagnostic: Option<String>,
180 pub created_at: Timestamp,
182 pub updated_at: Timestamp,
184}
185
186impl Task {
187 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
189 let now = Timestamp::now();
190 Self {
191 schema: SCHEMA,
192 id: new_id(),
193 title,
194 instruction,
195 repo,
196 source,
197 priority: 0,
198 solo: false,
199 status: TaskStatus::Queued,
200 attempts: 0,
201 runs: Vec::new(),
202 last_error: None,
203 hold_reason: None,
204 diagnostic: None,
205 created_at: now,
206 updated_at: now,
207 }
208 }
209
210 pub fn short(&self) -> &str {
212 short(&self.id)
213 }
214
215 pub fn start(&mut self, run: String) {
217 self.status = TaskStatus::Running;
218 self.attempts += 1;
219 self.runs.push(run);
220 self.last_error = None;
221 }
222
223 pub fn succeed(&mut self) {
232 self.status = TaskStatus::Done;
233 self.last_error = None;
234 self.hold_reason = None;
235 self.diagnostic = None;
236 }
237
238 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
249 self.last_error = Some(why.into());
250 self.diagnostic = None;
251 self.status = if self.attempts >= max_attempts {
252 TaskStatus::Held
253 } else {
254 TaskStatus::Failed
255 };
256 }
257
258 pub fn stall(&mut self, why: impl Into<String>) {
268 self.last_error = Some(why.into());
269 self.diagnostic = None;
270 self.attempts = self.attempts.saturating_sub(1);
271 self.status = TaskStatus::Failed;
272 }
273
274 pub fn hold(&mut self, reason: Option<String>) {
281 self.status = TaskStatus::Held;
282 if reason.is_some() {
283 self.hold_reason = reason;
284 }
285 }
286
287 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
296 if self.status == TaskStatus::Running {
297 bail!(
298 "task {} is running; its priority cannot be changed until \
299 this attempt finishes",
300 self.short()
301 );
302 }
303 self.priority = priority;
304 Ok(())
305 }
306
307 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
319 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
320 bail!(
321 "task {} is {}; only a queued or held task's instruction can \
322 be edited",
323 self.short(),
324 self.status.as_str()
325 );
326 }
327 self.title = title;
328 self.instruction = instruction;
329 Ok(())
330 }
331
332 pub fn handed_off(&mut self, why: impl Into<String>) {
344 self.last_error = Some(why.into());
345 self.diagnostic = None;
346 self.status = TaskStatus::Held;
347 }
348
349 pub fn release(&mut self) {
353 self.status = TaskStatus::Queued;
354 self.attempts = 0;
355 self.last_error = None;
356 self.hold_reason = None;
359 self.diagnostic = None;
360 }
361}
362
363#[derive(Debug, Clone)]
365pub struct Queue {
366 root: PathBuf,
367}
368
369impl Queue {
370 pub fn open() -> Self {
372 Self::at(crate::run::home().join("queue"))
373 }
374
375 pub fn at(root: PathBuf) -> Self {
378 Self { root }
379 }
380
381 pub fn root(&self) -> &Path {
383 &self.root
384 }
385
386 pub fn path_of(&self, id: &str) -> PathBuf {
388 self.root.join(format!("{id}.json"))
389 }
390
391 pub fn put(&self, task: &mut Task) -> Result<()> {
394 task.updated_at = Timestamp::now();
395 std::fs::create_dir_all(&self.root)
396 .with_context(|| format!("create {}", self.root.display()))?;
397 let body = serde_json::to_string_pretty(task).context("serialize task")?;
398 let path = self.path_of(&task.id);
399 let tmp = path.with_extension("json.tmp");
400 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
401 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
402 Ok(())
403 }
404
405 pub fn get(&self, id: &str) -> Result<Task> {
407 let resolved = self.resolve_id(id)?;
408 read_path(&self.path_of(&resolved))
409 }
410
411 pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
425 let resolved = self.resolve_id(id)?;
426 if in_flight {
427 bail!("task {resolved} is being run by a live daemon right now");
428 }
429 let path = self.path_of(&resolved);
430 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
431 let lock = self.lock_path(&resolved);
432 if let Err(e) = std::fs::remove_file(&lock) {
433 if e.kind() != std::io::ErrorKind::NotFound {
434 return Err(e).with_context(|| format!("remove {}", lock.display()));
435 }
436 }
437 Ok(resolved)
438 }
439
440 fn lock_path(&self, id: &str) -> PathBuf {
443 self.root.join(format!("{id}.lock"))
444 }
445
446 pub fn list(&self) -> Vec<Task> {
459 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
460 .into_iter()
461 .flatten()
462 .flatten()
463 .map(|e| e.path())
464 .filter(|p| p.extension().is_some_and(|x| x == "json"))
465 .filter_map(|p| read_path(&p).ok())
466 .collect();
467 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
468 tasks
469 }
470
471 pub fn next_runnable(&self) -> Option<Task> {
476 let mut runnable: Vec<Task> = self
477 .list()
478 .into_iter()
479 .filter(|t| t.status.runnable())
480 .collect();
481 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
482 runnable.into_iter().next()
483 }
484
485 pub fn claim(&self, id: &str) -> Result<Claim> {
492 std::fs::create_dir_all(&self.root)
493 .with_context(|| format!("create {}", self.root.display()))?;
494 let path = self.lock_path(id);
495 match std::fs::OpenOptions::new()
496 .write(true)
497 .create_new(true)
498 .open(&path)
499 {
500 Ok(mut f) => {
501 use std::io::Write as _;
502 let _ = writeln!(f, "{}", std::process::id());
504 Ok(Claim { path })
505 }
506 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
507 bail!("task {id} is already claimed ({} exists)", path.display())
508 }
509 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
510 }
511 }
512
513 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
515 if self.path_of(prefix).is_file() {
516 return Ok(prefix.to_owned());
517 }
518 let hits: Vec<String> = self
519 .list()
520 .into_iter()
521 .map(|t| t.id)
522 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
523 .collect();
524 match hits.len() {
525 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
526 0 => bail!("no task matches `{prefix}`"),
527 _ => bail!(
528 "`{prefix}` matches {} tasks: {}",
529 hits.len(),
530 hits.join(", ")
531 ),
532 }
533 }
534
535 pub fn revision(&self) -> u64 {
542 use std::hash::{Hash as _, Hasher as _};
543
544 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
545 .into_iter()
546 .flatten()
547 .flatten()
548 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
549 .filter_map(|e| {
550 let name = e.file_name().to_string_lossy().into_owned();
551 let mtime = e
552 .metadata()
553 .ok()?
554 .modified()
555 .ok()?
556 .duration_since(std::time::UNIX_EPOCH)
557 .ok()?
558 .as_millis() as u64;
559 Some((name, mtime))
560 })
561 .collect();
562
563 if entries.is_empty() {
564 return 0;
565 }
566
567 entries.sort_unstable();
568 let mut hasher = std::hash::DefaultHasher::new();
569 for (name, mtime) in &entries {
570 name.hash(&mut hasher);
571 mtime.hash(&mut hasher);
572 }
573 let h = hasher.finish();
574 if h == 0 { 1 } else { h }
575 }
576}
577
578#[derive(Debug)]
580pub struct Claim {
581 path: PathBuf,
582}
583
584impl Drop for Claim {
585 fn drop(&mut self) {
586 let _ = std::fs::remove_file(&self.path);
587 }
588}
589
590pub fn title_from(instruction: &str, max: usize) -> String {
593 let line = instruction
599 .lines()
600 .map(str::trim)
601 .find(|l| !l.is_empty())
602 .unwrap_or("(empty task)")
603 .trim_start_matches(['#', '-', '*', '>', ' '])
604 .trim();
605 if line.is_empty() {
606 return "(empty task)".to_owned();
607 }
608 if line.chars().count() <= max {
609 return line.to_owned();
610 }
611 let head: String = line.chars().take(max.saturating_sub(1)).collect();
612 format!("{head}…")
613}
614
615fn read_path(path: &Path) -> Result<Task> {
616 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
617 let task: Task =
618 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
619 if task.schema != SCHEMA {
620 bail!(
621 "task {} was written by a different magi (schema {}, this build \
622 speaks {SCHEMA})",
623 task.id,
624 task.schema
625 );
626 }
627 Ok(task)
628}
629
630fn short(id: &str) -> &str {
631 id.split('-').next_back().unwrap_or(id)
632}
633
634fn new_id() -> String {
635 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
636 let seed = crate::rng::entropy();
637 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 fn queue() -> (tempfile::TempDir, Queue) {
647 let dir = tempfile::tempdir().unwrap();
648 let q = Queue::at(dir.path().join("queue"));
649 (dir, q)
650 }
651
652 fn task(title: &str) -> Task {
653 Task::new(
654 title.to_owned(),
655 format!("do {title}"),
656 PathBuf::from("."),
657 Source::Human,
658 )
659 }
660
661 #[test]
662 fn a_markdown_heading_is_the_title_not_decoration() {
663 assert_eq!(
668 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
669 "Rework the config loader"
670 );
671 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
672 assert_eq!(title_from("> quoted task", 40), "quoted task");
673 assert_eq!(title_from(" \n\n", 40), "(empty task)");
675 assert_eq!(title_from("###\n", 40), "(empty task)");
676 }
677
678 #[test]
679 fn a_long_title_is_elided_by_characters_not_bytes() {
680 let long = "課題".repeat(30);
682 let title = title_from(&long, 10);
683 assert_eq!(title.chars().count(), 10);
684 assert!(title.ends_with('…'));
685 }
686
687 #[test]
688 fn priority_wins_and_ties_break_oldest_first() {
689 let (_dir, q) = queue();
690 let mut a = task("first");
691 let mut b = task("second");
692 let mut c = task("urgent");
693 a.id = "20260101-000001-aaaa".to_owned();
695 b.id = "20260101-000002-bbbb".to_owned();
696 c.id = "20260101-000003-cccc".to_owned();
697 c.priority = 5;
698 for t in [&mut a, &mut b, &mut c] {
699 q.put(t).unwrap();
700 }
701
702 assert_eq!(q.next_runnable().unwrap().id, c.id);
704 c.hold(None);
705 q.put(&mut c).unwrap();
706 assert_eq!(q.next_runnable().unwrap().id, a.id);
708 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
709 }
710
711 #[test]
712 fn a_held_task_is_never_offered_to_the_loop() {
713 let (_dir, q) = queue();
714 let mut t = task("held");
715 q.put(&mut t).unwrap();
716 assert!(q.next_runnable().is_some());
717
718 t.hold(None);
719 q.put(&mut t).unwrap();
720 assert!(
721 q.next_runnable().is_none(),
722 "a held task must wait for a human"
723 );
724
725 t.status = TaskStatus::Failed;
727 q.put(&mut t).unwrap();
728 assert!(q.next_runnable().is_some());
729 }
730
731 #[test]
732 fn attempts_are_capped_and_then_the_task_is_held() {
733 let mut t = task("doomed");
734
735 t.start("run-1".to_owned());
736 t.fail("gate red", 2);
737 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
738
739 t.start("run-2".to_owned());
740 t.fail("gate red", 2);
741 assert_eq!(
742 t.status,
743 TaskStatus::Held,
744 "out of attempts: stop spending money on it"
745 );
746 assert_eq!(t.runs, ["run-1", "run-2"]);
747 assert_eq!(t.last_error.as_deref(), Some("gate red"));
748 }
749
750 #[test]
751 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
752 let mut t = task("stalled by quota");
753
754 t.start("run-1".to_owned());
755 assert_eq!(t.attempts, 1);
756 t.stall("judge-1, judge-2 out of quota");
757 assert_eq!(
758 t.attempts, 0,
759 "a closed quota window must not spend the task's retry budget"
760 );
761 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
762 assert_eq!(
763 t.last_error.as_deref(),
764 Some("judge-1, judge-2 out of quota")
765 );
766
767 for _ in 0..20 {
770 t.start("run-n".to_owned());
771 t.stall("still out of quota");
772 }
773 t.start("run-real".to_owned());
774 t.fail("gate red", 2);
775 assert_eq!(
776 t.status,
777 TaskStatus::Failed,
778 "the first attempt that was really judged is attempt one"
779 );
780 }
781
782 #[test]
783 fn releasing_a_held_task_gives_it_a_real_second_chance() {
784 let mut t = task("retry me");
785 t.start("run-1".to_owned());
786 t.fail("gate red", 1);
787 assert_eq!(t.status, TaskStatus::Held);
788
789 t.release();
790 assert_eq!(t.status, TaskStatus::Queued);
791 assert_eq!(t.attempts, 0);
794 assert!(t.last_error.is_none());
795 assert_eq!(
796 t.runs.len(),
797 1,
798 "history is kept: attempts reset, evidence does not"
799 );
800 }
801
802 #[test]
803 fn a_hold_reason_survives_and_a_release_clears_it() {
804 let mut t = task("waiting on something else");
805 t.hold(Some(
806 "waiting for 20260101-000000-aaaa to land first".to_owned(),
807 ));
808 assert_eq!(t.status, TaskStatus::Held);
809 assert_eq!(
810 t.hold_reason.as_deref(),
811 Some("waiting for 20260101-000000-aaaa to land first")
812 );
813
814 t.hold(None);
816 assert_eq!(
817 t.hold_reason.as_deref(),
818 Some("waiting for 20260101-000000-aaaa to land first"),
819 "a bare re-hold keeps whatever a human already wrote down"
820 );
821
822 let mut plain = task("no reason given");
824 plain.hold(None);
825 assert_eq!(plain.status, TaskStatus::Held);
826 assert!(plain.hold_reason.is_none());
827
828 t.release();
829 assert_eq!(t.status, TaskStatus::Queued);
830 assert!(
831 t.hold_reason.is_none(),
832 "a stale reason must not greet the next person who holds this task"
833 );
834 }
835
836 #[test]
837 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
838 let mut t = task("landed by hand while held");
843 t.hold(Some("waiting on 3ed9".to_owned()));
844 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
845
846 t.succeed();
847 assert_eq!(t.status, TaskStatus::Done);
848 assert!(
849 t.hold_reason.is_none(),
850 "a done task cannot still be waiting on something"
851 );
852 }
853
854 #[test]
855 fn priority_can_be_changed_while_queued_but_not_while_running() {
856 let mut t = task("reprioritise me");
857 t.set_priority(5).unwrap();
858 assert_eq!(t.priority, 5);
859
860 t.start("run-1".to_owned());
861 let err = t.set_priority(9).unwrap_err().to_string();
862 assert!(err.contains("running"), "{err}");
863 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
864 }
865
866 #[test]
867 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
868 let (_dir, q) = queue();
869 let mut a = task("first filed");
870 let mut b = task("second filed");
871 a.id = "20260101-000001-aaaa".to_owned();
872 b.id = "20260101-000002-bbbb".to_owned();
873 q.put(&mut a).unwrap();
874 q.put(&mut b).unwrap();
875
876 assert_eq!(
877 q.next_runnable().unwrap().id,
878 a.id,
879 "with equal priority the older task goes first, so a burst of \
880 new work cannot starve it"
881 );
882 assert_eq!(
883 q.list()[0].id,
884 b.id,
885 "but the list an operator reads is newest first, the same as \
886 before priority existed - a's turn to run does not make it the \
887 newest task"
888 );
889
890 let mut a = q.get(&a.id).unwrap();
891 a.set_priority(10).unwrap();
892 q.put(&mut a).unwrap();
893
894 assert_eq!(
895 q.next_runnable().unwrap().id,
896 a.id,
897 "a raised priority must be reflected the moment it is saved"
898 );
899 assert_eq!(
903 q.list()[0].id,
904 a.id,
905 "the raised task must sort first in the list an operator reads, \
906 not only in next_runnable's own ordering"
907 );
908 }
909
910 #[test]
911 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
912 let mut t = Task::new(
913 "old title".to_owned(),
914 "old instruction".to_owned(),
915 PathBuf::from("/repo"),
916 Source::Agent {
917 run: "20260101-000000-beef".to_owned(),
918 node: "implement".to_owned(),
919 },
920 );
921 let id = t.id.clone();
922 let created_at = t.created_at;
923 t.runs.push("20260101-000000-beef".to_owned());
924
925 t.edit("new title".to_owned(), "new instruction".to_owned())
926 .unwrap();
927
928 assert_eq!(t.title, "new title");
929 assert_eq!(t.instruction, "new instruction");
930 assert_eq!(t.id, id, "editing must not mint a new id");
931 assert_eq!(t.created_at, created_at);
932 assert_eq!(
933 t.source,
934 Source::Agent {
935 run: "20260101-000000-beef".to_owned(),
936 node: "implement".to_owned(),
937 },
938 "editing must not turn agent attribution into human"
939 );
940 assert_eq!(t.runs, ["20260101-000000-beef"]);
941 }
942
943 #[test]
944 fn editing_is_refused_once_a_task_is_running_or_finished() {
945 let mut running = task("in flight");
946 running.start("run-1".to_owned());
947 let err = running
948 .edit("x".to_owned(), "y".to_owned())
949 .unwrap_err()
950 .to_string();
951 assert!(err.contains("running"), "{err}");
952
953 let mut done = task("finished");
954 done.succeed();
955 let err = done
956 .edit("x".to_owned(), "y".to_owned())
957 .unwrap_err()
958 .to_string();
959 assert!(err.contains("done"), "{err}");
960
961 let mut queued = task("waiting");
963 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
964 let mut held = task("parked");
965 held.hold(None);
966 held.edit("x".to_owned(), "y".to_owned()).unwrap();
967 }
968
969 #[test]
970 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
971 let (_dir, q) = queue();
972 let path = q.path_of("20260101-000000-aaaa");
973 std::fs::create_dir_all(q.root()).unwrap();
974 std::fs::write(
975 &path,
976 serde_json::json!({
977 "schema": SCHEMA,
978 "id": "20260101-000000-aaaa",
979 "title": "from before hold reasons existed",
980 "instruction": "from before hold reasons existed",
981 "repo": ".",
982 "source": { "kind": "human" },
983 "status": "held",
984 "created_at": Timestamp::now().to_string(),
985 "updated_at": Timestamp::now().to_string(),
986 })
987 .to_string(),
988 )
989 .unwrap();
990
991 let task = q.get("20260101-000000-aaaa").expect("must still read");
992 assert!(task.hold_reason.is_none());
993 assert_eq!(SCHEMA, 1, "this feature must not bump the schema");
994 }
995
996 #[test]
997 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
998 let (_dir, q) = queue();
999 let path = q.path_of("20260101-000000-aaaa");
1000 std::fs::create_dir_all(q.root()).unwrap();
1001 std::fs::write(
1002 &path,
1003 serde_json::json!({
1004 "schema": SCHEMA,
1005 "id": "20260101-000000-aaaa",
1006 "title": "from before diagnostics existed",
1007 "instruction": "from before diagnostics existed",
1008 "repo": ".",
1009 "source": { "kind": "human" },
1010 "status": "held",
1011 "created_at": Timestamp::now().to_string(),
1012 "updated_at": Timestamp::now().to_string(),
1013 })
1014 .to_string(),
1015 )
1016 .unwrap();
1017
1018 let task = q.get("20260101-000000-aaaa").expect("must still read");
1019 assert!(task.diagnostic.is_none());
1020 assert_eq!(SCHEMA, 1, "this feature must not bump the schema");
1021 }
1022
1023 #[test]
1024 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
1025 let mut held = task("diagnosed");
1030 held.start("run-1".to_owned());
1031 held.fail("gate red", 1);
1032 held.diagnostic = Some("cargo test failed: ...".to_owned());
1033 assert_eq!(held.status, TaskStatus::Held);
1034
1035 held.release();
1036 assert!(held.diagnostic.is_none());
1037
1038 held.diagnostic = Some("cargo test failed: ...".to_owned());
1039 held.succeed();
1040 assert!(held.diagnostic.is_none());
1041 }
1042
1043 #[test]
1044 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
1045 let mut t = task("retried");
1046 t.start("run-1".to_owned());
1047 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
1048 t.fail("unrelated config error", 5);
1049 assert_eq!(t.status, TaskStatus::Failed);
1050 assert!(
1051 t.diagnostic.is_none(),
1052 "fail() must not let an old diagnostic outlive the run that produced it"
1053 );
1054 }
1055
1056 #[test]
1057 fn a_claim_is_exclusive_and_releases_on_drop() {
1058 let (_dir, q) = queue();
1059 let mut t = task("contended");
1060 q.put(&mut t).unwrap();
1061
1062 let held = q.claim(&t.id).unwrap();
1063 assert!(
1064 q.claim(&t.id).is_err(),
1065 "two daemons must not drive one task into two runs"
1066 );
1067 drop(held);
1068 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
1069 }
1070
1071 #[test]
1072 fn a_round_trip_survives_disk() {
1073 let (_dir, q) = queue();
1074 let mut t = Task::new(
1075 "titled".to_owned(),
1076 "body".to_owned(),
1077 PathBuf::from("/repo"),
1078 Source::Agent {
1079 run: "20260101-000000-beef".to_owned(),
1080 node: "implement".to_owned(),
1081 },
1082 );
1083 t.priority = 3;
1084 q.put(&mut t).unwrap();
1085
1086 let back = q.get(&t.id).unwrap();
1087 assert_eq!(back.id, t.id);
1088 assert_eq!(back.priority, 3);
1089 assert_eq!(back.source.label(), "implement@beef");
1090 assert_eq!(q.get(t.short()).unwrap().id, t.id);
1092 }
1093
1094 #[test]
1095 fn an_unreadable_task_does_not_take_the_queue_down() {
1096 let (_dir, q) = queue();
1097 let mut t = task("fine");
1098 q.put(&mut t).unwrap();
1099 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
1100
1101 let listed = q.list();
1102 assert_eq!(listed.len(), 1, "the readable task still lists");
1103 assert_eq!(listed[0].id, t.id);
1104 }
1105
1106 #[test]
1107 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
1108 let (_dir, q) = queue();
1109 let path = q.path_of("20260101-000000-aaaa");
1110 std::fs::create_dir_all(q.root()).unwrap();
1111 std::fs::write(
1112 &path,
1113 serde_json::json!({
1114 "schema": SCHEMA,
1115 "id": "20260101-000000-aaaa",
1116 "title": "from before solo existed",
1117 "instruction": "from before solo existed",
1118 "repo": ".",
1119 "source": { "kind": "human" },
1120 "status": "queued",
1121 "created_at": Timestamp::now().to_string(),
1122 "updated_at": Timestamp::now().to_string(),
1123 })
1124 .to_string(),
1125 )
1126 .unwrap();
1127
1128 let task = q.get("20260101-000000-aaaa").expect("must still read");
1129 assert!(!task.solo, "a queue file with no `solo` field means false");
1130 }
1131
1132 #[test]
1133 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
1134 let (_dir, q) = queue();
1135 let mut t = task("from the future");
1136 q.put(&mut t).unwrap();
1137 let path = q.path_of(&t.id);
1138 let body = std::fs::read_to_string(&path)
1139 .unwrap()
1140 .replace("\"schema\": 1", "\"schema\": 99");
1141 std::fs::write(&path, body).unwrap();
1142
1143 let err = q.get(&t.id).unwrap_err().to_string();
1144 assert!(err.contains("schema 99"), "{err}");
1145 }
1146
1147 #[test]
1148 fn revision_moves_when_the_queue_changes() {
1149 let (_dir, q) = queue();
1150 assert_eq!(q.revision(), 0, "an empty queue has no revision");
1151 let mut t = task("first");
1152 q.put(&mut t).unwrap();
1153 assert!(q.revision() > 0, "a written task moves the revision");
1154 }
1155
1156 #[test]
1157 fn revision_moves_when_deleting_an_older_task() {
1158 let (_dir, q) = queue();
1159 let mut t1 = task("older");
1160 q.put(&mut t1).unwrap();
1161 std::thread::sleep(std::time::Duration::from_millis(10));
1163 let mut t2 = task("newer");
1164 q.put(&mut t2).unwrap();
1165
1166 let rev_before = q.revision();
1167 q.remove(&t1.id, false).unwrap();
1168 let rev_after = q.revision();
1169
1170 assert_ne!(
1171 rev_before, rev_after,
1172 "deleting an older task must change the revision so other clients see the deletion"
1173 );
1174 }
1175
1176 #[test]
1177 fn removing_a_task_takes_it_out_of_the_listing() {
1178 let (_dir, q) = queue();
1179 let mut t = task("delete me");
1180 q.put(&mut t).unwrap();
1181 let removed = q.remove(t.short(), false).unwrap();
1182 assert_eq!(removed, t.id, "a prefix resolves before deleting");
1183 assert!(q.list().is_empty());
1184 assert!(
1185 q.remove(&t.id, false).is_err(),
1186 "removing twice is an error"
1187 );
1188 }
1189
1190 #[test]
1191 fn removing_a_task_takes_its_stale_lock_with_it() {
1192 let (_dir, q) = queue();
1193 let mut t = task("interrupted");
1194 q.put(&mut t).unwrap();
1195
1196 let claim = q.claim(&t.id).unwrap();
1199 std::mem::forget(claim);
1200 assert!(
1201 q.claim(&t.id).is_err(),
1202 "the orphaned lock is what makes the task look claimed"
1203 );
1204
1205 let err = q.remove(&t.id, true).unwrap_err().to_string();
1207 assert!(err.contains("live daemon"), "{err}");
1208 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
1209
1210 q.remove(&t.id, false).unwrap();
1212 assert!(q.list().is_empty());
1213 let mut again = task("interrupted");
1214 again.id = t.id.clone();
1215 q.put(&mut again).unwrap();
1216 assert!(
1217 q.claim(&t.id).is_ok(),
1218 "a task that comes back must be claimable, which a left-behind lock would prevent"
1219 );
1220 }
1221}