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
39use crate::ask::Questions;
40
41/// On-disk format for a queued task. Bumped when a field's meaning changes.
42///
43/// 5: added [`Task::triage_applied`], the ids of triage questions whose
44/// answer has already been applied to this task. A "resume" answer used to
45/// leave no trace ([`Task::release`] clears [`Task::hold_reason`], which was
46/// the only place the applied marker lived), so when the released task failed
47/// its attempts and went back to `held`, the next idle pass found the same
48/// answered question "not applied" and released it again with `attempts` reset
49/// to 0 - the `max_attempts` bound never held. `#[serde(default)]` so an older
50/// record reads as empty. A task already looping when this build arrives has
51/// no record, so it is released once more, recorded, and then stays held.
52///
53/// 4: added [`Task::blocked_from`], the status a task had the moment it
54/// became [`TaskStatus::Blocked`], so [`Task::unblock`] restores it instead
55/// of always landing on [`TaskStatus::Queued`]. Without it, a task a human
56/// or `crate::triage` had deliberately left [`TaskStatus::Held`] — machine
57/// or manual — would lose that the instant `crate::conduct` blocked it on a
58/// follow-up question, and come back `Queued` the moment the question was
59/// answered, regardless of what the answer said: exactly the loop where a
60/// task the operator told to stay held instead re-enters the competition
61/// queue every time someone answers a question about it. `#[serde(default)]`
62/// so an older record reads as `None`; [`Task::unblock`] then falls back to
63/// inferring `Held` from surviving hold evidence ([`Task::hold_reason`] /
64/// [`Task::hold_source`], never cleared by [`Task::block`]) rather than
65/// guessing `Queued` outright — see [`Task::unblock`]'s own doc.
66///
67/// 3: added [`HoldSource`] so conductor recovery cannot release a hold an
68/// operator deliberately placed. Old records default to `None` and are
69/// protected as operator-held until an explicit release; the safe direction
70/// when their author was never recorded.
71///
72/// 2: added [`TaskStatus::Blocked`], [`Task::blocked_by`] and
73/// [`Task::block_reason`] (`crate::conduct`'s decisions) and
74/// [`Task::answers`] (operator answers carried forward to the next
75/// conductor prompt and the next run's instruction). All three are
76/// `#[serde(default)]`, so [`read_path`] accepts anything up to and
77/// including this schema rather than only an exact match — a task written
78/// by a build that only knew about schema 1 has nothing to say about
79/// blocking or answers, and defaulting those fields is exactly as good a
80/// reading as a value that build never had a chance to write.
81pub const SCHEMA: u32 = 5;
82
83/// Who placed the current hold.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum HoldSource {
87    /// An operator used the CLI or web UI.
88    Manual,
89    /// The daemon or conductor placed the hold as part of its own recovery.
90    Machine,
91}
92
93impl HoldSource {
94    /// Short human-facing label for reports and the CLI.
95    pub fn label(self) -> &'static str {
96        match self {
97            Self::Manual => "manual",
98            Self::Machine => "machine",
99        }
100    }
101}
102
103/// Where a task came from. Recorded because "who asked for this" is the first
104/// question about an autonomous run, and the answer is not recoverable later.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(tag = "kind", rename_all = "lowercase")]
107pub enum Source {
108    /// A person, at a terminal or through the web UI.
109    Human,
110    /// An agent inside a run, via `magi task add`. Both ids are recorded so a
111    /// task can be traced back to the exact seat that asked for it.
112    Agent {
113        /// Run the asking agent belonged to.
114        run: String,
115        /// Node it was working in, e.g. `implement` or `review`.
116        node: String,
117    },
118    /// A GitHub issue, imported by number.
119    Issue {
120        /// Issue number.
121        number: u64,
122        /// `owner/repo`, as `gh` reports it.
123        repo: String,
124    },
125}
126
127impl Source {
128    /// Short human-facing label, for lists and the web UI.
129    pub fn label(&self) -> String {
130        match self {
131            Self::Human => "human".to_owned(),
132            Self::Agent { run, node } => format!("{node}@{}", short(run)),
133            Self::Issue { number, .. } => format!("issue #{number}"),
134        }
135    }
136}
137
138/// Where a task is in its life.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "lowercase")]
141pub enum TaskStatus {
142    /// Waiting to be claimed.
143    Queued,
144    /// Claimed by a daemon; a run is in flight.
145    Running,
146    /// A run finished and its gate passed.
147    Done,
148    /// A run finished without passing, and attempts remain.
149    Failed,
150    /// Out of attempts, or held by hand. The loop will not pick it up.
151    Held,
152    /// Waiting on another task or an unanswered question. See
153    /// [`Task::blocked_by`]. Set and cleared by `crate::conduct` and
154    /// `crate::daemon`'s deterministic resolver, never by hand.
155    Blocked,
156}
157
158impl TaskStatus {
159    /// Is this task eligible for a daemon to claim?
160    pub fn runnable(self) -> bool {
161        matches!(self, Self::Queued | Self::Failed)
162    }
163
164    /// Lowercase name, as it appears on disk and in the API.
165    pub fn as_str(self) -> &'static str {
166        match self {
167            Self::Queued => "queued",
168            Self::Running => "running",
169            Self::Done => "done",
170            Self::Failed => "failed",
171            Self::Held => "held",
172            Self::Blocked => "blocked",
173        }
174    }
175}
176
177/// One unit of work.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct Task {
181    /// On-disk format version.
182    pub schema: u32,
183    /// Task id, e.g. `20260902-140501-a1b2`.
184    pub id: String,
185    /// One line, for lists and notifications.
186    pub title: String,
187    /// The task itself, handed to the graph verbatim.
188    pub instruction: String,
189    /// Repository to work in.
190    pub repo: PathBuf,
191    /// Who asked.
192    pub source: Source,
193    /// Higher runs first; ties break oldest-first so nothing starves.
194    #[serde(default)]
195    pub priority: i32,
196    /// Run this task alone: one implementer, no panel of judges to convince.
197    ///
198    /// `#[serde(default)]` so a queue file written before this field existed
199    /// still reads, as `false` - the ordinary multi-candidate competition,
200    /// unchanged. A task set to `solo` still runs the whole graph; only the
201    /// candidate count the daemon builds it with changes, and
202    /// [`crate::graph::Runner`] already collapses a single-candidate run to
203    /// implement → review → gate → merge on its own (see
204    /// [`crate::graph::Runner::review`]'s doc), so nothing about judging,
205    /// deliberation or voting had to change to support this.
206    #[serde(default)]
207    pub solo: bool,
208    /// Current state.
209    pub status: TaskStatus,
210    /// How many times this task has been claimed.
211    #[serde(default)]
212    pub attempts: usize,
213    /// Runs this task has produced, oldest first.
214    #[serde(default)]
215    pub runs: Vec<String>,
216    /// Why the last attempt did not land.
217    #[serde(default)]
218    pub last_error: Option<String>,
219    /// What a human hold is waiting on.
220    ///
221    /// `None` covers both the ordinary cases: a hold the loop makes itself
222    /// (out of attempts, or the disk gate closed) explains itself through
223    /// [`Task::last_error`] instead, and a human hold nobody bothered to
224    /// explain is still a valid hold. The queue has no way to express a
225    /// dependency between two tasks, so on the occasions a hold really is
226    /// "wait for that other task first", this is the only place that reason
227    /// survives - see [`Task::hold_manual`] and [`Task::release`].
228    ///
229    /// `#[serde(default)]` so a queue file written before this field existed
230    /// still reads, with no reason recorded rather than a parse error.
231    #[serde(default)]
232    pub hold_reason: Option<String>,
233    /// Who placed [`Task::hold_reason`].  `None` is a compatible old record;
234    /// see [`Task::operator_held`] for its deliberately conservative meaning.
235    #[serde(default)]
236    pub hold_source: Option<HoldSource>,
237    /// Diagnostic detail excerpted from the run that led to a hold - what a
238    /// human would have found opening `artifacts/` by hand, not the one-line
239    /// reason in [`Task::last_error`]. Set only when a run's own attempts are
240    /// exhausted and the task becomes [`TaskStatus::Held`]; `daemon` computes
241    /// it from the run's own record, since this module has no notion of a
242    /// run's internals. Bounded in length by the writer - see
243    /// `daemon::diagnostic` - so a verbose run cannot make this file grow
244    /// without limit.
245    ///
246    /// `#[serde(default)]` so a queue file written before this field existed
247    /// still reads, with no diagnostic recorded rather than a parse error.
248    #[serde(default)]
249    pub diagnostic: Option<String>,
250    /// What this task is waiting on: other task ids, unanswered
251    /// `crate::ask::Question` ids, or both. Non-empty exactly when
252    /// [`TaskStatus::Blocked`]; emptying it — see [`Task::unblock`] — is what
253    /// puts the task back at [`TaskStatus::Queued`].
254    ///
255    /// Set by `crate::conduct`'s decisions and cleared deterministically by
256    /// `crate::daemon` as each dependency resolves, never by a person. Never
257    /// `#[serde(default)]` is skipped: a queue file from before this field
258    /// existed has nothing to report here, and an empty list is exactly that.
259    #[serde(default)]
260    pub blocked_by: Vec<String>,
261    /// One line explaining the current [`Task::blocked_by`], written by
262    /// `crate::conduct`. Cleared whenever `blocked_by` empties.
263    #[serde(default)]
264    pub block_reason: Option<String>,
265    /// The status this task had the moment [`Task::block`] most recently
266    /// moved it to [`TaskStatus::Blocked`] — what [`Task::unblock`] restores
267    /// once nothing is left in `blocked_by`, instead of always landing on
268    /// [`TaskStatus::Queued`]. See [`SCHEMA`]'s doc for schema 4 on why this
269    /// exists: an answer to a question `crate::conduct` filed about a
270    /// [`TaskStatus::Held`] task must not itself be what puts the task back
271    /// in the competition queue.
272    ///
273    /// `#[serde(default)]` so a queue file written before this field existed
274    /// reads as `None`; [`Task::unblock`] treats that the same as a task
275    /// blocked straight from `Queued`, unless surviving hold evidence says
276    /// otherwise.
277    #[serde(default)]
278    pub blocked_from: Option<TaskStatus>,
279    /// Questions `crate::conduct` asked about this task that the operator has
280    /// since answered, oldest first — what was asked, and what they said.
281    ///
282    /// A blocking question's id leaves [`Task::blocked_by`] the moment
283    /// [`crate::ask::QuestionStatus::Answered`] is observed, but the id alone
284    /// tells nobody what was decided. This is what carries the answer's
285    /// *content* forward: into the next conductor prompt for this task, and
286    /// into the instruction handed to the next run — see `crate::daemon`'s
287    /// deterministic blocker resolution. Kept for the task's whole life, the
288    /// same as [`Task::runs`]: a release resets attempts, not evidence.
289    #[serde(default)]
290    pub answers: Vec<AnsweredQuestion>,
291    /// Ids of the `crate::triage` questions whose answer has been applied to
292    /// this task. Unlike [`Task::hold_reason`], [`Task::release`] and every
293    /// hold transition leave it alone, so an answer is applied at most once
294    /// however many times the task is held again. See [`SCHEMA`]'s doc for
295    /// schema 5. `#[serde(default)]` so an older record reads as empty.
296    #[serde(default)]
297    pub triage_applied: Vec<String>,
298    /// Set by `crate::conduct` when it chooses `Review` recovery for a task
299    /// whose branch survived a blocked run: the branch to reopen with
300    /// `crate::graph::Runner::review` instead of competing from scratch.
301    ///
302    /// Requeues the task the same way [`Task::release`] does, so it is
303    /// picked up by the ordinary loop; `crate::daemon` reads this field once,
304    /// when it actually starts the run, and clears it either way — consumed
305    /// on success, dropped if the branch no longer exists by then. Never set
306    /// from the conductor's own words: `crate::daemon` derives the branch
307    /// name itself from the task's last run, so a hallucinated branch can
308    /// never reach here.
309    #[serde(default)]
310    pub review_branch: Option<String>,
311    /// A release deliberately starts a new competition instead of resuming
312    /// the prior run. History remains as evidence in `runs`.
313    #[serde(default)]
314    pub fresh_start: bool,
315    /// Marked by an operator (`magi task interrupt`) to ask `magi serve` to
316    /// run this one ahead of whatever it already has in flight, once
317    /// `[daemon] pause_for_interrupts` is on - see
318    /// `crate::daemon::advance_interrupt`. Never set by the loop itself, and
319    /// deliberately a different operation from [`Task::set_priority`]: a
320    /// priority only reorders the queue a claim has not reached yet, while
321    /// this asks a run already in flight to park at its next safe boundary
322    /// and step aside. `#[serde(default)]` so a queue file written before
323    /// this field existed still reads, as `false` - no task interrupts
324    /// anything unless asked to, exactly as before.
325    #[serde(default)]
326    pub interrupt: bool,
327    /// Marked by `magi task add --urgent`: `crate::daemon::poll` dispatches
328    /// this task through its own one-slot `urgent_sem` the moment it is
329    /// runnable, in addition to whatever is already running under the
330    /// ordinary `[daemon] max_concurrent_runs` pool - never instead of it,
331    /// and never by pausing or otherwise touching that run. This is the
332    /// opposite direction from [`Task::interrupt`]: that one asks a run
333    /// already in flight to step aside; this one never asks anything to
334    /// step aside, it only spends one additional, temporary concurrency
335    /// slot. The two are independent and may both be set on the same task,
336    /// but this exemption stops at `[daemon] pause_for_interrupts`'s own
337    /// park/resume handoff (75dd): while an interrupt sequence is actively
338    /// parking, running, or resuming - its own, or an unrelated task's -
339    /// `crate::daemon::interrupt_gate` withholds an urgent candidate exactly
340    /// like an ordinary one, never exempted. 75dd's "at most one run, ever,
341    /// at once" guarantee takes precedence, because the alternative is a run
342    /// still genuinely in flight (only *asked* to park, not yet gone) ending
343    /// up alongside a second one this feature let through - the very thing
344    /// that guarantee exists to rule out.
345    ///
346    /// `#[serde(default)]` so a queue file written before this field existed
347    /// still reads, as `false` - no task claims the urgent slot unless asked
348    /// to, exactly as before.
349    #[serde(default)]
350    pub urgent: bool,
351    /// When the task was filed.
352    pub created_at: Timestamp,
353    /// Last change to this file.
354    pub updated_at: Timestamp,
355}
356
357/// One question `crate::conduct` asked about a task, and what the operator
358/// said back. See [`Task::answers`].
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360pub struct AnsweredQuestion {
361    /// The question as asked, e.g. [`crate::ask::Question::summary`].
362    pub question: String,
363    /// What the operator answered.
364    pub answer: String,
365}
366
367impl Task {
368    /// File a new task. Persist it with [`Queue::put`].
369    pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
370        let now = Timestamp::now();
371        Self {
372            schema: SCHEMA,
373            id: new_id(),
374            title,
375            instruction,
376            repo,
377            source,
378            priority: 0,
379            solo: false,
380            status: TaskStatus::Queued,
381            attempts: 0,
382            runs: Vec::new(),
383            last_error: None,
384            hold_reason: None,
385            hold_source: None,
386            diagnostic: None,
387            blocked_by: Vec::new(),
388            block_reason: None,
389            blocked_from: None,
390            answers: Vec::new(),
391            triage_applied: Vec::new(),
392            review_branch: None,
393            fresh_start: false,
394            interrupt: false,
395            urgent: false,
396            created_at: now,
397            updated_at: now,
398        }
399    }
400
401    /// Short form used in reports, matching a run's short id.
402    pub fn short(&self) -> &str {
403        short(&self.id)
404    }
405
406    /// Record that triage question `question_id`'s answer has been applied.
407    pub fn mark_triage_applied(&mut self, question_id: &str) {
408        if !self.triage_applied(question_id) {
409            self.triage_applied.push(question_id.to_owned());
410        }
411    }
412
413    /// Has triage question `question_id`'s answer already been applied?
414    pub fn triage_applied(&self, question_id: &str) -> bool {
415        self.triage_applied.iter().any(|id| id == question_id)
416    }
417
418    /// Record that a run has started for this task.
419    ///
420    /// Clears [`Task::interrupt`]: a mark to run ahead of whatever else is
421    /// in flight is fulfilled the moment this task actually gets its turn,
422    /// dispatched same as any other. Without this, a task whose run fails
423    /// and requeues - still `runnable`, still carrying the mark from its
424    /// first attempt - would keep re-triggering `crate::daemon`'s interrupt
425    /// scheduler and re-parking whatever it interrupted on every later
426    /// boundary, for as long as its attempts hold out, instead of the
427    /// one-shot "let this go next" the mark is meant to be.
428    pub fn start(&mut self, run: String) {
429        self.status = TaskStatus::Running;
430        self.attempts += 1;
431        self.runs.push(run);
432        self.last_error = None;
433        self.fresh_start = false;
434        self.interrupt = false;
435    }
436
437    /// Record a successful run.
438    ///
439    /// Both `magi task done` and `POST /api/queue/{id}/done` can close a held
440    /// *or blocked* task directly, with no release in between, so this clears
441    /// `hold_reason` and `blocked_by`/`block_reason` the same way
442    /// [`Task::release`] does. Otherwise a task held for "waiting on 3ed9", or
443    /// blocked on a dependency that never actually finished, and then closed
444    /// as done without ever being released would still read as waiting on
445    /// something in `magi task show` and on its card, after it no longer is.
446    pub fn succeed(&mut self) {
447        self.status = TaskStatus::Done;
448        self.last_error = None;
449        self.hold_reason = None;
450        self.hold_source = None;
451        self.diagnostic = None;
452        self.blocked_by.clear();
453        self.block_reason = None;
454        self.blocked_from = None;
455    }
456
457    /// Record a failed attempt. Out of attempts means held for a human, rather
458    /// than retried until the money runs out.
459    ///
460    /// Clears [`Task::diagnostic`] unconditionally: it belongs to whatever run
461    /// produced it, and a caller that has one for *this* attempt sets it
462    /// itself right after calling this, once it knows the task actually ended
463    /// up [`TaskStatus::Held`] - see `daemon::diagnostic`. Without the clear, a
464    /// task released after a diagnosed hold and then failed again for an
465    /// unrelated, undiagnosed reason (a config error, say) would go on
466    /// showing the previous run's diagnostic as if it explained the new one.
467    pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
468        self.last_error = Some(why.into());
469        self.diagnostic = None;
470        self.status = if self.attempts >= max_attempts {
471            self.hold_source = Some(HoldSource::Machine);
472            TaskStatus::Held
473        } else {
474            TaskStatus::Failed
475        };
476    }
477
478    /// Record an attempt that failed for a reason the task is not responsible
479    /// for - the agent CLIs ran out of quota and the judging panel collapsed.
480    ///
481    /// This refunds the attempt on purpose. A quota window closing at 4am must
482    /// not spend the backlog's retry budget: the operator would come back to a
483    /// queue of held tasks that were never actually judged, and would have to
484    /// release every one by hand to find out which had a real problem. The task
485    /// goes back to `Failed`, which the loop retries, so a reset quota picks the
486    /// work up where it stopped.
487    pub fn stall(&mut self, why: impl Into<String>) {
488        self.last_error = Some(why.into());
489        self.diagnostic = None;
490        self.attempts = self.attempts.saturating_sub(1);
491        self.status = TaskStatus::Failed;
492    }
493
494    /// Whether this held task may only be released by an operator.
495    ///
496    /// Old files did not record a source. Preserve every such hold rather
497    /// than guessing that it was automatic and risking duplicate work. New
498    /// automatic holds record [`HoldSource::Machine`] and remain recoverable.
499    pub fn operator_held(&self) -> bool {
500        self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
501    }
502
503    /// Take this task out of the loop's reach by an operator action.
504    ///
505    /// Clears `blocked_by`/`block_reason` unconditionally, the same as
506    /// [`Task::release`] and for the same reason its own comment already
507    /// gives: a human choosing to hold a *blocked* task overrides its wait
508    /// outright, the same as it overrides an ordinary hold. Without this, a
509    /// task held straight out of [`TaskStatus::Blocked`] - the web UI's "Hold"
510    /// button is reachable on a blocked task, same as "Mark done" - kept
511    /// reading as still waiting on a dependency it no longer had any claim on.
512    pub fn hold_manual(&mut self, reason: Option<String>) {
513        self.status = TaskStatus::Held;
514        if reason.is_some() {
515            self.hold_reason = reason;
516        }
517        self.hold_source = Some(HoldSource::Manual);
518        self.blocked_by.clear();
519        self.block_reason = None;
520        self.blocked_from = None;
521    }
522
523    /// Take this task out of the loop's reach during automatic recovery.
524    ///
525    /// Clears `blocked_by`/`block_reason` for the same reason
526    /// [`Task::hold_manual`] does.
527    pub fn hold_machine(&mut self, reason: Option<String>) {
528        self.status = TaskStatus::Held;
529        if reason.is_some() {
530            self.hold_reason = reason;
531        }
532        self.hold_source = Some(HoldSource::Machine);
533        self.blocked_by.clear();
534        self.block_reason = None;
535        self.blocked_from = None;
536    }
537
538    /// Block this task on other task ids and/or open question ids, chosen by
539    /// `crate::conduct`. Pure: the caller still owns writing it back with
540    /// [`Queue::put`].
541    ///
542    /// Records [`Task::blocked_from`] the first time this moves the task into
543    /// [`TaskStatus::Blocked`], and leaves it alone on a later call that adds
544    /// or replaces `blocked_by` while the task is already `Blocked` - a
545    /// second question about an already-blocked task must not overwrite the
546    /// status it should eventually return to with `Blocked` itself.
547    pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
548        if self.status != TaskStatus::Blocked {
549            self.blocked_from = Some(self.status);
550        }
551        self.status = TaskStatus::Blocked;
552        self.blocked_by = blocked_by;
553        self.block_reason = reason;
554    }
555
556    /// Remove one resolved dependency (a task id that became [`TaskStatus::Done`],
557    /// or a question id that became [`crate::ask::QuestionStatus::Answered`]).
558    /// Once nothing is left in [`Task::blocked_by`], the task returns to
559    /// whatever [`Task::blocked_from`] recorded - deciding *why* a task was
560    /// blocked was `crate::conduct`'s job, but noticing a dependency resolved
561    /// needs no model at all, and restoring the status it interrupted needs
562    /// nothing more than what `block` already wrote down.
563    ///
564    /// A task blocked while `Running` restores to [`TaskStatus::Queued`]
565    /// instead: whatever process was running it is gone by the time this
566    /// runs, so there is nothing left to resume. A task with no recorded
567    /// `blocked_from` - a pre-schema-4 record, or one blocked before this
568    /// field existed - falls back to [`TaskStatus::Held`] when it still
569    /// carries hold evidence ([`Task::hold_reason`] or [`Task::hold_source`],
570    /// neither ever cleared by `block`), and to `Queued` otherwise: the same
571    /// choice `block` itself would have recorded, reconstructed from what
572    /// survived.
573    ///
574    /// A no-op, on purpose, for a task that is not [`TaskStatus::Blocked`]:
575    /// `crate::daemon`'s deterministic resolver runs over every task on every
576    /// poll, and a task that moved on for some other reason must not be
577    /// dragged back by a stale id it still happens to carry.
578    pub fn unblock(&mut self, resolved_id: &str) {
579        if self.status != TaskStatus::Blocked {
580            return;
581        }
582        self.blocked_by.retain(|id| id != resolved_id);
583        if self.blocked_by.is_empty() {
584            self.status = match self.blocked_from {
585                Some(TaskStatus::Running) => TaskStatus::Queued,
586                Some(other) => other,
587                None if self.hold_reason.is_some() || self.hold_source.is_some() => {
588                    TaskStatus::Held
589                }
590                None => TaskStatus::Queued,
591            };
592            self.block_reason = None;
593            self.blocked_from = None;
594        }
595    }
596
597    /// Record that a question `crate::conduct` asked about this task has been
598    /// answered, so the answer's content — not just the fact that the
599    /// question is gone — reaches the next conductor prompt and the next
600    /// run's instruction. See [`Task::answers`].
601    pub fn record_answer(&mut self, question: String, answer: String) {
602        self.answers.push(AnsweredQuestion { question, answer });
603    }
604
605    /// Requeue this task to reopen its last run as a review-only pass against
606    /// `branch` (`crate::graph::Runner::review`) rather than competing from
607    /// scratch. See [`Task::review_branch`].
608    pub fn request_review(&mut self, branch: String) {
609        self.release();
610        self.review_branch = Some(branch);
611    }
612
613    /// Requeue after a conductor chose a new competition. Unlike an ordinary
614    /// operator release, this deliberately does not resume the old run.
615    pub fn requeue(&mut self) {
616        self.release();
617        self.fresh_start = true;
618    }
619
620    /// Change how urgently this task should run next.
621    ///
622    /// Refused once the task is `running`: priority only feeds the sort
623    /// [`Queue::next_runnable`] does over tasks waiting to be claimed, and a
624    /// running task has already left that pool. Accepting the write anyway
625    /// would look like it worked while changing nothing until - and unless -
626    /// this attempt fails and the task becomes runnable again, which is a
627    /// surprise the phone should not hand back as a success.
628    pub fn set_priority(&mut self, priority: i32) -> Result<()> {
629        if self.status == TaskStatus::Running {
630            bail!(
631                "task {} is running; its priority cannot be changed until \
632                 this attempt finishes",
633                self.short()
634            );
635        }
636        self.priority = priority;
637        Ok(())
638    }
639
640    /// Mark (or unmark) this task to interrupt whatever `magi serve` already
641    /// has in flight, once `[daemon] pause_for_interrupts` is on. See
642    /// [`Task::interrupt`].
643    ///
644    /// Setting it is restricted to a task the loop could pick up on its own
645    /// right now - [`TaskStatus::runnable`] - for the same reason as
646    /// [`Task::set_priority`]: a task already `running` has been claimed, and
647    /// a task that is `done`, `held`, or `blocked` is not going to compete
648    /// for the daemon's attention regardless of this flag. Unlike priority,
649    /// this is never silently inert while `running` - it is refused outright,
650    /// because the entire feature this flag drives (`crate::daemon`'s
651    /// interrupt scheduler) is scoped to tasks still waiting to be claimed.
652    /// Clearing it back to `false` carries no such risk and is always
653    /// allowed, including on a task that moved on since it was set.
654    pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
655        if interrupt && !self.status.runnable() {
656            bail!(
657                "task {} is {}; only a queued or failed task can be marked \
658                 to interrupt",
659                self.short(),
660                self.status.as_str()
661            );
662        }
663        self.interrupt = interrupt;
664        Ok(())
665    }
666
667    /// Replace this task's title and instruction wholesale.
668    ///
669    /// Restricted to `queued` and `held`. A `running` task's instruction has
670    /// already been handed to the graph, so a run in flight and the file on
671    /// disk must not be allowed to disagree about what was asked; a `done` or
672    /// `failed` task is a record of what actually happened and editing it
673    /// after the fact would falsify that record. `id`, `created_at`,
674    /// `source`, and `runs` are left untouched on purpose - an edit stands in
675    /// for "delete and refile", and keeping the id, the timestamp, the
676    /// attribution, and the run history is the entire reason it exists
677    /// instead.
678    pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
679        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
680            bail!(
681                "task {} is {}; only a queued or held task's instruction can \
682                 be edited",
683                self.short(),
684                self.status.as_str()
685            );
686        }
687        self.title = title;
688        self.instruction = instruction;
689        Ok(())
690    }
691
692    /// Record a run that produced a pull request without merging it.
693    ///
694    /// The task is held rather than retried, and it costs no further attempt
695    /// either way. The work the task asked for exists: it is sitting on a
696    /// branch, in a pull request, waiting for CI or for a person. Retrying
697    /// would spend the whole competition budget a second time and then race a
698    /// second branch against the pull request the first one opened - which is
699    /// exactly what happened to run 01c2, whose finished and green pull request
700    /// was re-competed from scratch four seconds after it opened.
701    ///
702    /// A pull request nobody merged is a request for a person, not a failure.
703    pub fn handed_off(&mut self, why: impl Into<String>) {
704        self.last_error = Some(why.into());
705        self.diagnostic = None;
706        self.status = TaskStatus::Held;
707        self.hold_source = Some(HoldSource::Machine);
708    }
709
710    /// Put a held or finished task back in line, with its attempt count reset
711    /// so a release is a real second chance rather than an instant re-hold.
712    /// The run history is kept: attempts reset, evidence does not.
713    pub fn release(&mut self) {
714        self.status = TaskStatus::Queued;
715        self.attempts = 0;
716        self.last_error = None;
717        // Otherwise the next person who holds this task reads a reason that
718        // belonged to whatever it was waiting on last time.
719        self.hold_reason = None;
720        self.hold_source = None;
721        self.diagnostic = None;
722        // A release also un-blocks: the dependency or question `blocked_by`
723        // named may still be unresolved, but a human (or `crate::conduct`)
724        // choosing to release the task overrides that wait outright, the same
725        // as it overrides an ordinary hold.
726        self.blocked_by.clear();
727        self.block_reason = None;
728        self.blocked_from = None;
729        self.review_branch = None;
730        self.fresh_start = false;
731    }
732}
733
734/// A queue on disk.
735#[derive(Debug, Clone)]
736pub struct Queue {
737    root: PathBuf,
738}
739
740impl Queue {
741    /// The operator's queue, `<home>/queue`.
742    pub fn open() -> Self {
743        Self::at(crate::run::home().join("queue"))
744    }
745
746    /// A queue at an explicit root. Tests use this; so could an operator who
747    /// wants a queue per project.
748    pub fn at(root: PathBuf) -> Self {
749        Self { root }
750    }
751
752    /// Directory holding the task files.
753    pub fn root(&self) -> &Path {
754        &self.root
755    }
756
757    /// Path for one task id.
758    pub fn path_of(&self, id: &str) -> PathBuf {
759        self.root.join(format!("{id}.json"))
760    }
761
762    /// Write a task, atomically, so a daemon killed mid-write leaves the
763    /// previous state readable rather than a truncated file.
764    pub fn put(&self, task: &mut Task) -> Result<()> {
765        task.updated_at = Timestamp::now();
766        std::fs::create_dir_all(&self.root)
767            .with_context(|| format!("create {}", self.root.display()))?;
768        let body = serde_json::to_string_pretty(task).context("serialize task")?;
769        let path = self.path_of(&task.id);
770        let tmp = path.with_extension("json.tmp");
771        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
772        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
773        Ok(())
774    }
775
776    /// Load a task by id or unambiguous id prefix.
777    pub fn get(&self, id: &str) -> Result<Task> {
778        let resolved = self.resolve_id(id)?;
779        read_path(&self.path_of(&resolved))
780    }
781
782    /// Remove a task, and the claim lock that belongs to it.
783    ///
784    /// `in_flight` comes from the caller — a live daemon's heartbeat naming
785    /// this task — because the task's own `running` status cannot answer the
786    /// question. A daemon killed mid-competition leaves the status at
787    /// `running` and an orphaned `.lock` behind, and a guard that trusted
788    /// either would make the task undeletable for good: the phone showed
789    /// exactly that, refusing a task whose daemon had been gone for an hour.
790    ///
791    /// So the lock is removed with the task rather than respected. Any lock
792    /// still there once no live daemon claims the task is by definition stale,
793    /// and leaving it would make a deleted task look claimed to
794    /// [`Queue::claim`] and to whoever reads the directory.
795    ///
796    /// Anything still `blocked` on the id just deleted is quarantined to a
797    /// machine hold in the same call - see [`Removal::quarantined`] - rather
798    /// than left to wait on a dependency that no longer exists. Best-effort:
799    /// a dependent claimed by something else right now, or one whose write
800    /// fails, is simply left for `crate::daemon::resolve_blockers`'s own poll
801    /// (or `crate::triage::run_once`) to catch on its own next pass, and does
802    /// not fail this removal.
803    ///
804    /// `questions` is the store [`missing_blockers`] checks a `blocked_by` id
805    /// against before calling it gone - the same store the caller already
806    /// resolves `id`'s own home from, passed in rather than reopened here so
807    /// a test queue at an explicit root is never quarantined against the
808    /// operator's real questions directory.
809    pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
810        let resolved = self.resolve_id(id)?;
811        if in_flight {
812            bail!("task {resolved} is being run by a live daemon right now");
813        }
814        let path = self.path_of(&resolved);
815        std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
816        let lock = self.lock_path(&resolved);
817        if let Err(e) = std::fs::remove_file(&lock) {
818            if e.kind() != std::io::ErrorKind::NotFound {
819                return Err(e).with_context(|| format!("remove {}", lock.display()));
820            }
821        }
822        let quarantined = self.quarantine_dependents_of(&resolved, questions);
823        Ok(Removal {
824            id: resolved,
825            quarantined,
826        })
827    }
828
829    /// Move every `blocked` task naming `dependency` in its own `blocked_by`
830    /// to a machine hold, now that `dependency`'s own file is gone. See
831    /// [`Queue::remove`]'s own doc for why this is best-effort.
832    fn quarantine_dependents_of(&self, dependency: &str, questions: &Questions) -> Vec<String> {
833        let mut quarantined = Vec::new();
834        for listed in self.list() {
835            if listed.status != TaskStatus::Blocked
836                || !listed.blocked_by.iter().any(|b| b == dependency)
837            {
838                continue;
839            }
840            let Ok(_claim) = self.claim(&listed.id) else {
841                continue;
842            };
843            let Ok(mut task) = self.get(&listed.id) else {
844                continue;
845            };
846            if task.status != TaskStatus::Blocked
847                || !task.blocked_by.iter().any(|b| b == dependency)
848            {
849                continue;
850            }
851            let missing = missing_blockers(self, questions, &task.blocked_by);
852            task.hold_machine(Some(missing_blocker_hold_reason(
853                &task.blocked_by,
854                &missing,
855            )));
856            if self.put(&mut task).is_ok() {
857                quarantined.push(task.id.clone());
858            }
859        }
860        quarantined
861    }
862
863    /// Path of the claim lock for a task. One definition, so `claim` and
864    /// `remove` cannot end up naming different files.
865    fn lock_path(&self, id: &str) -> PathBuf {
866        self.root.join(format!("{id}.lock"))
867    }
868
869    /// Every task on disk, highest priority first and newest first within a
870    /// priority. This is what `magi task list` and `GET /api/queue` print, so
871    /// a raised priority has to move a task here the moment it is saved, not
872    /// only in [`Queue::next_runnable`]'s own ordering - the operator reading
873    /// the backlog and the loop about to drain it must agree on what "first"
874    /// means. Every existing task defaults to priority 0, so this is a no-op
875    /// change from the old newest-first order for a queue nobody has
876    /// reprioritised.
877    ///
878    /// Unreadable files are skipped rather than fatal: one corrupt task must
879    /// not take the queue - or the web UI, or an unattended daemon - down
880    /// with it.
881    pub fn list(&self) -> Vec<Task> {
882        let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
883            .into_iter()
884            .flatten()
885            .flatten()
886            .map(|e| e.path())
887            .filter(|p| p.extension().is_some_and(|x| x == "json"))
888            .filter_map(|p| read_path(&p).ok())
889            .collect();
890        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
891        tasks
892    }
893
894    /// The task a daemon should run next, or `None` when the queue is idle.
895    ///
896    /// Highest priority first, oldest first within a priority, so a burst of
897    /// agent-filed work cannot starve the task a human filed this morning.
898    pub fn next_runnable(&self) -> Option<Task> {
899        let mut runnable: Vec<Task> = self
900            .list()
901            .into_iter()
902            .filter(|t| t.status.runnable())
903            .collect();
904        runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
905        runnable.into_iter().next()
906    }
907
908    /// Take exclusive ownership of a task.
909    ///
910    /// The lock is a `create_new` file next to the task, which is atomic on
911    /// every platform magi targets. It exists so two daemons - or a daemon and
912    /// a human running `magi run` - cannot drive one task into two competing
913    /// runs. The returned guard releases on drop, including on panic.
914    pub fn claim(&self, id: &str) -> Result<Claim> {
915        std::fs::create_dir_all(&self.root)
916            .with_context(|| format!("create {}", self.root.display()))?;
917        let path = self.lock_path(id);
918        match std::fs::OpenOptions::new()
919            .write(true)
920            .create_new(true)
921            .open(&path)
922        {
923            Ok(mut f) => {
924                use std::io::Write as _;
925                // Best effort: the pid is for the human looking at a stale lock.
926                let _ = writeln!(f, "{}", std::process::id());
927                Ok(Claim { path })
928            }
929            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
930                bail!("task {id} is already claimed ({} exists)", path.display())
931            }
932            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
933        }
934    }
935
936    /// Expand an id prefix to exactly one task id.
937    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
938        if self.path_of(prefix).is_file() {
939            return Ok(prefix.to_owned());
940        }
941        let hits: Vec<String> = self
942            .list()
943            .into_iter()
944            .map(|t| t.id)
945            .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
946            .collect();
947        match hits.len() {
948            1 => Ok(hits.into_iter().next().expect("exactly one hit")),
949            0 => bail!("no task matches `{prefix}`"),
950            _ => bail!(
951                "`{prefix}` matches {} tasks: {}",
952                hits.len(),
953                hits.join(", ")
954            ),
955        }
956    }
957
958    /// Change detection token for the queue.
959    ///
960    /// Combines file names and modification times of all task files in the
961    /// queue, so adding, modifying, or deleting any task — even an older one —
962    /// moves the revision and notifies connected clients via the change stream.
963    /// Returns 0 when the queue is completely empty.
964    pub fn revision(&self) -> u64 {
965        use std::hash::{Hash as _, Hasher as _};
966
967        let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
968            .into_iter()
969            .flatten()
970            .flatten()
971            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
972            .filter_map(|e| {
973                let name = e.file_name().to_string_lossy().into_owned();
974                let mtime = e
975                    .metadata()
976                    .ok()?
977                    .modified()
978                    .ok()?
979                    .duration_since(std::time::UNIX_EPOCH)
980                    .ok()?
981                    .as_millis() as u64;
982                Some((name, mtime))
983            })
984            .collect();
985
986        if entries.is_empty() {
987            return 0;
988        }
989
990        entries.sort_unstable();
991        let mut hasher = std::hash::DefaultHasher::new();
992        for (name, mtime) in &entries {
993            name.hash(&mut hasher);
994            mtime.hash(&mut hasher);
995        }
996        let h = hasher.finish();
997        if h == 0 { 1 } else { h }
998    }
999}
1000
1001/// What [`Queue::remove`] did, beyond deleting the named task's own file.
1002#[derive(Debug, Clone)]
1003pub struct Removal {
1004    /// The id actually removed - `id` expanded from a prefix, if it was one.
1005    pub id: String,
1006    /// Every `blocked` task that named [`Removal::id`] in its own
1007    /// `blocked_by` and was moved to a machine hold as a result, rather than
1008    /// left waiting on a dependency this call just erased.
1009    pub quarantined: Vec<String>,
1010}
1011
1012/// Exclusive ownership of a task, released on drop.
1013#[derive(Debug)]
1014pub struct Claim {
1015    path: PathBuf,
1016}
1017
1018impl Drop for Claim {
1019    fn drop(&mut self) {
1020        let _ = std::fs::remove_file(&self.path);
1021    }
1022}
1023
1024/// The first line of a task, trimmed to a title. Used when the caller gives a
1025/// body but no title, which is the normal case for an agent piping a file in.
1026pub fn title_from(instruction: &str, max: usize) -> String {
1027    // The first non-blank line, whatever it is. A markdown heading is the
1028    // task's own summary - agents pipe in `# Rework the config loader` and mean
1029    // exactly that - so it is preferred over the prose beneath it rather than
1030    // skipped as decoration. Leading list and heading markers are stripped
1031    // because they are syntax, not words.
1032    let line = instruction
1033        .lines()
1034        .map(str::trim)
1035        .find(|l| !l.is_empty())
1036        .unwrap_or("(empty task)")
1037        .trim_start_matches(['#', '-', '*', '>', ' '])
1038        .trim();
1039    if line.is_empty() {
1040        return "(empty task)".to_owned();
1041    }
1042    if line.chars().count() <= max {
1043        return line.to_owned();
1044    }
1045    let head: String = line.chars().take(max.saturating_sub(1)).collect();
1046    format!("{head}…")
1047}
1048
1049fn read_path(path: &Path) -> Result<Task> {
1050    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1051    let task: Task =
1052        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
1053    // Greater-than, not not-equal: every field added since schema 1 carries
1054    // `#[serde(default)]`, so an older task has nothing to say about it and
1055    // defaulting is exactly as good a reading as a value that build never had
1056    // a chance to write. Only a schema *ahead* of this build - a meaning it
1057    // cannot possibly know - is refused rather than guessed at.
1058    if task.schema > SCHEMA {
1059        bail!(
1060            "task {} was written by a different magi (schema {}, this build \
1061             speaks {SCHEMA})",
1062            task.id,
1063            task.schema
1064        );
1065    }
1066    Ok(task)
1067}
1068
1069/// Ids inside a `blocked_by` list that name neither an existing task file nor
1070/// an existing question file - a dependency deleted (`magi task rm`, or by
1071/// hand) while something was still waiting on it.
1072///
1073/// Existence is decided by [`Queue::path_of`]/[`Questions::path_of`]
1074/// `is_file()` alone, never by [`Queue::get`]/[`Questions::get`] succeeding:
1075/// those also fail on a merely unreadable file - mid-write, corrupt, or from
1076/// a schema ahead of this build (see [`read_path`]) - and misreading "cannot
1077/// read it right now" as "it was deleted" would quarantine a task over a
1078/// transient failure. `blocked_by` always carries a full id, written by
1079/// `crate::conduct` or `crate::triage` from a real task's or question's own
1080/// `id`/`short`, never a prefix a caller typed - so the exact-path check is
1081/// complete on its own, with no [`Queue::resolve_id`] fallback needed.
1082pub fn missing_blockers(
1083    queue: &Queue,
1084    questions: &Questions,
1085    blocked_by: &[String],
1086) -> Vec<String> {
1087    blocked_by
1088        .iter()
1089        .filter(|id| !queue.path_of(id).is_file() && !questions.path_of(id).is_file())
1090        .cloned()
1091        .collect()
1092}
1093
1094/// The `hold_reason` text for a task quarantined because one or more of its
1095/// `blocked_by` ids no longer exist. Shared by `crate::daemon::resolve_blockers`,
1096/// `crate::triage::run_once`, and [`Queue::remove`]'s own dependent
1097/// quarantine, so the three call sites read as the same event to an operator
1098/// looking at `magi task show` rather than three different wordings for it.
1099///
1100/// Names the full original `blocked_by` list, not just `missing` - a task
1101/// quarantined here can also have named a dependency that was still
1102/// perfectly valid, and [`Task::hold_machine`] clears `blocked_by` on the way
1103/// in, so this text is the only place that information survives for an
1104/// operator deciding whether to release the task outright.
1105pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
1106    format!(
1107        "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
1108        blocked_by.join(", "),
1109        missing.join(", "),
1110    )
1111}
1112
1113fn short(id: &str) -> &str {
1114    id.split('-').next_back().unwrap_or(id)
1115}
1116
1117fn new_id() -> String {
1118    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
1119    let seed = crate::rng::entropy();
1120    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125    use super::*;
1126
1127    #[test]
1128    fn triage_applied_survives_release_and_old_records_read_as_empty() {
1129        let mut t = Task::new(
1130            "t".to_owned(),
1131            "i".to_owned(),
1132            PathBuf::from("r"),
1133            Source::Human,
1134        );
1135        t.mark_triage_applied("q1");
1136        t.mark_triage_applied("q1");
1137        t.hold_machine(Some("x".to_owned()));
1138        t.release();
1139        assert_eq!(t.triage_applied, ["q1"]);
1140        assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
1141
1142        let mut v = serde_json::to_value(&t).unwrap();
1143        v.as_object_mut().unwrap().remove("triage_applied");
1144        let old: Task = serde_json::from_value(v).unwrap();
1145        assert!(old.triage_applied.is_empty());
1146    }
1147
1148    /// A queue of its own, with no process-global state - which is the point of
1149    /// `Queue::at`, and why these can run in parallel.
1150    fn queue() -> (tempfile::TempDir, Queue) {
1151        let dir = tempfile::tempdir().unwrap();
1152        let q = Queue::at(dir.path().join("queue"));
1153        (dir, q)
1154    }
1155
1156    fn task(title: &str) -> Task {
1157        Task::new(
1158            title.to_owned(),
1159            format!("do {title}"),
1160            PathBuf::from("."),
1161            Source::Human,
1162        )
1163    }
1164
1165    #[test]
1166    fn a_markdown_heading_is_the_title_not_decoration() {
1167        // A task file's heading is the summary its author already wrote, so it
1168        // beats the prose underneath. Getting this backwards was visible in the
1169        // first smoke test: a task titled "# Rework the config loader" listed
1170        // as "It re-reads the file on every lookup".
1171        assert_eq!(
1172            title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
1173            "Rework the config loader"
1174        );
1175        assert_eq!(title_from("- fix the thing", 40), "fix the thing");
1176        assert_eq!(title_from("> quoted task", 40), "quoted task");
1177        // Nothing usable at all still has to produce something printable.
1178        assert_eq!(title_from("   \n\n", 40), "(empty task)");
1179        assert_eq!(title_from("###\n", 40), "(empty task)");
1180    }
1181
1182    #[test]
1183    fn a_long_title_is_elided_by_characters_not_bytes() {
1184        // Byte truncation would split a multi-byte character and panic.
1185        let long = "課題".repeat(30);
1186        let title = title_from(&long, 10);
1187        assert_eq!(title.chars().count(), 10);
1188        assert!(title.ends_with('…'));
1189    }
1190
1191    #[test]
1192    fn priority_wins_and_ties_break_oldest_first() {
1193        let (_dir, q) = queue();
1194        let mut a = task("first");
1195        let mut b = task("second");
1196        let mut c = task("urgent");
1197        // Ids carry a timestamp, so force a known order.
1198        a.id = "20260101-000001-aaaa".to_owned();
1199        b.id = "20260101-000002-bbbb".to_owned();
1200        c.id = "20260101-000003-cccc".to_owned();
1201        c.priority = 5;
1202        for t in [&mut a, &mut b, &mut c] {
1203            q.put(t).unwrap();
1204        }
1205
1206        // Priority first...
1207        assert_eq!(q.next_runnable().unwrap().id, c.id);
1208        c.hold_machine(None);
1209        q.put(&mut c).unwrap();
1210        // ...then oldest, so a burst of new work cannot starve older work.
1211        assert_eq!(q.next_runnable().unwrap().id, a.id);
1212        assert_eq!(q.list().len(), 3, "b is still waiting its turn");
1213    }
1214
1215    #[test]
1216    fn a_blocked_task_never_starves_another_runnable_one() {
1217        let (_dir, q) = queue();
1218        let mut blocked = task("blocked");
1219        blocked.block(vec!["something".to_owned()], None);
1220        q.put(&mut blocked).unwrap();
1221
1222        let mut runnable = task("free to go");
1223        q.put(&mut runnable).unwrap();
1224
1225        let next = q.next_runnable().expect("a runnable task is still offered");
1226        assert_eq!(next.id, runnable.id);
1227    }
1228
1229    #[test]
1230    fn a_held_task_is_never_offered_to_the_loop() {
1231        let (_dir, q) = queue();
1232        let mut t = task("held");
1233        q.put(&mut t).unwrap();
1234        assert!(q.next_runnable().is_some());
1235
1236        t.hold_machine(None);
1237        q.put(&mut t).unwrap();
1238        assert!(
1239            q.next_runnable().is_none(),
1240            "a held task must wait for a human"
1241        );
1242
1243        // A failed task, by contrast, is exactly what the loop should retry.
1244        t.status = TaskStatus::Failed;
1245        q.put(&mut t).unwrap();
1246        assert!(q.next_runnable().is_some());
1247    }
1248
1249    #[test]
1250    fn attempts_are_capped_and_then_the_task_is_held() {
1251        let mut t = task("doomed");
1252
1253        t.start("run-1".to_owned());
1254        t.fail("gate red", 2);
1255        assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
1256
1257        t.start("run-2".to_owned());
1258        t.fail("gate red", 2);
1259        assert_eq!(
1260            t.status,
1261            TaskStatus::Held,
1262            "out of attempts: stop spending money on it"
1263        );
1264        assert_eq!(t.runs, ["run-1", "run-2"]);
1265        assert_eq!(t.last_error.as_deref(), Some("gate red"));
1266    }
1267
1268    #[test]
1269    fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
1270        let mut t = task("stalled by quota");
1271
1272        t.start("run-1".to_owned());
1273        assert_eq!(t.attempts, 1);
1274        t.stall("judge-1, judge-2 out of quota");
1275        assert_eq!(
1276            t.attempts, 0,
1277            "a closed quota window must not spend the task's retry budget"
1278        );
1279        assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
1280        assert_eq!(
1281            t.last_error.as_deref(),
1282            Some("judge-1, judge-2 out of quota")
1283        );
1284
1285        // A task can therefore stall all night and still get its real attempts
1286        // once the quota resets - which is the whole point.
1287        for _ in 0..20 {
1288            t.start("run-n".to_owned());
1289            t.stall("still out of quota");
1290        }
1291        t.start("run-real".to_owned());
1292        t.fail("gate red", 2);
1293        assert_eq!(
1294            t.status,
1295            TaskStatus::Failed,
1296            "the first attempt that was really judged is attempt one"
1297        );
1298    }
1299
1300    #[test]
1301    fn releasing_a_held_task_gives_it_a_real_second_chance() {
1302        let mut t = task("retry me");
1303        t.start("run-1".to_owned());
1304        t.fail("gate red", 1);
1305        assert_eq!(t.status, TaskStatus::Held);
1306
1307        t.release();
1308        assert_eq!(t.status, TaskStatus::Queued);
1309        // Without resetting attempts the next failure would re-hold at once,
1310        // and a release would be a no-op the operator cannot see.
1311        assert_eq!(t.attempts, 0);
1312        assert!(t.last_error.is_none());
1313        assert_eq!(
1314            t.runs.len(),
1315            1,
1316            "history is kept: attempts reset, evidence does not"
1317        );
1318    }
1319
1320    #[test]
1321    fn a_hold_reason_survives_and_a_release_clears_it() {
1322        let mut t = task("waiting on something else");
1323        t.hold_manual(Some(
1324            "waiting for 20260101-000000-aaaa to land first".to_owned(),
1325        ));
1326        assert_eq!(t.status, TaskStatus::Held);
1327        assert_eq!(
1328            t.hold_reason.as_deref(),
1329            Some("waiting for 20260101-000000-aaaa to land first")
1330        );
1331
1332        // Holding again with no reason must not erase the one already there.
1333        t.hold_manual(None);
1334        assert_eq!(
1335            t.hold_reason.as_deref(),
1336            Some("waiting for 20260101-000000-aaaa to land first"),
1337            "a bare re-hold keeps whatever a human already wrote down"
1338        );
1339
1340        // A hold with no reason at all is still an ordinary, allowed hold.
1341        let mut plain = task("no reason given");
1342        plain.hold_manual(None);
1343        assert_eq!(plain.status, TaskStatus::Held);
1344        assert!(plain.hold_reason.is_none());
1345
1346        t.release();
1347        assert_eq!(t.status, TaskStatus::Queued);
1348        assert!(
1349            t.hold_reason.is_none(),
1350            "a stale reason must not greet the next person who holds this task"
1351        );
1352    }
1353
1354    #[test]
1355    fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
1356        // `done` can close a held task directly - neither `magi task done`
1357        // nor `POST /api/queue/{id}/done` requires a release first - so a
1358        // task held for "waiting on 3ed9" and then closed without ever being
1359        // released must not still read as waiting on it afterwards.
1360        let mut t = task("landed by hand while held");
1361        t.hold_manual(Some("waiting on 3ed9".to_owned()));
1362        assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
1363
1364        t.succeed();
1365        assert_eq!(t.status, TaskStatus::Done);
1366        assert!(
1367            t.hold_reason.is_none(),
1368            "a done task cannot still be waiting on something"
1369        );
1370    }
1371
1372    #[test]
1373    fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
1374        // The web UI's "Hold" and "Mark done" buttons are both reachable on a
1375        // `blocked` task, not just on `queued`/`held` ones - neither requires
1376        // a release first. A task moved off `Blocked` that way must not still
1377        // carry the dependency it was waiting on: a dependency graph built
1378        // from `blocked_by` would otherwise keep drawing an edge for a task
1379        // that is not blocked on anything any more.
1380        let mut held = task("held straight out of blocked");
1381        held.block(
1382            vec!["20260101-000000-dead".to_owned()],
1383            Some("waiting on the migration script".to_owned()),
1384        );
1385        assert_eq!(held.status, TaskStatus::Blocked);
1386
1387        held.hold_manual(None);
1388        assert_eq!(held.status, TaskStatus::Held);
1389        assert!(
1390            held.blocked_by.is_empty(),
1391            "hold overrides the wait, same as release"
1392        );
1393        assert!(held.block_reason.is_none());
1394
1395        let mut done = task("closed straight out of blocked");
1396        done.block(
1397            vec!["20260101-000000-dead".to_owned()],
1398            Some("waiting on the migration script".to_owned()),
1399        );
1400        done.succeed();
1401        assert_eq!(done.status, TaskStatus::Done);
1402        assert!(
1403            done.blocked_by.is_empty(),
1404            "a done task cannot still be waiting on a dependency"
1405        );
1406        assert!(done.block_reason.is_none());
1407    }
1408
1409    #[test]
1410    fn a_blocked_task_is_never_offered_to_the_loop() {
1411        let mut t = task("blocked");
1412        assert!(t.status.runnable());
1413        t.block(
1414            vec!["dep-id".to_owned()],
1415            Some("waits on dep-id".to_owned()),
1416        );
1417        assert_eq!(t.status, TaskStatus::Blocked);
1418        assert!(!t.status.runnable());
1419        assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
1420    }
1421
1422    #[test]
1423    fn unblocking_the_last_dependency_returns_the_task_to_queued() {
1424        let mut t = task("blocked on two");
1425        t.block(
1426            vec!["a".to_owned(), "b".to_owned()],
1427            Some("waits on a and b".to_owned()),
1428        );
1429
1430        t.unblock("a");
1431        assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
1432        assert_eq!(t.blocked_by, ["b"]);
1433
1434        t.unblock("b");
1435        assert_eq!(t.status, TaskStatus::Queued);
1436        assert!(t.blocked_by.is_empty());
1437        assert!(t.block_reason.is_none());
1438    }
1439
1440    #[test]
1441    fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
1442        let mut t = task("never blocked");
1443        t.unblock("whatever");
1444        assert_eq!(t.status, TaskStatus::Queued);
1445    }
1446
1447    #[test]
1448    fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
1449        // The bug this guards: a task an operator (or `crate::triage`) has
1450        // deliberately held, once `crate::conduct` blocks it on a follow-up
1451        // question, must not silently re-enter the competition queue the
1452        // moment that question is answered - whatever the answer said.
1453        let mut t = task("held, then asked about");
1454        t.hold_machine(Some("out of attempts".to_owned()));
1455        assert_eq!(t.status, TaskStatus::Held);
1456
1457        t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
1458        assert_eq!(t.status, TaskStatus::Blocked);
1459
1460        t.record_answer("what now?".to_owned(), "leave it held".to_owned());
1461        t.unblock("q1");
1462        assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
1463        assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
1464        assert_eq!(t.hold_source, Some(HoldSource::Machine));
1465        assert!(t.blocked_from.is_none(), "consumed once restored");
1466    }
1467
1468    #[test]
1469    fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
1470        let mut t = task("manually held, then asked about");
1471        t.hold_manual(Some("waiting on a dependency".to_owned()));
1472
1473        t.block(vec!["q1".to_owned()], None);
1474        t.unblock("q1");
1475
1476        assert_eq!(t.status, TaskStatus::Held);
1477        assert_eq!(t.hold_source, Some(HoldSource::Manual));
1478    }
1479
1480    #[test]
1481    fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
1482        // A second `Task::block` call - `crate::conduct` adding a question on
1483        // top of an existing block - must not overwrite `blocked_from` with
1484        // `Blocked` itself, or the task would restore into itself.
1485        let mut t = task("held, blocked twice");
1486        t.hold_machine(None);
1487        t.block(vec!["q1".to_owned()], Some("first".to_owned()));
1488        t.block(
1489            vec!["q1".to_owned(), "q2".to_owned()],
1490            Some("second".to_owned()),
1491        );
1492
1493        t.unblock("q1");
1494        assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
1495        t.unblock("q2");
1496        assert_eq!(t.status, TaskStatus::Held);
1497    }
1498
1499    #[test]
1500    fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
1501        // Whatever process was driving the run is gone by the time a
1502        // conductor's question about it gets answered - there is nothing left
1503        // to resume into.
1504        let mut t = task("blocked mid-run");
1505        t.start("run-1".to_owned());
1506        assert_eq!(t.status, TaskStatus::Running);
1507
1508        t.block(vec!["q1".to_owned()], None);
1509        t.unblock("q1");
1510        assert_eq!(t.status, TaskStatus::Queued);
1511    }
1512
1513    #[test]
1514    fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
1515        // `blocked_from` is `None` for a record written before schema 4 (or,
1516        // equivalently, deserialized straight from an on-disk file that never
1517        // had the field). Held evidence surviving on the task - never cleared
1518        // by `block` - is the only way left to tell such a record apart from
1519        // one blocked straight out of `Queued`.
1520        let mut t = task("legacy record, held before it was blocked");
1521        t.hold_source = Some(HoldSource::Machine);
1522        t.hold_reason = Some("legacy hold reason".to_owned());
1523        t.status = TaskStatus::Blocked;
1524        t.blocked_by = vec!["q1".to_owned()];
1525        t.blocked_from = None;
1526
1527        t.unblock("q1");
1528        assert_eq!(t.status, TaskStatus::Held);
1529    }
1530
1531    #[test]
1532    fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
1533        let mut t = task("legacy record, ordinary dependency block");
1534        t.status = TaskStatus::Blocked;
1535        t.blocked_by = vec!["dep".to_owned()];
1536        t.blocked_from = None;
1537
1538        t.unblock("dep");
1539        assert_eq!(t.status, TaskStatus::Queued);
1540    }
1541
1542    #[test]
1543    fn answering_a_question_is_recorded_and_survives_a_release() {
1544        let mut t = task("asked something");
1545        t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
1546        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
1547        t.unblock("q1");
1548        assert_eq!(t.status, TaskStatus::Queued);
1549        assert_eq!(t.answers.len(), 1);
1550        assert_eq!(t.answers[0].answer, "SQLite");
1551
1552        // A release resets attempts, not evidence - the same rule
1553        // `releasing_a_held_task_gives_it_a_real_second_chance` asserts for
1554        // `runs`.
1555        t.release();
1556        assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
1557    }
1558
1559    #[test]
1560    fn requesting_review_requeues_the_task_and_remembers_the_branch() {
1561        let mut t = task("blocked run with a surviving branch");
1562        t.start("run-1".to_owned());
1563        t.fail("blocked with major findings", 5);
1564        assert_eq!(t.status, TaskStatus::Failed);
1565
1566        t.request_review("magi/eba2/A".to_owned());
1567        assert_eq!(t.status, TaskStatus::Queued);
1568        assert_eq!(t.attempts, 0);
1569        assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
1570
1571        // An ordinary release (a human overriding the choice) drops it again.
1572        t.release();
1573        assert!(t.review_branch.is_none());
1574    }
1575
1576    #[test]
1577    fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
1578        let mut t = task("retry");
1579        t.start("run-1".to_owned());
1580        t.requeue();
1581        assert!(t.fresh_start);
1582
1583        t.release();
1584        assert!(!t.fresh_start);
1585    }
1586
1587    #[test]
1588    fn priority_can_be_changed_while_queued_but_not_while_running() {
1589        let mut t = task("reprioritise me");
1590        t.set_priority(5).unwrap();
1591        assert_eq!(t.priority, 5);
1592
1593        t.start("run-1".to_owned());
1594        let err = t.set_priority(9).unwrap_err().to_string();
1595        assert!(err.contains("running"), "{err}");
1596        assert_eq!(t.priority, 5, "the rejected write must not partially apply");
1597    }
1598
1599    #[test]
1600    fn interrupt_can_be_marked_while_queued_but_not_while_running() {
1601        let mut t = task("interrupt me");
1602        assert!(!t.interrupt, "off unless asked, same as any other task");
1603
1604        t.set_interrupt(true).unwrap();
1605        assert!(t.interrupt);
1606
1607        t.start("run-1".to_owned());
1608        assert!(
1609            !t.interrupt,
1610            "the mark is one-shot: dispatching the task fulfils it, \
1611             whatever the run that follows ends up doing"
1612        );
1613        let err = t.set_interrupt(true).unwrap_err().to_string();
1614        assert!(err.contains("running"), "{err}");
1615        // Clearing is always allowed, even on a running task - there is
1616        // nothing left for it to interrupt once it has been claimed.
1617        t.set_interrupt(false).unwrap();
1618        assert!(!t.interrupt);
1619    }
1620
1621    /// R2-1-1: a task whose run fails and requeues must not go on
1622    /// re-triggering `crate::daemon`'s interrupt scheduler on every later
1623    /// boundary, attempt after attempt, until it exhausts its budget.
1624    #[test]
1625    fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
1626        let mut t = task("interrupt me");
1627        t.set_interrupt(true).unwrap();
1628        t.start("run-1".to_owned());
1629        t.fail("mock failure", 5);
1630        assert_eq!(t.status, TaskStatus::Failed);
1631        assert!(
1632            !t.interrupt,
1633            "one attempt already spent the mark; a retry is an ordinary \
1634             requeue, not a fresh interrupt request"
1635        );
1636    }
1637
1638    #[test]
1639    fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
1640        let (_dir, q) = queue();
1641        let mut a = task("first filed");
1642        let mut b = task("second filed");
1643        a.id = "20260101-000001-aaaa".to_owned();
1644        b.id = "20260101-000002-bbbb".to_owned();
1645        q.put(&mut a).unwrap();
1646        q.put(&mut b).unwrap();
1647
1648        assert_eq!(
1649            q.next_runnable().unwrap().id,
1650            a.id,
1651            "with equal priority the older task goes first, so a burst of \
1652             new work cannot starve it"
1653        );
1654        assert_eq!(
1655            q.list()[0].id,
1656            b.id,
1657            "but the list an operator reads is newest first, the same as \
1658             before priority existed - a's turn to run does not make it the \
1659             newest task"
1660        );
1661
1662        let mut a = q.get(&a.id).unwrap();
1663        a.set_priority(10).unwrap();
1664        q.put(&mut a).unwrap();
1665
1666        assert_eq!(
1667            q.next_runnable().unwrap().id,
1668            a.id,
1669            "a raised priority must be reflected the moment it is saved"
1670        );
1671        // `magi task list` and `GET /api/queue` both print `Queue::list()`
1672        // directly, so the raised task has to lead there too - not only in
1673        // what the loop would claim next.
1674        assert_eq!(
1675            q.list()[0].id,
1676            a.id,
1677            "the raised task must sort first in the list an operator reads, \
1678             not only in next_runnable's own ordering"
1679        );
1680    }
1681
1682    #[test]
1683    fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
1684        let mut t = Task::new(
1685            "old title".to_owned(),
1686            "old instruction".to_owned(),
1687            PathBuf::from("/repo"),
1688            Source::Agent {
1689                run: "20260101-000000-beef".to_owned(),
1690                node: "implement".to_owned(),
1691            },
1692        );
1693        let id = t.id.clone();
1694        let created_at = t.created_at;
1695        t.runs.push("20260101-000000-beef".to_owned());
1696
1697        t.edit("new title".to_owned(), "new instruction".to_owned())
1698            .unwrap();
1699
1700        assert_eq!(t.title, "new title");
1701        assert_eq!(t.instruction, "new instruction");
1702        assert_eq!(t.id, id, "editing must not mint a new id");
1703        assert_eq!(t.created_at, created_at);
1704        assert_eq!(
1705            t.source,
1706            Source::Agent {
1707                run: "20260101-000000-beef".to_owned(),
1708                node: "implement".to_owned(),
1709            },
1710            "editing must not turn agent attribution into human"
1711        );
1712        assert_eq!(t.runs, ["20260101-000000-beef"]);
1713    }
1714
1715    #[test]
1716    fn editing_is_refused_once_a_task_is_running_or_finished() {
1717        let mut running = task("in flight");
1718        running.start("run-1".to_owned());
1719        let err = running
1720            .edit("x".to_owned(), "y".to_owned())
1721            .unwrap_err()
1722            .to_string();
1723        assert!(err.contains("running"), "{err}");
1724
1725        let mut done = task("finished");
1726        done.succeed();
1727        let err = done
1728            .edit("x".to_owned(), "y".to_owned())
1729            .unwrap_err()
1730            .to_string();
1731        assert!(err.contains("done"), "{err}");
1732
1733        // Both queued and held are the point of the feature and must work.
1734        let mut queued = task("waiting");
1735        queued.edit("x".to_owned(), "y".to_owned()).unwrap();
1736        let mut held = task("parked");
1737        held.hold_machine(None);
1738        held.edit("x".to_owned(), "y".to_owned()).unwrap();
1739    }
1740
1741    #[test]
1742    fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
1743        let (_dir, q) = queue();
1744        let path = q.path_of("20260101-000000-aaaa");
1745        std::fs::create_dir_all(q.root()).unwrap();
1746        std::fs::write(
1747            &path,
1748            serde_json::json!({
1749                "schema": SCHEMA,
1750                "id": "20260101-000000-aaaa",
1751                "title": "from before hold reasons existed",
1752                "instruction": "from before hold reasons existed",
1753                "repo": ".",
1754                "source": { "kind": "human" },
1755                "status": "held",
1756                "created_at": Timestamp::now().to_string(),
1757                "updated_at": Timestamp::now().to_string(),
1758            })
1759            .to_string(),
1760        )
1761        .unwrap();
1762
1763        let task = q.get("20260101-000000-aaaa").expect("must still read");
1764        assert!(task.hold_reason.is_none());
1765        assert!(task.operator_held());
1766    }
1767
1768    #[test]
1769    fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
1770        let (_dir, q) = queue();
1771        let path = q.path_of("20260101-000000-bbbb");
1772        std::fs::create_dir_all(q.root()).unwrap();
1773        std::fs::write(
1774            &path,
1775            serde_json::json!({
1776                "schema": 2,
1777                "id": "20260101-000000-bbbb",
1778                "title": "old manual recovery",
1779                "instruction": "old manual recovery",
1780                "repo": ".",
1781                "source": { "kind": "human" },
1782                "status": "held",
1783                "hold_reason": "active manual recovery run20260912-224242-daf5",
1784                "created_at": Timestamp::now().to_string(),
1785                "updated_at": Timestamp::now().to_string(),
1786            })
1787            .to_string(),
1788        )
1789        .unwrap();
1790
1791        let task = q.get("20260101-000000-bbbb").expect("must still read");
1792        assert_eq!(task.hold_source, None);
1793        assert!(task.operator_held());
1794    }
1795
1796    #[test]
1797    fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
1798        let (_dir, q) = queue();
1799        let path = q.path_of("20260101-000000-aaaa");
1800        std::fs::create_dir_all(q.root()).unwrap();
1801        std::fs::write(
1802            &path,
1803            serde_json::json!({
1804                "schema": SCHEMA,
1805                "id": "20260101-000000-aaaa",
1806                "title": "from before diagnostics existed",
1807                "instruction": "from before diagnostics existed",
1808                "repo": ".",
1809                "source": { "kind": "human" },
1810                "status": "held",
1811                "created_at": Timestamp::now().to_string(),
1812                "updated_at": Timestamp::now().to_string(),
1813            })
1814            .to_string(),
1815        )
1816        .unwrap();
1817
1818        let task = q.get("20260101-000000-aaaa").expect("must still read");
1819        assert!(task.diagnostic.is_none());
1820    }
1821
1822    #[test]
1823    fn a_schema_1_task_with_no_blocking_fields_still_reads() {
1824        // Written by a build that predates `blocked_by`, `block_reason`,
1825        // `answers` and `review_branch` entirely - literal `"schema": 1`,
1826        // not `SCHEMA`, since the whole point is a build older than this one.
1827        let (_dir, q) = queue();
1828        let path = q.path_of("20260101-000000-aaaa");
1829        std::fs::create_dir_all(q.root()).unwrap();
1830        std::fs::write(
1831            &path,
1832            serde_json::json!({
1833                "schema": 1,
1834                "id": "20260101-000000-aaaa",
1835                "title": "from before blocking existed",
1836                "instruction": "from before blocking existed",
1837                "repo": ".",
1838                "source": { "kind": "human" },
1839                "status": "queued",
1840                "created_at": Timestamp::now().to_string(),
1841                "updated_at": Timestamp::now().to_string(),
1842            })
1843            .to_string(),
1844        )
1845        .unwrap();
1846
1847        let task = q.get("20260101-000000-aaaa").expect("must still read");
1848        assert!(task.blocked_by.is_empty());
1849        assert!(task.block_reason.is_none());
1850        assert!(task.answers.is_empty());
1851        assert!(task.review_branch.is_none());
1852    }
1853
1854    #[test]
1855    fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
1856        // A diagnostic belongs to the run that produced it. Left in place
1857        // across a release, an unrelated later failure - a config error, say -
1858        // would go on showing evidence for a problem that is no longer why the
1859        // task is stuck.
1860        let mut held = task("diagnosed");
1861        held.start("run-1".to_owned());
1862        held.fail("gate red", 1);
1863        held.diagnostic = Some("cargo test failed: ...".to_owned());
1864        assert_eq!(held.status, TaskStatus::Held);
1865
1866        held.release();
1867        assert!(held.diagnostic.is_none());
1868
1869        held.diagnostic = Some("cargo test failed: ...".to_owned());
1870        held.succeed();
1871        assert!(held.diagnostic.is_none());
1872    }
1873
1874    #[test]
1875    fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
1876        let mut t = task("retried");
1877        t.start("run-1".to_owned());
1878        t.diagnostic = Some("stale evidence from a previous hold".to_owned());
1879        t.fail("unrelated config error", 5);
1880        assert_eq!(t.status, TaskStatus::Failed);
1881        assert!(
1882            t.diagnostic.is_none(),
1883            "fail() must not let an old diagnostic outlive the run that produced it"
1884        );
1885    }
1886
1887    #[test]
1888    fn a_claim_is_exclusive_and_releases_on_drop() {
1889        let (_dir, q) = queue();
1890        let mut t = task("contended");
1891        q.put(&mut t).unwrap();
1892
1893        let held = q.claim(&t.id).unwrap();
1894        assert!(
1895            q.claim(&t.id).is_err(),
1896            "two daemons must not drive one task into two runs"
1897        );
1898        drop(held);
1899        assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
1900    }
1901
1902    #[test]
1903    fn a_round_trip_survives_disk() {
1904        let (_dir, q) = queue();
1905        let mut t = Task::new(
1906            "titled".to_owned(),
1907            "body".to_owned(),
1908            PathBuf::from("/repo"),
1909            Source::Agent {
1910                run: "20260101-000000-beef".to_owned(),
1911                node: "implement".to_owned(),
1912            },
1913        );
1914        t.priority = 3;
1915        q.put(&mut t).unwrap();
1916
1917        let back = q.get(&t.id).unwrap();
1918        assert_eq!(back.id, t.id);
1919        assert_eq!(back.priority, 3);
1920        assert_eq!(back.source.label(), "implement@beef");
1921        // A prefix is enough, the way run ids work everywhere else.
1922        assert_eq!(q.get(t.short()).unwrap().id, t.id);
1923    }
1924
1925    #[test]
1926    fn an_unreadable_task_does_not_take_the_queue_down() {
1927        let (_dir, q) = queue();
1928        let mut t = task("fine");
1929        q.put(&mut t).unwrap();
1930        std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
1931
1932        let listed = q.list();
1933        assert_eq!(listed.len(), 1, "the readable task still lists");
1934        assert_eq!(listed[0].id, t.id);
1935    }
1936
1937    #[test]
1938    fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
1939        let (_dir, q) = queue();
1940        let path = q.path_of("20260101-000000-aaaa");
1941        std::fs::create_dir_all(q.root()).unwrap();
1942        std::fs::write(
1943            &path,
1944            serde_json::json!({
1945                "schema": SCHEMA,
1946                "id": "20260101-000000-aaaa",
1947                "title": "from before solo existed",
1948                "instruction": "from before solo existed",
1949                "repo": ".",
1950                "source": { "kind": "human" },
1951                "status": "queued",
1952                "created_at": Timestamp::now().to_string(),
1953                "updated_at": Timestamp::now().to_string(),
1954            })
1955            .to_string(),
1956        )
1957        .unwrap();
1958
1959        let task = q.get("20260101-000000-aaaa").expect("must still read");
1960        assert!(!task.solo, "a queue file with no `solo` field means false");
1961    }
1962
1963    #[test]
1964    fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
1965        let (_dir, q) = queue();
1966        let path = q.path_of("20260101-000000-bbbb");
1967        std::fs::create_dir_all(q.root()).unwrap();
1968        std::fs::write(
1969            &path,
1970            serde_json::json!({
1971                "schema": SCHEMA,
1972                "id": "20260101-000000-bbbb",
1973                "title": "from before urgent existed",
1974                "instruction": "from before urgent existed",
1975                "repo": ".",
1976                "source": { "kind": "human" },
1977                "status": "queued",
1978                "created_at": Timestamp::now().to_string(),
1979                "updated_at": Timestamp::now().to_string(),
1980            })
1981            .to_string(),
1982        )
1983        .unwrap();
1984
1985        let task = q.get("20260101-000000-bbbb").expect("must still read");
1986        assert!(
1987            !task.urgent,
1988            "a queue file with no `urgent` field means false, same as `solo`"
1989        );
1990    }
1991
1992    #[test]
1993    fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
1994        let (_dir, q) = queue();
1995        let mut t = task("from the future");
1996        q.put(&mut t).unwrap();
1997        let path = q.path_of(&t.id);
1998        let body = std::fs::read_to_string(&path)
1999            .unwrap()
2000            .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
2001        std::fs::write(&path, body).unwrap();
2002
2003        let err = q.get(&t.id).unwrap_err().to_string();
2004        assert!(err.contains("schema 99"), "{err}");
2005    }
2006
2007    #[test]
2008    fn revision_moves_when_the_queue_changes() {
2009        let (_dir, q) = queue();
2010        assert_eq!(q.revision(), 0, "an empty queue has no revision");
2011        let mut t = task("first");
2012        q.put(&mut t).unwrap();
2013        assert!(q.revision() > 0, "a written task moves the revision");
2014    }
2015
2016    #[test]
2017    fn revision_moves_when_deleting_an_older_task() {
2018        let (dir, q) = queue();
2019        let questions = Questions::at(dir.path().join("questions"));
2020        let mut t1 = task("older");
2021        q.put(&mut t1).unwrap();
2022        // Ensure mtime ticks forward.
2023        std::thread::sleep(std::time::Duration::from_millis(10));
2024        let mut t2 = task("newer");
2025        q.put(&mut t2).unwrap();
2026
2027        let rev_before = q.revision();
2028        q.remove(&t1.id, false, &questions).unwrap();
2029        let rev_after = q.revision();
2030
2031        assert_ne!(
2032            rev_before, rev_after,
2033            "deleting an older task must change the revision so other clients see the deletion"
2034        );
2035    }
2036
2037    #[test]
2038    fn removing_a_task_takes_it_out_of_the_listing() {
2039        let (dir, q) = queue();
2040        let questions = Questions::at(dir.path().join("questions"));
2041        let mut t = task("delete me");
2042        q.put(&mut t).unwrap();
2043        let removed = q.remove(t.short(), false, &questions).unwrap();
2044        assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
2045        assert!(removed.quarantined.is_empty(), "nothing was blocked on it");
2046        assert!(q.list().is_empty());
2047        assert!(
2048            q.remove(&t.id, false, &questions).is_err(),
2049            "removing twice is an error"
2050        );
2051    }
2052
2053    #[test]
2054    fn removing_a_task_takes_its_stale_lock_with_it() {
2055        let (dir, q) = queue();
2056        let questions = Questions::at(dir.path().join("questions"));
2057        let mut t = task("interrupted");
2058        q.put(&mut t).unwrap();
2059
2060        // A daemon killed mid-run leaves this behind. Nothing holds it: the
2061        // process that would have dropped the guard is gone.
2062        let claim = q.claim(&t.id).unwrap();
2063        std::mem::forget(claim);
2064        assert!(
2065            q.claim(&t.id).is_err(),
2066            "the orphaned lock is what makes the task look claimed"
2067        );
2068
2069        // A live daemon on this task is refused, whatever the lock says.
2070        let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
2071        assert!(err.contains("live daemon"), "{err}");
2072        assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
2073
2074        // With no daemon behind it, the lock is stale and goes with the task.
2075        q.remove(&t.id, false, &questions).unwrap();
2076        assert!(q.list().is_empty());
2077        let mut again = task("interrupted");
2078        again.id = t.id.clone();
2079        q.put(&mut again).unwrap();
2080        assert!(
2081            q.claim(&t.id).is_ok(),
2082            "a task that comes back must be claimable, which a left-behind lock would prevent"
2083        );
2084    }
2085
2086    #[test]
2087    fn removing_a_task_quarantines_what_was_blocked_on_it() {
2088        let (dir, q) = queue();
2089        let questions = Questions::at(dir.path().join("questions"));
2090
2091        let mut dep = task("dependency");
2092        q.put(&mut dep).unwrap();
2093
2094        let mut still_valid = task("still valid");
2095        q.put(&mut still_valid).unwrap();
2096
2097        let mut blocked = task("waiting");
2098        blocked.block(
2099            vec![dep.id.clone(), still_valid.id.clone()],
2100            Some("waits on both".to_owned()),
2101        );
2102        q.put(&mut blocked).unwrap();
2103
2104        let removed = q.remove(&dep.id, false, &questions).unwrap();
2105        assert_eq!(removed.quarantined, [blocked.id.clone()]);
2106
2107        let after = q.get(&blocked.id).unwrap();
2108        assert_eq!(after.status, TaskStatus::Held);
2109        assert_eq!(after.hold_source, Some(HoldSource::Machine));
2110        assert!(after.blocked_by.is_empty());
2111        let reason = after.hold_reason.as_deref().unwrap_or_default();
2112        assert!(reason.contains(&dep.id), "{reason}");
2113        assert!(
2114            reason.contains(&still_valid.id),
2115            "the still-valid dependency must survive in the reason text: {reason}"
2116        );
2117    }
2118}