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 pub created_at: Timestamp,
169 pub updated_at: Timestamp,
171}
172
173impl Task {
174 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
176 let now = Timestamp::now();
177 Self {
178 schema: SCHEMA,
179 id: new_id(),
180 title,
181 instruction,
182 repo,
183 source,
184 priority: 0,
185 solo: false,
186 status: TaskStatus::Queued,
187 attempts: 0,
188 runs: Vec::new(),
189 last_error: None,
190 hold_reason: None,
191 created_at: now,
192 updated_at: now,
193 }
194 }
195
196 pub fn short(&self) -> &str {
198 short(&self.id)
199 }
200
201 pub fn start(&mut self, run: String) {
203 self.status = TaskStatus::Running;
204 self.attempts += 1;
205 self.runs.push(run);
206 self.last_error = None;
207 }
208
209 pub fn succeed(&mut self) {
218 self.status = TaskStatus::Done;
219 self.last_error = None;
220 self.hold_reason = None;
221 }
222
223 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
226 self.last_error = Some(why.into());
227 self.status = if self.attempts >= max_attempts {
228 TaskStatus::Held
229 } else {
230 TaskStatus::Failed
231 };
232 }
233
234 pub fn stall(&mut self, why: impl Into<String>) {
244 self.last_error = Some(why.into());
245 self.attempts = self.attempts.saturating_sub(1);
246 self.status = TaskStatus::Failed;
247 }
248
249 pub fn hold(&mut self, reason: Option<String>) {
256 self.status = TaskStatus::Held;
257 if reason.is_some() {
258 self.hold_reason = reason;
259 }
260 }
261
262 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
271 if self.status == TaskStatus::Running {
272 bail!(
273 "task {} is running; its priority cannot be changed until \
274 this attempt finishes",
275 self.short()
276 );
277 }
278 self.priority = priority;
279 Ok(())
280 }
281
282 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
294 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
295 bail!(
296 "task {} is {}; only a queued or held task's instruction can \
297 be edited",
298 self.short(),
299 self.status.as_str()
300 );
301 }
302 self.title = title;
303 self.instruction = instruction;
304 Ok(())
305 }
306
307 pub fn handed_off(&mut self, why: impl Into<String>) {
319 self.last_error = Some(why.into());
320 self.status = TaskStatus::Held;
321 }
322
323 pub fn release(&mut self) {
327 self.status = TaskStatus::Queued;
328 self.attempts = 0;
329 self.last_error = None;
330 self.hold_reason = None;
333 }
334}
335
336#[derive(Debug, Clone)]
338pub struct Queue {
339 root: PathBuf,
340}
341
342impl Queue {
343 pub fn open() -> Self {
345 Self::at(crate::run::home().join("queue"))
346 }
347
348 pub fn at(root: PathBuf) -> Self {
351 Self { root }
352 }
353
354 pub fn root(&self) -> &Path {
356 &self.root
357 }
358
359 pub fn path_of(&self, id: &str) -> PathBuf {
361 self.root.join(format!("{id}.json"))
362 }
363
364 pub fn put(&self, task: &mut Task) -> Result<()> {
367 task.updated_at = Timestamp::now();
368 std::fs::create_dir_all(&self.root)
369 .with_context(|| format!("create {}", self.root.display()))?;
370 let body = serde_json::to_string_pretty(task).context("serialize task")?;
371 let path = self.path_of(&task.id);
372 let tmp = path.with_extension("json.tmp");
373 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
374 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
375 Ok(())
376 }
377
378 pub fn get(&self, id: &str) -> Result<Task> {
380 let resolved = self.resolve_id(id)?;
381 read_path(&self.path_of(&resolved))
382 }
383
384 pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
398 let resolved = self.resolve_id(id)?;
399 if in_flight {
400 bail!("task {resolved} is being run by a live daemon right now");
401 }
402 let path = self.path_of(&resolved);
403 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
404 let lock = self.lock_path(&resolved);
405 if let Err(e) = std::fs::remove_file(&lock) {
406 if e.kind() != std::io::ErrorKind::NotFound {
407 return Err(e).with_context(|| format!("remove {}", lock.display()));
408 }
409 }
410 Ok(resolved)
411 }
412
413 fn lock_path(&self, id: &str) -> PathBuf {
416 self.root.join(format!("{id}.lock"))
417 }
418
419 pub fn list(&self) -> Vec<Task> {
432 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
433 .into_iter()
434 .flatten()
435 .flatten()
436 .map(|e| e.path())
437 .filter(|p| p.extension().is_some_and(|x| x == "json"))
438 .filter_map(|p| read_path(&p).ok())
439 .collect();
440 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
441 tasks
442 }
443
444 pub fn next_runnable(&self) -> Option<Task> {
449 let mut runnable: Vec<Task> = self
450 .list()
451 .into_iter()
452 .filter(|t| t.status.runnable())
453 .collect();
454 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
455 runnable.into_iter().next()
456 }
457
458 pub fn claim(&self, id: &str) -> Result<Claim> {
465 std::fs::create_dir_all(&self.root)
466 .with_context(|| format!("create {}", self.root.display()))?;
467 let path = self.lock_path(id);
468 match std::fs::OpenOptions::new()
469 .write(true)
470 .create_new(true)
471 .open(&path)
472 {
473 Ok(mut f) => {
474 use std::io::Write as _;
475 let _ = writeln!(f, "{}", std::process::id());
477 Ok(Claim { path })
478 }
479 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
480 bail!("task {id} is already claimed ({} exists)", path.display())
481 }
482 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
483 }
484 }
485
486 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
488 if self.path_of(prefix).is_file() {
489 return Ok(prefix.to_owned());
490 }
491 let hits: Vec<String> = self
492 .list()
493 .into_iter()
494 .map(|t| t.id)
495 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
496 .collect();
497 match hits.len() {
498 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
499 0 => bail!("no task matches `{prefix}`"),
500 _ => bail!(
501 "`{prefix}` matches {} tasks: {}",
502 hits.len(),
503 hits.join(", ")
504 ),
505 }
506 }
507
508 pub fn revision(&self) -> u64 {
515 use std::hash::{Hash as _, Hasher as _};
516
517 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
518 .into_iter()
519 .flatten()
520 .flatten()
521 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
522 .filter_map(|e| {
523 let name = e.file_name().to_string_lossy().into_owned();
524 let mtime = e
525 .metadata()
526 .ok()?
527 .modified()
528 .ok()?
529 .duration_since(std::time::UNIX_EPOCH)
530 .ok()?
531 .as_millis() as u64;
532 Some((name, mtime))
533 })
534 .collect();
535
536 if entries.is_empty() {
537 return 0;
538 }
539
540 entries.sort_unstable();
541 let mut hasher = std::hash::DefaultHasher::new();
542 for (name, mtime) in &entries {
543 name.hash(&mut hasher);
544 mtime.hash(&mut hasher);
545 }
546 let h = hasher.finish();
547 if h == 0 { 1 } else { h }
548 }
549}
550
551#[derive(Debug)]
553pub struct Claim {
554 path: PathBuf,
555}
556
557impl Drop for Claim {
558 fn drop(&mut self) {
559 let _ = std::fs::remove_file(&self.path);
560 }
561}
562
563pub fn title_from(instruction: &str, max: usize) -> String {
566 let line = instruction
572 .lines()
573 .map(str::trim)
574 .find(|l| !l.is_empty())
575 .unwrap_or("(empty task)")
576 .trim_start_matches(['#', '-', '*', '>', ' '])
577 .trim();
578 if line.is_empty() {
579 return "(empty task)".to_owned();
580 }
581 if line.chars().count() <= max {
582 return line.to_owned();
583 }
584 let head: String = line.chars().take(max.saturating_sub(1)).collect();
585 format!("{head}…")
586}
587
588fn read_path(path: &Path) -> Result<Task> {
589 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
590 let task: Task =
591 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
592 if task.schema != SCHEMA {
593 bail!(
594 "task {} was written by a different magi (schema {}, this build \
595 speaks {SCHEMA})",
596 task.id,
597 task.schema
598 );
599 }
600 Ok(task)
601}
602
603fn short(id: &str) -> &str {
604 id.split('-').next_back().unwrap_or(id)
605}
606
607fn new_id() -> String {
608 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
609 let seed = crate::rng::entropy();
610 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 fn queue() -> (tempfile::TempDir, Queue) {
620 let dir = tempfile::tempdir().unwrap();
621 let q = Queue::at(dir.path().join("queue"));
622 (dir, q)
623 }
624
625 fn task(title: &str) -> Task {
626 Task::new(
627 title.to_owned(),
628 format!("do {title}"),
629 PathBuf::from("."),
630 Source::Human,
631 )
632 }
633
634 #[test]
635 fn a_markdown_heading_is_the_title_not_decoration() {
636 assert_eq!(
641 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
642 "Rework the config loader"
643 );
644 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
645 assert_eq!(title_from("> quoted task", 40), "quoted task");
646 assert_eq!(title_from(" \n\n", 40), "(empty task)");
648 assert_eq!(title_from("###\n", 40), "(empty task)");
649 }
650
651 #[test]
652 fn a_long_title_is_elided_by_characters_not_bytes() {
653 let long = "課題".repeat(30);
655 let title = title_from(&long, 10);
656 assert_eq!(title.chars().count(), 10);
657 assert!(title.ends_with('…'));
658 }
659
660 #[test]
661 fn priority_wins_and_ties_break_oldest_first() {
662 let (_dir, q) = queue();
663 let mut a = task("first");
664 let mut b = task("second");
665 let mut c = task("urgent");
666 a.id = "20260101-000001-aaaa".to_owned();
668 b.id = "20260101-000002-bbbb".to_owned();
669 c.id = "20260101-000003-cccc".to_owned();
670 c.priority = 5;
671 for t in [&mut a, &mut b, &mut c] {
672 q.put(t).unwrap();
673 }
674
675 assert_eq!(q.next_runnable().unwrap().id, c.id);
677 c.hold(None);
678 q.put(&mut c).unwrap();
679 assert_eq!(q.next_runnable().unwrap().id, a.id);
681 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
682 }
683
684 #[test]
685 fn a_held_task_is_never_offered_to_the_loop() {
686 let (_dir, q) = queue();
687 let mut t = task("held");
688 q.put(&mut t).unwrap();
689 assert!(q.next_runnable().is_some());
690
691 t.hold(None);
692 q.put(&mut t).unwrap();
693 assert!(
694 q.next_runnable().is_none(),
695 "a held task must wait for a human"
696 );
697
698 t.status = TaskStatus::Failed;
700 q.put(&mut t).unwrap();
701 assert!(q.next_runnable().is_some());
702 }
703
704 #[test]
705 fn attempts_are_capped_and_then_the_task_is_held() {
706 let mut t = task("doomed");
707
708 t.start("run-1".to_owned());
709 t.fail("gate red", 2);
710 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
711
712 t.start("run-2".to_owned());
713 t.fail("gate red", 2);
714 assert_eq!(
715 t.status,
716 TaskStatus::Held,
717 "out of attempts: stop spending money on it"
718 );
719 assert_eq!(t.runs, ["run-1", "run-2"]);
720 assert_eq!(t.last_error.as_deref(), Some("gate red"));
721 }
722
723 #[test]
724 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
725 let mut t = task("stalled by quota");
726
727 t.start("run-1".to_owned());
728 assert_eq!(t.attempts, 1);
729 t.stall("judge-1, judge-2 out of quota");
730 assert_eq!(
731 t.attempts, 0,
732 "a closed quota window must not spend the task's retry budget"
733 );
734 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
735 assert_eq!(
736 t.last_error.as_deref(),
737 Some("judge-1, judge-2 out of quota")
738 );
739
740 for _ in 0..20 {
743 t.start("run-n".to_owned());
744 t.stall("still out of quota");
745 }
746 t.start("run-real".to_owned());
747 t.fail("gate red", 2);
748 assert_eq!(
749 t.status,
750 TaskStatus::Failed,
751 "the first attempt that was really judged is attempt one"
752 );
753 }
754
755 #[test]
756 fn releasing_a_held_task_gives_it_a_real_second_chance() {
757 let mut t = task("retry me");
758 t.start("run-1".to_owned());
759 t.fail("gate red", 1);
760 assert_eq!(t.status, TaskStatus::Held);
761
762 t.release();
763 assert_eq!(t.status, TaskStatus::Queued);
764 assert_eq!(t.attempts, 0);
767 assert!(t.last_error.is_none());
768 assert_eq!(
769 t.runs.len(),
770 1,
771 "history is kept: attempts reset, evidence does not"
772 );
773 }
774
775 #[test]
776 fn a_hold_reason_survives_and_a_release_clears_it() {
777 let mut t = task("waiting on something else");
778 t.hold(Some(
779 "waiting for 20260101-000000-aaaa to land first".to_owned(),
780 ));
781 assert_eq!(t.status, TaskStatus::Held);
782 assert_eq!(
783 t.hold_reason.as_deref(),
784 Some("waiting for 20260101-000000-aaaa to land first")
785 );
786
787 t.hold(None);
789 assert_eq!(
790 t.hold_reason.as_deref(),
791 Some("waiting for 20260101-000000-aaaa to land first"),
792 "a bare re-hold keeps whatever a human already wrote down"
793 );
794
795 let mut plain = task("no reason given");
797 plain.hold(None);
798 assert_eq!(plain.status, TaskStatus::Held);
799 assert!(plain.hold_reason.is_none());
800
801 t.release();
802 assert_eq!(t.status, TaskStatus::Queued);
803 assert!(
804 t.hold_reason.is_none(),
805 "a stale reason must not greet the next person who holds this task"
806 );
807 }
808
809 #[test]
810 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
811 let mut t = task("landed by hand while held");
816 t.hold(Some("waiting on 3ed9".to_owned()));
817 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
818
819 t.succeed();
820 assert_eq!(t.status, TaskStatus::Done);
821 assert!(
822 t.hold_reason.is_none(),
823 "a done task cannot still be waiting on something"
824 );
825 }
826
827 #[test]
828 fn priority_can_be_changed_while_queued_but_not_while_running() {
829 let mut t = task("reprioritise me");
830 t.set_priority(5).unwrap();
831 assert_eq!(t.priority, 5);
832
833 t.start("run-1".to_owned());
834 let err = t.set_priority(9).unwrap_err().to_string();
835 assert!(err.contains("running"), "{err}");
836 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
837 }
838
839 #[test]
840 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
841 let (_dir, q) = queue();
842 let mut a = task("first filed");
843 let mut b = task("second filed");
844 a.id = "20260101-000001-aaaa".to_owned();
845 b.id = "20260101-000002-bbbb".to_owned();
846 q.put(&mut a).unwrap();
847 q.put(&mut b).unwrap();
848
849 assert_eq!(
850 q.next_runnable().unwrap().id,
851 a.id,
852 "with equal priority the older task goes first, so a burst of \
853 new work cannot starve it"
854 );
855 assert_eq!(
856 q.list()[0].id,
857 b.id,
858 "but the list an operator reads is newest first, the same as \
859 before priority existed - a's turn to run does not make it the \
860 newest task"
861 );
862
863 let mut a = q.get(&a.id).unwrap();
864 a.set_priority(10).unwrap();
865 q.put(&mut a).unwrap();
866
867 assert_eq!(
868 q.next_runnable().unwrap().id,
869 a.id,
870 "a raised priority must be reflected the moment it is saved"
871 );
872 assert_eq!(
876 q.list()[0].id,
877 a.id,
878 "the raised task must sort first in the list an operator reads, \
879 not only in next_runnable's own ordering"
880 );
881 }
882
883 #[test]
884 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
885 let mut t = Task::new(
886 "old title".to_owned(),
887 "old instruction".to_owned(),
888 PathBuf::from("/repo"),
889 Source::Agent {
890 run: "20260101-000000-beef".to_owned(),
891 node: "implement".to_owned(),
892 },
893 );
894 let id = t.id.clone();
895 let created_at = t.created_at;
896 t.runs.push("20260101-000000-beef".to_owned());
897
898 t.edit("new title".to_owned(), "new instruction".to_owned())
899 .unwrap();
900
901 assert_eq!(t.title, "new title");
902 assert_eq!(t.instruction, "new instruction");
903 assert_eq!(t.id, id, "editing must not mint a new id");
904 assert_eq!(t.created_at, created_at);
905 assert_eq!(
906 t.source,
907 Source::Agent {
908 run: "20260101-000000-beef".to_owned(),
909 node: "implement".to_owned(),
910 },
911 "editing must not turn agent attribution into human"
912 );
913 assert_eq!(t.runs, ["20260101-000000-beef"]);
914 }
915
916 #[test]
917 fn editing_is_refused_once_a_task_is_running_or_finished() {
918 let mut running = task("in flight");
919 running.start("run-1".to_owned());
920 let err = running
921 .edit("x".to_owned(), "y".to_owned())
922 .unwrap_err()
923 .to_string();
924 assert!(err.contains("running"), "{err}");
925
926 let mut done = task("finished");
927 done.succeed();
928 let err = done
929 .edit("x".to_owned(), "y".to_owned())
930 .unwrap_err()
931 .to_string();
932 assert!(err.contains("done"), "{err}");
933
934 let mut queued = task("waiting");
936 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
937 let mut held = task("parked");
938 held.hold(None);
939 held.edit("x".to_owned(), "y".to_owned()).unwrap();
940 }
941
942 #[test]
943 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
944 let (_dir, q) = queue();
945 let path = q.path_of("20260101-000000-aaaa");
946 std::fs::create_dir_all(q.root()).unwrap();
947 std::fs::write(
948 &path,
949 serde_json::json!({
950 "schema": SCHEMA,
951 "id": "20260101-000000-aaaa",
952 "title": "from before hold reasons existed",
953 "instruction": "from before hold reasons existed",
954 "repo": ".",
955 "source": { "kind": "human" },
956 "status": "held",
957 "created_at": Timestamp::now().to_string(),
958 "updated_at": Timestamp::now().to_string(),
959 })
960 .to_string(),
961 )
962 .unwrap();
963
964 let task = q.get("20260101-000000-aaaa").expect("must still read");
965 assert!(task.hold_reason.is_none());
966 assert_eq!(SCHEMA, 1, "this feature must not bump the schema");
967 }
968
969 #[test]
970 fn a_claim_is_exclusive_and_releases_on_drop() {
971 let (_dir, q) = queue();
972 let mut t = task("contended");
973 q.put(&mut t).unwrap();
974
975 let held = q.claim(&t.id).unwrap();
976 assert!(
977 q.claim(&t.id).is_err(),
978 "two daemons must not drive one task into two runs"
979 );
980 drop(held);
981 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
982 }
983
984 #[test]
985 fn a_round_trip_survives_disk() {
986 let (_dir, q) = queue();
987 let mut t = Task::new(
988 "titled".to_owned(),
989 "body".to_owned(),
990 PathBuf::from("/repo"),
991 Source::Agent {
992 run: "20260101-000000-beef".to_owned(),
993 node: "implement".to_owned(),
994 },
995 );
996 t.priority = 3;
997 q.put(&mut t).unwrap();
998
999 let back = q.get(&t.id).unwrap();
1000 assert_eq!(back.id, t.id);
1001 assert_eq!(back.priority, 3);
1002 assert_eq!(back.source.label(), "implement@beef");
1003 assert_eq!(q.get(t.short()).unwrap().id, t.id);
1005 }
1006
1007 #[test]
1008 fn an_unreadable_task_does_not_take_the_queue_down() {
1009 let (_dir, q) = queue();
1010 let mut t = task("fine");
1011 q.put(&mut t).unwrap();
1012 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
1013
1014 let listed = q.list();
1015 assert_eq!(listed.len(), 1, "the readable task still lists");
1016 assert_eq!(listed[0].id, t.id);
1017 }
1018
1019 #[test]
1020 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
1021 let (_dir, q) = queue();
1022 let path = q.path_of("20260101-000000-aaaa");
1023 std::fs::create_dir_all(q.root()).unwrap();
1024 std::fs::write(
1025 &path,
1026 serde_json::json!({
1027 "schema": SCHEMA,
1028 "id": "20260101-000000-aaaa",
1029 "title": "from before solo existed",
1030 "instruction": "from before solo existed",
1031 "repo": ".",
1032 "source": { "kind": "human" },
1033 "status": "queued",
1034 "created_at": Timestamp::now().to_string(),
1035 "updated_at": Timestamp::now().to_string(),
1036 })
1037 .to_string(),
1038 )
1039 .unwrap();
1040
1041 let task = q.get("20260101-000000-aaaa").expect("must still read");
1042 assert!(!task.solo, "a queue file with no `solo` field means false");
1043 }
1044
1045 #[test]
1046 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
1047 let (_dir, q) = queue();
1048 let mut t = task("from the future");
1049 q.put(&mut t).unwrap();
1050 let path = q.path_of(&t.id);
1051 let body = std::fs::read_to_string(&path)
1052 .unwrap()
1053 .replace("\"schema\": 1", "\"schema\": 99");
1054 std::fs::write(&path, body).unwrap();
1055
1056 let err = q.get(&t.id).unwrap_err().to_string();
1057 assert!(err.contains("schema 99"), "{err}");
1058 }
1059
1060 #[test]
1061 fn revision_moves_when_the_queue_changes() {
1062 let (_dir, q) = queue();
1063 assert_eq!(q.revision(), 0, "an empty queue has no revision");
1064 let mut t = task("first");
1065 q.put(&mut t).unwrap();
1066 assert!(q.revision() > 0, "a written task moves the revision");
1067 }
1068
1069 #[test]
1070 fn revision_moves_when_deleting_an_older_task() {
1071 let (_dir, q) = queue();
1072 let mut t1 = task("older");
1073 q.put(&mut t1).unwrap();
1074 std::thread::sleep(std::time::Duration::from_millis(10));
1076 let mut t2 = task("newer");
1077 q.put(&mut t2).unwrap();
1078
1079 let rev_before = q.revision();
1080 q.remove(&t1.id, false).unwrap();
1081 let rev_after = q.revision();
1082
1083 assert_ne!(
1084 rev_before, rev_after,
1085 "deleting an older task must change the revision so other clients see the deletion"
1086 );
1087 }
1088
1089 #[test]
1090 fn removing_a_task_takes_it_out_of_the_listing() {
1091 let (_dir, q) = queue();
1092 let mut t = task("delete me");
1093 q.put(&mut t).unwrap();
1094 let removed = q.remove(t.short(), false).unwrap();
1095 assert_eq!(removed, t.id, "a prefix resolves before deleting");
1096 assert!(q.list().is_empty());
1097 assert!(
1098 q.remove(&t.id, false).is_err(),
1099 "removing twice is an error"
1100 );
1101 }
1102
1103 #[test]
1104 fn removing_a_task_takes_its_stale_lock_with_it() {
1105 let (_dir, q) = queue();
1106 let mut t = task("interrupted");
1107 q.put(&mut t).unwrap();
1108
1109 let claim = q.claim(&t.id).unwrap();
1112 std::mem::forget(claim);
1113 assert!(
1114 q.claim(&t.id).is_err(),
1115 "the orphaned lock is what makes the task look claimed"
1116 );
1117
1118 let err = q.remove(&t.id, true).unwrap_err().to_string();
1120 assert!(err.contains("live daemon"), "{err}");
1121 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
1122
1123 q.remove(&t.id, false).unwrap();
1125 assert!(q.list().is_empty());
1126 let mut again = task("interrupted");
1127 again.id = t.id.clone();
1128 q.put(&mut again).unwrap();
1129 assert!(
1130 q.claim(&t.id).is_ok(),
1131 "a task that comes back must be claimable, which a left-behind lock would prevent"
1132 );
1133 }
1134}