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