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 pub created_at: Timestamp,
155 pub updated_at: Timestamp,
157}
158
159impl Task {
160 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
162 let now = Timestamp::now();
163 Self {
164 schema: SCHEMA,
165 id: new_id(),
166 title,
167 instruction,
168 repo,
169 source,
170 priority: 0,
171 solo: false,
172 status: TaskStatus::Queued,
173 attempts: 0,
174 runs: Vec::new(),
175 last_error: None,
176 created_at: now,
177 updated_at: now,
178 }
179 }
180
181 pub fn short(&self) -> &str {
183 short(&self.id)
184 }
185
186 pub fn start(&mut self, run: String) {
188 self.status = TaskStatus::Running;
189 self.attempts += 1;
190 self.runs.push(run);
191 self.last_error = None;
192 }
193
194 pub fn succeed(&mut self) {
196 self.status = TaskStatus::Done;
197 self.last_error = None;
198 }
199
200 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
203 self.last_error = Some(why.into());
204 self.status = if self.attempts >= max_attempts {
205 TaskStatus::Held
206 } else {
207 TaskStatus::Failed
208 };
209 }
210
211 pub fn stall(&mut self, why: impl Into<String>) {
221 self.last_error = Some(why.into());
222 self.attempts = self.attempts.saturating_sub(1);
223 self.status = TaskStatus::Failed;
224 }
225
226 pub fn hold(&mut self) {
228 self.status = TaskStatus::Held;
229 }
230
231 pub fn handed_off(&mut self, why: impl Into<String>) {
243 self.last_error = Some(why.into());
244 self.status = TaskStatus::Held;
245 }
246
247 pub fn release(&mut self) {
251 self.status = TaskStatus::Queued;
252 self.attempts = 0;
253 self.last_error = None;
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Queue {
260 root: PathBuf,
261}
262
263impl Queue {
264 pub fn open() -> Self {
266 Self::at(crate::run::home().join("queue"))
267 }
268
269 pub fn at(root: PathBuf) -> Self {
272 Self { root }
273 }
274
275 pub fn root(&self) -> &Path {
277 &self.root
278 }
279
280 pub fn path_of(&self, id: &str) -> PathBuf {
282 self.root.join(format!("{id}.json"))
283 }
284
285 pub fn put(&self, task: &mut Task) -> Result<()> {
288 task.updated_at = Timestamp::now();
289 std::fs::create_dir_all(&self.root)
290 .with_context(|| format!("create {}", self.root.display()))?;
291 let body = serde_json::to_string_pretty(task).context("serialize task")?;
292 let path = self.path_of(&task.id);
293 let tmp = path.with_extension("json.tmp");
294 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
295 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
296 Ok(())
297 }
298
299 pub fn get(&self, id: &str) -> Result<Task> {
301 let resolved = self.resolve_id(id)?;
302 read_path(&self.path_of(&resolved))
303 }
304
305 pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
319 let resolved = self.resolve_id(id)?;
320 if in_flight {
321 bail!("task {resolved} is being run by a live daemon right now");
322 }
323 let path = self.path_of(&resolved);
324 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
325 let lock = self.lock_path(&resolved);
326 if let Err(e) = std::fs::remove_file(&lock) {
327 if e.kind() != std::io::ErrorKind::NotFound {
328 return Err(e).with_context(|| format!("remove {}", lock.display()));
329 }
330 }
331 Ok(resolved)
332 }
333
334 fn lock_path(&self, id: &str) -> PathBuf {
337 self.root.join(format!("{id}.lock"))
338 }
339
340 pub fn list(&self) -> Vec<Task> {
344 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
345 .into_iter()
346 .flatten()
347 .flatten()
348 .map(|e| e.path())
349 .filter(|p| p.extension().is_some_and(|x| x == "json"))
350 .filter_map(|p| read_path(&p).ok())
351 .collect();
352 tasks.sort_unstable_by(|a, b| b.id.cmp(&a.id));
353 tasks
354 }
355
356 pub fn next_runnable(&self) -> Option<Task> {
361 let mut runnable: Vec<Task> = self
362 .list()
363 .into_iter()
364 .filter(|t| t.status.runnable())
365 .collect();
366 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
367 runnable.into_iter().next()
368 }
369
370 pub fn claim(&self, id: &str) -> Result<Claim> {
377 std::fs::create_dir_all(&self.root)
378 .with_context(|| format!("create {}", self.root.display()))?;
379 let path = self.lock_path(id);
380 match std::fs::OpenOptions::new()
381 .write(true)
382 .create_new(true)
383 .open(&path)
384 {
385 Ok(mut f) => {
386 use std::io::Write as _;
387 let _ = writeln!(f, "{}", std::process::id());
389 Ok(Claim { path })
390 }
391 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
392 bail!("task {id} is already claimed ({} exists)", path.display())
393 }
394 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
395 }
396 }
397
398 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
400 if self.path_of(prefix).is_file() {
401 return Ok(prefix.to_owned());
402 }
403 let hits: Vec<String> = self
404 .list()
405 .into_iter()
406 .map(|t| t.id)
407 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
408 .collect();
409 match hits.len() {
410 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
411 0 => bail!("no task matches `{prefix}`"),
412 _ => bail!(
413 "`{prefix}` matches {} tasks: {}",
414 hits.len(),
415 hits.join(", ")
416 ),
417 }
418 }
419
420 pub fn revision(&self) -> u64 {
427 use std::hash::{Hash as _, Hasher as _};
428
429 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
430 .into_iter()
431 .flatten()
432 .flatten()
433 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
434 .filter_map(|e| {
435 let name = e.file_name().to_string_lossy().into_owned();
436 let mtime = e
437 .metadata()
438 .ok()?
439 .modified()
440 .ok()?
441 .duration_since(std::time::UNIX_EPOCH)
442 .ok()?
443 .as_millis() as u64;
444 Some((name, mtime))
445 })
446 .collect();
447
448 if entries.is_empty() {
449 return 0;
450 }
451
452 entries.sort_unstable();
453 let mut hasher = std::hash::DefaultHasher::new();
454 for (name, mtime) in &entries {
455 name.hash(&mut hasher);
456 mtime.hash(&mut hasher);
457 }
458 let h = hasher.finish();
459 if h == 0 { 1 } else { h }
460 }
461}
462
463#[derive(Debug)]
465pub struct Claim {
466 path: PathBuf,
467}
468
469impl Drop for Claim {
470 fn drop(&mut self) {
471 let _ = std::fs::remove_file(&self.path);
472 }
473}
474
475pub fn title_from(instruction: &str, max: usize) -> String {
478 let line = instruction
484 .lines()
485 .map(str::trim)
486 .find(|l| !l.is_empty())
487 .unwrap_or("(empty task)")
488 .trim_start_matches(['#', '-', '*', '>', ' '])
489 .trim();
490 if line.is_empty() {
491 return "(empty task)".to_owned();
492 }
493 if line.chars().count() <= max {
494 return line.to_owned();
495 }
496 let head: String = line.chars().take(max.saturating_sub(1)).collect();
497 format!("{head}…")
498}
499
500fn read_path(path: &Path) -> Result<Task> {
501 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
502 let task: Task =
503 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
504 if task.schema != SCHEMA {
505 bail!(
506 "task {} was written by a different magi (schema {}, this build \
507 speaks {SCHEMA})",
508 task.id,
509 task.schema
510 );
511 }
512 Ok(task)
513}
514
515fn short(id: &str) -> &str {
516 id.split('-').next_back().unwrap_or(id)
517}
518
519fn new_id() -> String {
520 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
521 let seed = crate::rng::entropy();
522 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 fn queue() -> (tempfile::TempDir, Queue) {
532 let dir = tempfile::tempdir().unwrap();
533 let q = Queue::at(dir.path().join("queue"));
534 (dir, q)
535 }
536
537 fn task(title: &str) -> Task {
538 Task::new(
539 title.to_owned(),
540 format!("do {title}"),
541 PathBuf::from("."),
542 Source::Human,
543 )
544 }
545
546 #[test]
547 fn a_markdown_heading_is_the_title_not_decoration() {
548 assert_eq!(
553 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
554 "Rework the config loader"
555 );
556 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
557 assert_eq!(title_from("> quoted task", 40), "quoted task");
558 assert_eq!(title_from(" \n\n", 40), "(empty task)");
560 assert_eq!(title_from("###\n", 40), "(empty task)");
561 }
562
563 #[test]
564 fn a_long_title_is_elided_by_characters_not_bytes() {
565 let long = "課題".repeat(30);
567 let title = title_from(&long, 10);
568 assert_eq!(title.chars().count(), 10);
569 assert!(title.ends_with('…'));
570 }
571
572 #[test]
573 fn priority_wins_and_ties_break_oldest_first() {
574 let (_dir, q) = queue();
575 let mut a = task("first");
576 let mut b = task("second");
577 let mut c = task("urgent");
578 a.id = "20260101-000001-aaaa".to_owned();
580 b.id = "20260101-000002-bbbb".to_owned();
581 c.id = "20260101-000003-cccc".to_owned();
582 c.priority = 5;
583 for t in [&mut a, &mut b, &mut c] {
584 q.put(t).unwrap();
585 }
586
587 assert_eq!(q.next_runnable().unwrap().id, c.id);
589 c.hold();
590 q.put(&mut c).unwrap();
591 assert_eq!(q.next_runnable().unwrap().id, a.id);
593 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
594 }
595
596 #[test]
597 fn a_held_task_is_never_offered_to_the_loop() {
598 let (_dir, q) = queue();
599 let mut t = task("held");
600 q.put(&mut t).unwrap();
601 assert!(q.next_runnable().is_some());
602
603 t.hold();
604 q.put(&mut t).unwrap();
605 assert!(
606 q.next_runnable().is_none(),
607 "a held task must wait for a human"
608 );
609
610 t.status = TaskStatus::Failed;
612 q.put(&mut t).unwrap();
613 assert!(q.next_runnable().is_some());
614 }
615
616 #[test]
617 fn attempts_are_capped_and_then_the_task_is_held() {
618 let mut t = task("doomed");
619
620 t.start("run-1".to_owned());
621 t.fail("gate red", 2);
622 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
623
624 t.start("run-2".to_owned());
625 t.fail("gate red", 2);
626 assert_eq!(
627 t.status,
628 TaskStatus::Held,
629 "out of attempts: stop spending money on it"
630 );
631 assert_eq!(t.runs, ["run-1", "run-2"]);
632 assert_eq!(t.last_error.as_deref(), Some("gate red"));
633 }
634
635 #[test]
636 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
637 let mut t = task("stalled by quota");
638
639 t.start("run-1".to_owned());
640 assert_eq!(t.attempts, 1);
641 t.stall("judge-1, judge-2 out of quota");
642 assert_eq!(
643 t.attempts, 0,
644 "a closed quota window must not spend the task's retry budget"
645 );
646 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
647 assert_eq!(
648 t.last_error.as_deref(),
649 Some("judge-1, judge-2 out of quota")
650 );
651
652 for _ in 0..20 {
655 t.start("run-n".to_owned());
656 t.stall("still out of quota");
657 }
658 t.start("run-real".to_owned());
659 t.fail("gate red", 2);
660 assert_eq!(
661 t.status,
662 TaskStatus::Failed,
663 "the first attempt that was really judged is attempt one"
664 );
665 }
666
667 #[test]
668 fn releasing_a_held_task_gives_it_a_real_second_chance() {
669 let mut t = task("retry me");
670 t.start("run-1".to_owned());
671 t.fail("gate red", 1);
672 assert_eq!(t.status, TaskStatus::Held);
673
674 t.release();
675 assert_eq!(t.status, TaskStatus::Queued);
676 assert_eq!(t.attempts, 0);
679 assert!(t.last_error.is_none());
680 assert_eq!(
681 t.runs.len(),
682 1,
683 "history is kept: attempts reset, evidence does not"
684 );
685 }
686
687 #[test]
688 fn a_claim_is_exclusive_and_releases_on_drop() {
689 let (_dir, q) = queue();
690 let mut t = task("contended");
691 q.put(&mut t).unwrap();
692
693 let held = q.claim(&t.id).unwrap();
694 assert!(
695 q.claim(&t.id).is_err(),
696 "two daemons must not drive one task into two runs"
697 );
698 drop(held);
699 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
700 }
701
702 #[test]
703 fn a_round_trip_survives_disk() {
704 let (_dir, q) = queue();
705 let mut t = Task::new(
706 "titled".to_owned(),
707 "body".to_owned(),
708 PathBuf::from("/repo"),
709 Source::Agent {
710 run: "20260101-000000-beef".to_owned(),
711 node: "implement".to_owned(),
712 },
713 );
714 t.priority = 3;
715 q.put(&mut t).unwrap();
716
717 let back = q.get(&t.id).unwrap();
718 assert_eq!(back.id, t.id);
719 assert_eq!(back.priority, 3);
720 assert_eq!(back.source.label(), "implement@beef");
721 assert_eq!(q.get(t.short()).unwrap().id, t.id);
723 }
724
725 #[test]
726 fn an_unreadable_task_does_not_take_the_queue_down() {
727 let (_dir, q) = queue();
728 let mut t = task("fine");
729 q.put(&mut t).unwrap();
730 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
731
732 let listed = q.list();
733 assert_eq!(listed.len(), 1, "the readable task still lists");
734 assert_eq!(listed[0].id, t.id);
735 }
736
737 #[test]
738 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
739 let (_dir, q) = queue();
740 let path = q.path_of("20260101-000000-aaaa");
741 std::fs::create_dir_all(q.root()).unwrap();
742 std::fs::write(
743 &path,
744 serde_json::json!({
745 "schema": SCHEMA,
746 "id": "20260101-000000-aaaa",
747 "title": "from before solo existed",
748 "instruction": "from before solo existed",
749 "repo": ".",
750 "source": { "kind": "human" },
751 "status": "queued",
752 "created_at": Timestamp::now().to_string(),
753 "updated_at": Timestamp::now().to_string(),
754 })
755 .to_string(),
756 )
757 .unwrap();
758
759 let task = q.get("20260101-000000-aaaa").expect("must still read");
760 assert!(!task.solo, "a queue file with no `solo` field means false");
761 }
762
763 #[test]
764 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
765 let (_dir, q) = queue();
766 let mut t = task("from the future");
767 q.put(&mut t).unwrap();
768 let path = q.path_of(&t.id);
769 let body = std::fs::read_to_string(&path)
770 .unwrap()
771 .replace("\"schema\": 1", "\"schema\": 99");
772 std::fs::write(&path, body).unwrap();
773
774 let err = q.get(&t.id).unwrap_err().to_string();
775 assert!(err.contains("schema 99"), "{err}");
776 }
777
778 #[test]
779 fn revision_moves_when_the_queue_changes() {
780 let (_dir, q) = queue();
781 assert_eq!(q.revision(), 0, "an empty queue has no revision");
782 let mut t = task("first");
783 q.put(&mut t).unwrap();
784 assert!(q.revision() > 0, "a written task moves the revision");
785 }
786
787 #[test]
788 fn revision_moves_when_deleting_an_older_task() {
789 let (_dir, q) = queue();
790 let mut t1 = task("older");
791 q.put(&mut t1).unwrap();
792 std::thread::sleep(std::time::Duration::from_millis(10));
794 let mut t2 = task("newer");
795 q.put(&mut t2).unwrap();
796
797 let rev_before = q.revision();
798 q.remove(&t1.id, false).unwrap();
799 let rev_after = q.revision();
800
801 assert_ne!(
802 rev_before, rev_after,
803 "deleting an older task must change the revision so other clients see the deletion"
804 );
805 }
806
807 #[test]
808 fn removing_a_task_takes_it_out_of_the_listing() {
809 let (_dir, q) = queue();
810 let mut t = task("delete me");
811 q.put(&mut t).unwrap();
812 let removed = q.remove(t.short(), false).unwrap();
813 assert_eq!(removed, t.id, "a prefix resolves before deleting");
814 assert!(q.list().is_empty());
815 assert!(
816 q.remove(&t.id, false).is_err(),
817 "removing twice is an error"
818 );
819 }
820
821 #[test]
822 fn removing_a_task_takes_its_stale_lock_with_it() {
823 let (_dir, q) = queue();
824 let mut t = task("interrupted");
825 q.put(&mut t).unwrap();
826
827 let claim = q.claim(&t.id).unwrap();
830 std::mem::forget(claim);
831 assert!(
832 q.claim(&t.id).is_err(),
833 "the orphaned lock is what makes the task look claimed"
834 );
835
836 let err = q.remove(&t.id, true).unwrap_err().to_string();
838 assert!(err.contains("live daemon"), "{err}");
839 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
840
841 q.remove(&t.id, false).unwrap();
843 assert!(q.list().is_empty());
844 let mut again = task("interrupted");
845 again.id = t.id.clone();
846 q.put(&mut again).unwrap();
847 assert!(
848 q.claim(&t.id).is_ok(),
849 "a task that comes back must be claimable, which a left-behind lock would prevent"
850 );
851 }
852}