Skip to main content

magi/
queue.rs

1//! The task queue: what magi should do next, and who asked for it.
2//!
3//! The queue is what lets magi run unattended. `magi serve` takes the next
4//! task, runs the graph on it, records the outcome, and takes the next one.
5//!
6//! It is also the reason an agent can ask for work. `magi task add` is the
7//! whole interface, and it is the same command whether a human types it at a
8//! prompt, a phone posts it through the web UI, or an implementer inside a run
9//! shells out to it because it noticed something worth doing but out of scope.
10//! magi's CLI is the operating surface for both kinds of user; the queue is
11//! where their intentions meet.
12//!
13//! One task is one JSON file under [`Queue`]'s root. Files rather than a
14//! database because the operator has to be able to read, edit, and delete the
15//! backlog with the tools already on the machine, and because a crashed daemon
16//! must leave a queue the next one can pick up without recovery ceremony.
17//!
18//! # Shape
19//!
20//! [`Task`] is data plus *pure* state transitions - [`Task::fail`] decides
21//! whether an attempt was the last one, and touches no disk. [`Queue`] owns all
22//! I/O and is constructed with its root, so a test drives a real queue in a
23//! temp directory without setting a process-global home. Splitting them this
24//! way is why the retry policy below can be asserted directly.
25//!
26//! # Bounded by construction
27//!
28//! An autonomous loop that retries forever is a way to spend money on a task
29//! that cannot succeed. Every claim increments [`Task::attempts`]; a task that
30//! has burned its attempts becomes [`TaskStatus::Held`] and waits for a human
31//! rather than for another agent.
32
33use std::path::{Path, PathBuf};
34
35use anyhow::{Context, Result, bail};
36use jiff::Timestamp;
37use serde::{Deserialize, Serialize};
38
39/// On-disk format for a queued task. Bumped when a field's meaning changes.
40pub const SCHEMA: u32 = 1;
41
42/// Where a task came from. Recorded because "who asked for this" is the first
43/// question about an autonomous run, and the answer is not recoverable later.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "kind", rename_all = "lowercase")]
46pub enum Source {
47    /// A person, at a terminal or through the web UI.
48    Human,
49    /// An agent inside a run, via `magi task add`. Both ids are recorded so a
50    /// task can be traced back to the exact seat that asked for it.
51    Agent {
52        /// Run the asking agent belonged to.
53        run: String,
54        /// Node it was working in, e.g. `implement` or `review`.
55        node: String,
56    },
57    /// A GitHub issue, imported by number.
58    Issue {
59        /// Issue number.
60        number: u64,
61        /// `owner/repo`, as `gh` reports it.
62        repo: String,
63    },
64}
65
66impl Source {
67    /// Short human-facing label, for lists and the web UI.
68    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/// Where a task is in its life.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum TaskStatus {
81    /// Waiting to be claimed.
82    Queued,
83    /// Claimed by a daemon; a run is in flight.
84    Running,
85    /// A run finished and its gate passed.
86    Done,
87    /// A run finished without passing, and attempts remain.
88    Failed,
89    /// Out of attempts, or held by hand. The loop will not pick it up.
90    Held,
91}
92
93impl TaskStatus {
94    /// Is this task eligible for a daemon to claim?
95    pub fn runnable(self) -> bool {
96        matches!(self, Self::Queued | Self::Failed)
97    }
98
99    /// Lowercase name, as it appears on disk and in the API.
100    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/// One unit of work.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct Task {
115    /// On-disk format version.
116    pub schema: u32,
117    /// Task id, e.g. `20260902-140501-a1b2`.
118    pub id: String,
119    /// One line, for lists and notifications.
120    pub title: String,
121    /// The task itself, handed to the graph verbatim.
122    pub instruction: String,
123    /// Repository to work in.
124    pub repo: PathBuf,
125    /// Who asked.
126    pub source: Source,
127    /// Higher runs first; ties break oldest-first so nothing starves.
128    #[serde(default)]
129    pub priority: i32,
130    /// Current state.
131    pub status: TaskStatus,
132    /// How many times this task has been claimed.
133    #[serde(default)]
134    pub attempts: usize,
135    /// Runs this task has produced, oldest first.
136    #[serde(default)]
137    pub runs: Vec<String>,
138    /// Why the last attempt did not land.
139    #[serde(default)]
140    pub last_error: Option<String>,
141    /// When the task was filed.
142    pub created_at: Timestamp,
143    /// Last change to this file.
144    pub updated_at: Timestamp,
145}
146
147impl Task {
148    /// File a new task. Persist it with [`Queue::put`].
149    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    /// Short form used in reports, matching a run's short id.
169    pub fn short(&self) -> &str {
170        short(&self.id)
171    }
172
173    /// Record that a run has started for this task.
174    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    /// Record a successful run.
182    pub fn succeed(&mut self) {
183        self.status = TaskStatus::Done;
184        self.last_error = None;
185    }
186
187    /// Record a failed attempt. Out of attempts means held for a human, rather
188    /// than retried until the money runs out.
189    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    /// Record an attempt that failed for a reason the task is not responsible
199    /// for - the agent CLIs ran out of quota and the judging panel collapsed.
200    ///
201    /// This refunds the attempt on purpose. A quota window closing at 4am must
202    /// not spend the backlog's retry budget: the operator would come back to a
203    /// queue of held tasks that were never actually judged, and would have to
204    /// release every one by hand to find out which had a real problem. The task
205    /// goes back to `Failed`, which the loop retries, so a reset quota picks the
206    /// work up where it stopped.
207    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    /// Take this task out of the loop's reach without deleting it.
214    pub fn hold(&mut self) {
215        self.status = TaskStatus::Held;
216    }
217
218    /// Record a run that produced a pull request without merging it.
219    ///
220    /// The task is held rather than retried, and it costs no further attempt
221    /// either way. The work the task asked for exists: it is sitting on a
222    /// branch, in a pull request, waiting for CI or for a person. Retrying
223    /// would spend the whole competition budget a second time and then race a
224    /// second branch against the pull request the first one opened - which is
225    /// exactly what happened to run 01c2, whose finished and green pull request
226    /// was re-competed from scratch four seconds after it opened.
227    ///
228    /// A pull request nobody merged is a request for a person, not a failure.
229    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    /// Put a held or finished task back in line, with its attempt count reset
235    /// so a release is a real second chance rather than an instant re-hold.
236    /// The run history is kept: attempts reset, evidence does not.
237    pub fn release(&mut self) {
238        self.status = TaskStatus::Queued;
239        self.attempts = 0;
240        self.last_error = None;
241    }
242}
243
244/// A queue on disk.
245#[derive(Debug, Clone)]
246pub struct Queue {
247    root: PathBuf,
248}
249
250impl Queue {
251    /// The operator's queue, `<home>/queue`.
252    pub fn open() -> Self {
253        Self::at(crate::run::home().join("queue"))
254    }
255
256    /// A queue at an explicit root. Tests use this; so could an operator who
257    /// wants a queue per project.
258    pub fn at(root: PathBuf) -> Self {
259        Self { root }
260    }
261
262    /// Directory holding the task files.
263    pub fn root(&self) -> &Path {
264        &self.root
265    }
266
267    /// Path for one task id.
268    pub fn path_of(&self, id: &str) -> PathBuf {
269        self.root.join(format!("{id}.json"))
270    }
271
272    /// Write a task, atomically, so a daemon killed mid-write leaves the
273    /// previous state readable rather than a truncated file.
274    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    /// Load a task by id or unambiguous id prefix.
287    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    /// Remove a task, and the claim lock that belongs to it.
293    ///
294    /// `in_flight` comes from the caller — a live daemon's heartbeat naming
295    /// this task — because the task's own `running` status cannot answer the
296    /// question. A daemon killed mid-competition leaves the status at
297    /// `running` and an orphaned `.lock` behind, and a guard that trusted
298    /// either would make the task undeletable for good: the phone showed
299    /// exactly that, refusing a task whose daemon had been gone for an hour.
300    ///
301    /// So the lock is removed with the task rather than respected. Any lock
302    /// still there once no live daemon claims the task is by definition stale,
303    /// and leaving it would make a deleted task look claimed to
304    /// [`Queue::claim`] and to whoever reads the directory.
305    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    /// Path of the claim lock for a task. One definition, so `claim` and
322    /// `remove` cannot end up naming different files.
323    fn lock_path(&self, id: &str) -> PathBuf {
324        self.root.join(format!("{id}.lock"))
325    }
326
327    /// Every task on disk, newest first. Unreadable files are skipped rather
328    /// than fatal: one corrupt task must not take the queue - or the web UI,
329    /// or an unattended daemon - down with it.
330    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    /// The task a daemon should run next, or `None` when the queue is idle.
344    ///
345    /// Highest priority first, oldest first within a priority, so a burst of
346    /// agent-filed work cannot starve the task a human filed this morning.
347    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    /// Take exclusive ownership of a task.
358    ///
359    /// The lock is a `create_new` file next to the task, which is atomic on
360    /// every platform magi targets. It exists so two daemons - or a daemon and
361    /// a human running `magi run` - cannot drive one task into two competing
362    /// runs. The returned guard releases on drop, including on panic.
363    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                // Best effort: the pid is for the human looking at a stale lock.
375                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    /// Expand an id prefix to exactly one task id.
386    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    /// Change detection token for the queue.
408    ///
409    /// Combines file names and modification times of all task files in the
410    /// queue, so adding, modifying, or deleting any task — even an older one —
411    /// moves the revision and notifies connected clients via the change stream.
412    /// Returns 0 when the queue is completely empty.
413    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/// Exclusive ownership of a task, released on drop.
451#[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
462/// The first line of a task, trimmed to a title. Used when the caller gives a
463/// body but no title, which is the normal case for an agent piping a file in.
464pub fn title_from(instruction: &str, max: usize) -> String {
465    // The first non-blank line, whatever it is. A markdown heading is the
466    // task's own summary - agents pipe in `# Rework the config loader` and mean
467    // exactly that - so it is preferred over the prose beneath it rather than
468    // skipped as decoration. Leading list and heading markers are stripped
469    // because they are syntax, not words.
470    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    /// A queue of its own, with no process-global state - which is the point of
517    /// `Queue::at`, and why these can run in parallel.
518    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        // A task file's heading is the summary its author already wrote, so it
536        // beats the prose underneath. Getting this backwards was visible in the
537        // first smoke test: a task titled "# Rework the config loader" listed
538        // as "It re-reads the file on every lookup".
539        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        // Nothing usable at all still has to produce something printable.
546        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        // Byte truncation would split a multi-byte character and panic.
553        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        // Ids carry a timestamp, so force a known order.
566        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        // Priority first...
575        assert_eq!(q.next_runnable().unwrap().id, c.id);
576        c.hold();
577        q.put(&mut c).unwrap();
578        // ...then oldest, so a burst of new work cannot starve older work.
579        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        // A failed task, by contrast, is exactly what the loop should retry.
598        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        // A task can therefore stall all night and still get its real attempts
640        // once the quota resets - which is the whole point.
641        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        // Without resetting attempts the next failure would re-hold at once,
664        // and a release would be a no-op the operator cannot see.
665        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        // A prefix is enough, the way run ids work everywhere else.
709        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        // Ensure mtime ticks forward.
754        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        // A daemon killed mid-run leaves this behind. Nothing holds it: the
789        // process that would have dropped the guard is gone.
790        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        // A live daemon on this task is refused, whatever the lock says.
798        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        // With no daemon behind it, the lock is stale and goes with the task.
803        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}