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