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    /// Run this task alone: one implementer, no panel of judges to convince.
131    ///
132    /// `#[serde(default)]` so a queue file written before this field existed
133    /// still reads, as `false` - the ordinary multi-candidate competition,
134    /// unchanged. A task set to `solo` still runs the whole graph; only the
135    /// candidate count the daemon builds it with changes, and
136    /// [`crate::graph::Runner`] already collapses a single-candidate run to
137    /// implement → review → gate → merge on its own (see
138    /// [`crate::graph::Runner::review`]'s doc), so nothing about judging,
139    /// deliberation or voting had to change to support this.
140    #[serde(default)]
141    pub solo: bool,
142    /// Current state.
143    pub status: TaskStatus,
144    /// How many times this task has been claimed.
145    #[serde(default)]
146    pub attempts: usize,
147    /// Runs this task has produced, oldest first.
148    #[serde(default)]
149    pub runs: Vec<String>,
150    /// Why the last attempt did not land.
151    #[serde(default)]
152    pub last_error: Option<String>,
153    /// What a human hold is waiting on.
154    ///
155    /// `None` covers both the ordinary cases: a hold the loop makes itself
156    /// (out of attempts, or the disk gate closed) explains itself through
157    /// [`Task::last_error`] instead, and a human hold nobody bothered to
158    /// explain is still a valid hold. The queue has no way to express a
159    /// dependency between two tasks, so on the occasions a hold really is
160    /// "wait for that other task first", this is the only place that reason
161    /// survives - see [`Task::hold`] and [`Task::release`].
162    ///
163    /// `#[serde(default)]` so a queue file written before this field existed
164    /// still reads, with no reason recorded rather than a parse error.
165    #[serde(default)]
166    pub hold_reason: Option<String>,
167    /// When the task was filed.
168    pub created_at: Timestamp,
169    /// Last change to this file.
170    pub updated_at: Timestamp,
171}
172
173impl Task {
174    /// File a new task. Persist it with [`Queue::put`].
175    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    /// Short form used in reports, matching a run's short id.
197    pub fn short(&self) -> &str {
198        short(&self.id)
199    }
200
201    /// Record that a run has started for this task.
202    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    /// Record a successful run.
210    ///
211    /// Both `magi task done` and `POST /api/queue/{id}/done` can close a held
212    /// task directly, with no release in between, so this clears
213    /// `hold_reason` the same way [`Task::release`] does. Otherwise a task
214    /// held for "waiting on 3ed9" and then closed as done without ever being
215    /// released would still read as waiting on something in `magi task show`
216    /// and on its card, after it no longer is.
217    pub fn succeed(&mut self) {
218        self.status = TaskStatus::Done;
219        self.last_error = None;
220        self.hold_reason = None;
221    }
222
223    /// Record a failed attempt. Out of attempts means held for a human, rather
224    /// than retried until the money runs out.
225    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    /// Record an attempt that failed for a reason the task is not responsible
235    /// for - the agent CLIs ran out of quota and the judging panel collapsed.
236    ///
237    /// This refunds the attempt on purpose. A quota window closing at 4am must
238    /// not spend the backlog's retry budget: the operator would come back to a
239    /// queue of held tasks that were never actually judged, and would have to
240    /// release every one by hand to find out which had a real problem. The task
241    /// goes back to `Failed`, which the loop retries, so a reset quota picks the
242    /// work up where it stopped.
243    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    /// Take this task out of the loop's reach without deleting it.
250    ///
251    /// `reason` replaces whatever was recorded before when it is given.
252    /// Passing `None` - the loop's own holds do this - leaves any existing
253    /// reason alone, so a machine-initiated hold cannot erase what a human
254    /// wrote down about a previous one.
255    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    /// Change how urgently this task should run next.
263    ///
264    /// Refused once the task is `running`: priority only feeds the sort
265    /// [`Queue::next_runnable`] does over tasks waiting to be claimed, and a
266    /// running task has already left that pool. Accepting the write anyway
267    /// would look like it worked while changing nothing until - and unless -
268    /// this attempt fails and the task becomes runnable again, which is a
269    /// surprise the phone should not hand back as a success.
270    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    /// Replace this task's title and instruction wholesale.
283    ///
284    /// Restricted to `queued` and `held`. A `running` task's instruction has
285    /// already been handed to the graph, so a run in flight and the file on
286    /// disk must not be allowed to disagree about what was asked; a `done` or
287    /// `failed` task is a record of what actually happened and editing it
288    /// after the fact would falsify that record. `id`, `created_at`,
289    /// `source`, and `runs` are left untouched on purpose - an edit stands in
290    /// for "delete and refile", and keeping the id, the timestamp, the
291    /// attribution, and the run history is the entire reason it exists
292    /// instead.
293    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    /// Record a run that produced a pull request without merging it.
308    ///
309    /// The task is held rather than retried, and it costs no further attempt
310    /// either way. The work the task asked for exists: it is sitting on a
311    /// branch, in a pull request, waiting for CI or for a person. Retrying
312    /// would spend the whole competition budget a second time and then race a
313    /// second branch against the pull request the first one opened - which is
314    /// exactly what happened to run 01c2, whose finished and green pull request
315    /// was re-competed from scratch four seconds after it opened.
316    ///
317    /// A pull request nobody merged is a request for a person, not a failure.
318    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    /// Put a held or finished task back in line, with its attempt count reset
324    /// so a release is a real second chance rather than an instant re-hold.
325    /// The run history is kept: attempts reset, evidence does not.
326    pub fn release(&mut self) {
327        self.status = TaskStatus::Queued;
328        self.attempts = 0;
329        self.last_error = None;
330        // Otherwise the next person who holds this task reads a reason that
331        // belonged to whatever it was waiting on last time.
332        self.hold_reason = None;
333    }
334}
335
336/// A queue on disk.
337#[derive(Debug, Clone)]
338pub struct Queue {
339    root: PathBuf,
340}
341
342impl Queue {
343    /// The operator's queue, `<home>/queue`.
344    pub fn open() -> Self {
345        Self::at(crate::run::home().join("queue"))
346    }
347
348    /// A queue at an explicit root. Tests use this; so could an operator who
349    /// wants a queue per project.
350    pub fn at(root: PathBuf) -> Self {
351        Self { root }
352    }
353
354    /// Directory holding the task files.
355    pub fn root(&self) -> &Path {
356        &self.root
357    }
358
359    /// Path for one task id.
360    pub fn path_of(&self, id: &str) -> PathBuf {
361        self.root.join(format!("{id}.json"))
362    }
363
364    /// Write a task, atomically, so a daemon killed mid-write leaves the
365    /// previous state readable rather than a truncated file.
366    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    /// Load a task by id or unambiguous id prefix.
379    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    /// Remove a task, and the claim lock that belongs to it.
385    ///
386    /// `in_flight` comes from the caller — a live daemon's heartbeat naming
387    /// this task — because the task's own `running` status cannot answer the
388    /// question. A daemon killed mid-competition leaves the status at
389    /// `running` and an orphaned `.lock` behind, and a guard that trusted
390    /// either would make the task undeletable for good: the phone showed
391    /// exactly that, refusing a task whose daemon had been gone for an hour.
392    ///
393    /// So the lock is removed with the task rather than respected. Any lock
394    /// still there once no live daemon claims the task is by definition stale,
395    /// and leaving it would make a deleted task look claimed to
396    /// [`Queue::claim`] and to whoever reads the directory.
397    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    /// Path of the claim lock for a task. One definition, so `claim` and
414    /// `remove` cannot end up naming different files.
415    fn lock_path(&self, id: &str) -> PathBuf {
416        self.root.join(format!("{id}.lock"))
417    }
418
419    /// Every task on disk, highest priority first and newest first within a
420    /// priority. This is what `magi task list` and `GET /api/queue` print, so
421    /// a raised priority has to move a task here the moment it is saved, not
422    /// only in [`Queue::next_runnable`]'s own ordering - the operator reading
423    /// the backlog and the loop about to drain it must agree on what "first"
424    /// means. Every existing task defaults to priority 0, so this is a no-op
425    /// change from the old newest-first order for a queue nobody has
426    /// reprioritised.
427    ///
428    /// Unreadable files are skipped rather than fatal: one corrupt task must
429    /// not take the queue - or the web UI, or an unattended daemon - down
430    /// with it.
431    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    /// The task a daemon should run next, or `None` when the queue is idle.
445    ///
446    /// Highest priority first, oldest first within a priority, so a burst of
447    /// agent-filed work cannot starve the task a human filed this morning.
448    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    /// Take exclusive ownership of a task.
459    ///
460    /// The lock is a `create_new` file next to the task, which is atomic on
461    /// every platform magi targets. It exists so two daemons - or a daemon and
462    /// a human running `magi run` - cannot drive one task into two competing
463    /// runs. The returned guard releases on drop, including on panic.
464    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                // Best effort: the pid is for the human looking at a stale lock.
476                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    /// Expand an id prefix to exactly one task id.
487    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    /// Change detection token for the queue.
509    ///
510    /// Combines file names and modification times of all task files in the
511    /// queue, so adding, modifying, or deleting any task — even an older one —
512    /// moves the revision and notifies connected clients via the change stream.
513    /// Returns 0 when the queue is completely empty.
514    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/// Exclusive ownership of a task, released on drop.
552#[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
563/// The first line of a task, trimmed to a title. Used when the caller gives a
564/// body but no title, which is the normal case for an agent piping a file in.
565pub fn title_from(instruction: &str, max: usize) -> String {
566    // The first non-blank line, whatever it is. A markdown heading is the
567    // task's own summary - agents pipe in `# Rework the config loader` and mean
568    // exactly that - so it is preferred over the prose beneath it rather than
569    // skipped as decoration. Leading list and heading markers are stripped
570    // because they are syntax, not words.
571    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    /// A queue of its own, with no process-global state - which is the point of
618    /// `Queue::at`, and why these can run in parallel.
619    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        // A task file's heading is the summary its author already wrote, so it
637        // beats the prose underneath. Getting this backwards was visible in the
638        // first smoke test: a task titled "# Rework the config loader" listed
639        // as "It re-reads the file on every lookup".
640        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        // Nothing usable at all still has to produce something printable.
647        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        // Byte truncation would split a multi-byte character and panic.
654        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        // Ids carry a timestamp, so force a known order.
667        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        // Priority first...
676        assert_eq!(q.next_runnable().unwrap().id, c.id);
677        c.hold(None);
678        q.put(&mut c).unwrap();
679        // ...then oldest, so a burst of new work cannot starve older work.
680        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        // A failed task, by contrast, is exactly what the loop should retry.
699        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        // A task can therefore stall all night and still get its real attempts
741        // once the quota resets - which is the whole point.
742        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        // Without resetting attempts the next failure would re-hold at once,
765        // and a release would be a no-op the operator cannot see.
766        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        // Holding again with no reason must not erase the one already there.
788        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        // A hold with no reason at all is still an ordinary, allowed hold.
796        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        // `done` can close a held task directly - neither `magi task done`
812        // nor `POST /api/queue/{id}/done` requires a release first - so a
813        // task held for "waiting on 3ed9" and then closed without ever being
814        // released must not still read as waiting on it afterwards.
815        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        // `magi task list` and `GET /api/queue` both print `Queue::list()`
873        // directly, so the raised task has to lead there too - not only in
874        // what the loop would claim next.
875        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        // Both queued and held are the point of the feature and must work.
935        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        // A prefix is enough, the way run ids work everywhere else.
1004        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        // Ensure mtime ticks forward.
1075        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        // A daemon killed mid-run leaves this behind. Nothing holds it: the
1110        // process that would have dropped the guard is gone.
1111        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        // A live daemon on this task is refused, whatever the lock says.
1119        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        // With no daemon behind it, the lock is stale and goes with the task.
1124        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}