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::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36use anyhow::{Context, Result, bail};
37use jiff::Timestamp;
38use serde::{Deserialize, Serialize};
39
40use crate::ask::Questions;
41
42/// On-disk format for a queued task. Bumped when a field's meaning changes.
43///
44/// 7: added [`Task::attachments`], names of files copied under
45/// `<id>.attachments/` beside the task file (a field only; `#[serde(default)]`,
46/// so an older record reads as empty and [`read_path`] still accepts it).
47///
48/// 6: added [`Task::resume_override`] (a field only; `#[serde(default)]`, so
49/// an older record reads as `None` and [`read_path`] still accepts it).
50///
51/// 5: added [`Task::triage_applied`], the ids of triage questions whose
52/// answer has already been applied to this task. A "resume" answer used to
53/// leave no trace ([`Task::release`] clears [`Task::hold_reason`], which was
54/// the only place the applied marker lived), so when the released task failed
55/// its attempts and went back to `held`, the next idle pass found the same
56/// answered question "not applied" and released it again with `attempts` reset
57/// to 0 - the `max_attempts` bound never held. `#[serde(default)]` so an older
58/// record reads as empty. A task already looping when this build arrives has
59/// no record, so it is released once more, recorded, and then stays held.
60///
61/// 8: added [`Task::followup`], the origin of a task `crate::followup` filed
62/// from a merged run's leftover findings. Field-only bump, `#[serde(default)]`.
63///
64/// 4: added [`Task::blocked_from`], the status a task had the moment it
65/// became [`TaskStatus::Blocked`], so [`Task::unblock`] restores it instead
66/// of always landing on [`TaskStatus::Queued`]. Without it, a task a human
67/// or `crate::triage` had deliberately left [`TaskStatus::Held`] — machine
68/// or manual — would lose that the instant `crate::conduct` blocked it on a
69/// follow-up question, and come back `Queued` the moment the question was
70/// answered, regardless of what the answer said: exactly the loop where a
71/// task the operator told to stay held instead re-enters the competition
72/// queue every time someone answers a question about it. `#[serde(default)]`
73/// so an older record reads as `None`; [`Task::unblock`] then falls back to
74/// inferring `Held` from surviving hold evidence ([`Task::hold_reason`] /
75/// [`Task::hold_source`], never cleared by [`Task::block`]) rather than
76/// guessing `Queued` outright — see [`Task::unblock`]'s own doc.
77///
78/// 9: added [`Task::held_at`], the start of the current hold, so a notice can
79/// tell a question filed for this hold from one about an older cause.
80/// Field-only, `#[serde(default)]`.
81///
82/// 10: added [`Task::overrides`] and [`Task::review_of`], so a run started by
83/// hand (`magi run` / `magi review`) is carried by a task without losing its
84/// command-line choices. Field-only.
85///
86/// 11: added [`TaskStatus::Parked`] and [`Task::park_reason`], so a run that
87/// stopped at a node boundary (an upgrade, an operator's park) is no longer
88/// recorded as a failed task. Variant plus `#[serde(default)]` field; an older
89/// build must not half-read the new status.
90///
91/// 12: added [`Task::origin_chat`], the talk a task descends from, so a
92/// follow-up still knows its chat after an ancestor task is deleted.
93/// Field-only, `#[serde(default)]`.
94///
95/// 13: added [`Task::chat_notice`], the record that a chat-filed task's final
96/// state was reported to its conversation. Field-only, `#[serde(default)]`;
97/// a record below this schema is never notified (an upgrade must not replay
98/// the whole history into chats).
99///
100/// 3: added [`HoldSource`] so conductor recovery cannot release a hold an
101/// operator deliberately placed. Old records default to `None` and are
102/// protected as operator-held until an explicit release; the safe direction
103/// when their author was never recorded.
104///
105/// 2: added [`TaskStatus::Blocked`], [`Task::blocked_by`] and
106/// [`Task::block_reason`] (`crate::conduct`'s decisions) and
107/// [`Task::answers`] (operator answers carried forward to the next
108/// conductor prompt and the next run's instruction). All three are
109/// `#[serde(default)]`, so [`read_path`] accepts anything up to and
110/// including this schema rather than only an exact match — a task written
111/// by a build that only knew about schema 1 has nothing to say about
112/// blocking or answers, and defaulting those fields is exactly as good a
113/// reading as a value that build never had a chance to write.
114///
115/// 14: record whether a task count edit's overrides inherit daemon options.
116/// Old overrides default to isolated command-line settings, including empty ones.
117pub const SCHEMA: u32 = 14;
118
119/// Who placed the current hold.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "lowercase")]
122pub enum HoldSource {
123    /// An operator used the CLI or web UI.
124    Manual,
125    /// The daemon or conductor placed the hold as part of its own recovery.
126    Machine,
127}
128
129impl HoldSource {
130    /// Short human-facing label for reports and the CLI.
131    pub fn label(self) -> &'static str {
132        match self {
133            Self::Manual => "manual",
134            Self::Machine => "machine",
135        }
136    }
137}
138
139/// Where a task came from. Recorded because "who asked for this" is the first
140/// question about an autonomous run, and the answer is not recoverable later.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(tag = "kind", rename_all = "lowercase")]
143pub enum Source {
144    /// A person, at a terminal or through the web UI.
145    Human,
146    /// An agent inside a run, via `magi task add`. Both ids are recorded so a
147    /// task can be traced back to the exact seat that asked for it.
148    Agent {
149        /// Run the asking agent belonged to.
150        run: String,
151        /// Node it was working in, e.g. `implement` or `review`.
152        node: String,
153    },
154    /// A GitHub issue, imported by number.
155    Issue {
156        /// Issue number.
157        number: u64,
158        /// `owner/repo`, as `gh` reports it.
159        repo: String,
160    },
161}
162
163/// The `node` a [`Source::Agent`] carries when the asker is a chat
164/// conversation (its `run` is then the conversation's id).
165pub const CHAT_NODE: &str = "chat";
166
167impl Source {
168    /// Short human-facing label, for lists and the web UI.
169    pub fn label(&self) -> String {
170        match self {
171            Self::Human => "human".to_owned(),
172            Self::Agent { run, node } => format!("{node}@{}", short(run)),
173            Self::Issue { number, .. } => format!("issue #{number}"),
174        }
175    }
176}
177
178/// Where a task is in its life.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181pub enum TaskStatus {
182    /// Waiting to be claimed.
183    Queued,
184    /// Claimed by a daemon; a run is in flight.
185    Running,
186    /// A run finished and its gate passed.
187    Done,
188    /// A run finished without passing, and attempts remain.
189    Failed,
190    /// Out of attempts, or held by hand. The loop will not pick it up.
191    Held,
192    /// Waiting on another task or an unanswered question. See
193    /// [`Task::blocked_by`]. Set and cleared by `crate::conduct` and
194    /// `crate::daemon`'s deterministic resolver, never by hand.
195    Blocked,
196    /// The run stopped at a node boundary because the process was asked to
197    /// give itself back. Nothing failed: the run's work is intact and the next
198    /// loop resumes it. Runnable like [`TaskStatus::Queued`]; the reason is in
199    /// [`Task::park_reason`], never [`Task::last_error`].
200    Parked,
201}
202
203impl TaskStatus {
204    /// Is this task eligible for a daemon to claim?
205    pub fn runnable(self) -> bool {
206        matches!(self, Self::Queued | Self::Failed | Self::Parked)
207    }
208
209    /// Lowercase name, as it appears on disk and in the API.
210    pub fn as_str(self) -> &'static str {
211        match self {
212            Self::Queued => "queued",
213            Self::Running => "running",
214            Self::Done => "done",
215            Self::Failed => "failed",
216            Self::Held => "held",
217            Self::Blocked => "blocked",
218            Self::Parked => "parked",
219        }
220    }
221}
222
223/// How many tasks sit in each [`TaskStatus`], for a dashboard tile — never a
224/// per-task view, so it carries no ids.
225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
226pub struct TaskCounts {
227    /// Waiting to be claimed.
228    pub queued: usize,
229    /// Claimed; a run is in flight.
230    pub running: usize,
231    /// A run finished and its gate passed.
232    pub done: usize,
233    /// A run finished without passing, and attempts remain.
234    pub failed: usize,
235    /// Out of attempts, or held by hand.
236    pub held: usize,
237    /// Waiting on another task or an unanswered question.
238    pub blocked: usize,
239    /// Run parked at a node boundary; resumable, not a failure.
240    pub parked: usize,
241}
242
243impl TaskCounts {
244    /// Tally `tasks` by status. A pure function over whatever [`Queue::list`]
245    /// already read, so it needs no root of its own and stays trivially
246    /// testable against a hand-built slice.
247    pub fn of(tasks: &[Task]) -> Self {
248        let mut counts = Self::default();
249        for t in tasks {
250            match t.status {
251                TaskStatus::Queued => counts.queued += 1,
252                TaskStatus::Running => counts.running += 1,
253                TaskStatus::Done => counts.done += 1,
254                TaskStatus::Failed => counts.failed += 1,
255                TaskStatus::Held => counts.held += 1,
256                TaskStatus::Blocked => counts.blocked += 1,
257                TaskStatus::Parked => counts.parked += 1,
258            }
259        }
260        counts
261    }
262}
263
264/// One unit of work.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct Task {
268    /// On-disk format version.
269    pub schema: u32,
270    /// Task id, e.g. `20260902-140501-a1b2`.
271    pub id: String,
272    /// One line, for lists and notifications.
273    pub title: String,
274    /// The task itself, handed to the graph verbatim.
275    pub instruction: String,
276    /// Repository to work in.
277    pub repo: PathBuf,
278    /// Who asked.
279    pub source: Source,
280    /// Higher runs first; ties break oldest-first so nothing starves.
281    #[serde(default)]
282    pub priority: i32,
283    /// Run this task alone: one implementer, no panel of judges to convince.
284    ///
285    /// `#[serde(default)]` so a queue file written before this field existed
286    /// still reads, as `false` - the ordinary multi-candidate competition,
287    /// unchanged. A task set to `solo` still runs the whole graph; only the
288    /// candidate count the daemon builds it with changes, and
289    /// [`crate::graph::Runner`] already collapses a single-candidate run to
290    /// implement → review → gate → merge on its own (see
291    /// [`crate::graph::Runner::review`]'s doc), so nothing about judging,
292    /// deliberation or voting had to change to support this.
293    #[serde(default)]
294    pub solo: bool,
295    /// Current state.
296    pub status: TaskStatus,
297    /// How many times this task has been claimed.
298    #[serde(default)]
299    pub attempts: usize,
300    /// Runs this task has produced, oldest first.
301    #[serde(default)]
302    pub runs: Vec<String>,
303    /// Why the last attempt did not land.
304    #[serde(default)]
305    pub last_error: Option<String>,
306    /// What a human hold is waiting on.
307    ///
308    /// `None` covers both the ordinary cases: a hold the loop makes itself
309    /// (out of attempts, or the disk gate closed) explains itself through
310    /// [`Task::last_error`] instead, and a human hold nobody bothered to
311    /// explain is still a valid hold. The queue has no way to express a
312    /// dependency between two tasks, so on the occasions a hold really is
313    /// "wait for that other task first", this is the only place that reason
314    /// survives - see [`Task::hold_manual`] and [`Task::release`].
315    ///
316    /// `#[serde(default)]` so a queue file written before this field existed
317    /// still reads, with no reason recorded rather than a parse error.
318    #[serde(default)]
319    pub hold_reason: Option<String>,
320    /// Who placed [`Task::hold_reason`].  `None` is a compatible old record;
321    /// see [`Task::operator_held`] for its deliberately conservative meaning.
322    #[serde(default)]
323    pub hold_source: Option<HoldSource>,
324    /// Diagnostic detail excerpted from the run that led to a hold - what a
325    /// human would have found opening `artifacts/` by hand, not the one-line
326    /// reason in [`Task::last_error`]. Set only when a run's own attempts are
327    /// exhausted and the task becomes [`TaskStatus::Held`]; `daemon` computes
328    /// it from the run's own record, since this module has no notion of a
329    /// run's internals. Bounded in length by the writer - see
330    /// `daemon::diagnostic` - so a verbose run cannot make this file grow
331    /// without limit.
332    ///
333    /// `#[serde(default)]` so a queue file written before this field existed
334    /// still reads, with no diagnostic recorded rather than a parse error.
335    #[serde(default)]
336    pub diagnostic: Option<String>,
337    /// What this task is waiting on: other task ids, unanswered
338    /// `crate::ask::Question` ids, or both. Non-empty exactly when
339    /// [`TaskStatus::Blocked`]; emptying it — see [`Task::unblock`] — is what
340    /// puts the task back at [`TaskStatus::Queued`].
341    ///
342    /// Set by `crate::conduct`'s decisions and cleared deterministically by
343    /// `crate::daemon` as each dependency resolves, never by a person. Never
344    /// `#[serde(default)]` is skipped: a queue file from before this field
345    /// existed has nothing to report here, and an empty list is exactly that.
346    #[serde(default)]
347    pub blocked_by: Vec<String>,
348    /// One line explaining the current [`Task::blocked_by`], written by
349    /// `crate::conduct`. Cleared whenever `blocked_by` empties.
350    #[serde(default)]
351    pub block_reason: Option<String>,
352    /// The status this task had the moment [`Task::block`] most recently
353    /// moved it to [`TaskStatus::Blocked`] — what [`Task::unblock`] restores
354    /// once nothing is left in `blocked_by`, instead of always landing on
355    /// [`TaskStatus::Queued`]. See [`SCHEMA`]'s doc for schema 4 on why this
356    /// exists: an answer to a question `crate::conduct` filed about a
357    /// [`TaskStatus::Held`] task must not itself be what puts the task back
358    /// in the competition queue.
359    ///
360    /// `#[serde(default)]` so a queue file written before this field existed
361    /// reads as `None`; [`Task::unblock`] treats that the same as a task
362    /// blocked straight from `Queued`, unless surviving hold evidence says
363    /// otherwise.
364    #[serde(default)]
365    pub blocked_from: Option<TaskStatus>,
366    /// Questions `crate::conduct` asked about this task that the operator has
367    /// since answered, oldest first — what was asked, and what they said.
368    ///
369    /// A blocking question's id leaves [`Task::blocked_by`] the moment
370    /// [`crate::ask::QuestionStatus::Answered`] is observed, but the id alone
371    /// tells nobody what was decided. This is what carries the answer's
372    /// *content* forward: into the next conductor prompt for this task, and
373    /// into the instruction handed to the next run — see `crate::daemon`'s
374    /// deterministic blocker resolution. Kept for the task's whole life, the
375    /// same as [`Task::runs`]: a release resets attempts, not evidence.
376    #[serde(default)]
377    pub answers: Vec<AnsweredQuestion>,
378    /// Ids of the `crate::triage` questions whose answer has been applied to
379    /// this task. Unlike [`Task::hold_reason`], [`Task::release`] and every
380    /// hold transition leave it alone, so an answer is applied at most once
381    /// however many times the task is held again. See [`SCHEMA`]'s doc for
382    /// schema 5. `#[serde(default)]` so an older record reads as empty.
383    #[serde(default)]
384    pub triage_applied: Vec<String>,
385    /// Ids of the `magi ask` questions whose [`crate::ask::ChoiceAction`] has
386    /// been applied to this task. Separate from [`Task::triage_applied`]
387    /// (a different producer) and, like it, untouched by [`Task::release`],
388    /// so one answer acts at most once however often the task is held again.
389    /// `#[serde(default)]` so an older record reads as empty.
390    #[serde(default)]
391    pub actions_applied: Vec<String>,
392    /// The operator's "resume" answer to a triage question, kept until the
393    /// task actually runs (or is done) so `crate::conduct` cannot silently
394    /// undo it and `crate::triage` can tell that a hold it sees now came
395    /// *after* the answer. See [`OperatorResume`]. `#[serde(default)]`.
396    #[serde(default)]
397    pub resume_override: Option<OperatorResume>,
398    /// Set by `crate::conduct` when it chooses `Review` recovery for a task
399    /// whose branch survived a blocked run: the branch to reopen with
400    /// `crate::graph::Runner::review` instead of competing from scratch.
401    ///
402    /// Requeues the task the same way [`Task::release`] does, so it is
403    /// picked up by the ordinary loop; `crate::daemon` reads this field once,
404    /// when it actually starts the run, and clears it either way — consumed
405    /// on success, dropped if the branch no longer exists by then. Never set
406    /// from the conductor's own words: `crate::daemon` derives the branch
407    /// name itself from the task's last run, so a hallucinated branch can
408    /// never reach here.
409    #[serde(default)]
410    pub review_branch: Option<String>,
411    /// A release deliberately starts a new competition instead of resuming
412    /// the prior run. History remains as evidence in `runs`.
413    #[serde(default)]
414    pub fresh_start: bool,
415    /// Marked by an operator (`magi task interrupt`) to ask `magi serve` to
416    /// run this one ahead of whatever it already has in flight, once
417    /// `[daemon] pause_for_interrupts` is on - see
418    /// `crate::daemon::advance_interrupt`. Never set by the loop itself, and
419    /// deliberately a different operation from [`Task::set_priority`]: a
420    /// priority only reorders the queue a claim has not reached yet, while
421    /// this asks a run already in flight to park at its next safe boundary
422    /// and step aside. `#[serde(default)]` so a queue file written before
423    /// this field existed still reads, as `false` - no task interrupts
424    /// anything unless asked to, exactly as before.
425    #[serde(default)]
426    pub interrupt: bool,
427    /// Marked by `magi task add --urgent`: `crate::daemon::poll` dispatches
428    /// this task through its own one-slot `urgent_sem` the moment it is
429    /// runnable, in addition to whatever is already running under the
430    /// ordinary `[daemon] max_concurrent_runs` pool - never instead of it,
431    /// and never by pausing or otherwise touching that run. This is the
432    /// opposite direction from [`Task::interrupt`]: that one asks a run
433    /// already in flight to step aside; this one never asks anything to
434    /// step aside, it only spends one additional, temporary concurrency
435    /// slot. The two are independent and may both be set on the same task,
436    /// but this exemption stops at `[daemon] pause_for_interrupts`'s own
437    /// park/resume handoff (75dd): while an interrupt sequence is actively
438    /// parking, running, or resuming - its own, or an unrelated task's -
439    /// `crate::daemon::interrupt_gate` withholds an urgent candidate exactly
440    /// like an ordinary one, never exempted. 75dd's "at most one run, ever,
441    /// at once" guarantee takes precedence, because the alternative is a run
442    /// still genuinely in flight (only *asked* to park, not yet gone) ending
443    /// up alongside a second one this feature let through - the very thing
444    /// that guarantee exists to rule out.
445    ///
446    /// `#[serde(default)]` so a queue file written before this field existed
447    /// still reads, as `false` - no task claims the urgent slot unless asked
448    /// to, exactly as before.
449    #[serde(default)]
450    pub urgent: bool,
451    /// Files (typically screenshots) copied into `<id>.attachments/` beside
452    /// this task's file by [`Queue::attach`], as bare validated names. The
453    /// copy is what makes them reach the implementer: the original may be
454    /// cleaned up long before the task runs. Untouched by [`Task::edit`].
455    /// `#[serde(default)]` so an older record reads as empty.
456    #[serde(default)]
457    pub attachments: Vec<String>,
458    /// Set on a task `crate::followup` filed from the findings a merged run
459    /// left open: which run and findings it carries, and how many follow-ups
460    /// deep it is. `None` for every ordinary task. `#[serde(default)]`.
461    #[serde(default)]
462    pub followup: Option<FollowUp>,
463    /// Id of the chat this task descends from: set by [`Task::new`] when a
464    /// chat files it, inherited by the follow-ups `crate::followup` files, so
465    /// the provenance survives deleting an ancestor. `None` for a task written
466    /// before the field existed (`crate::consult` then walks the ancestry).
467    /// `#[serde(default)]`.
468    #[serde(default)]
469    pub origin_chat: Option<String>,
470    /// The command-line choices (`--merge`, `-c`, `--seed`, ...) of the
471    /// `magi run` / `magi review` that filed this task, applied on top of the
472    /// repository's config by whoever executes it - a daemon included - so
473    /// handing the task over does not change what it does. A count edit on an
474    /// ordinary queue task explicitly inherits daemon options instead.
475    /// `#[serde(default)]`.
476    #[serde(default)]
477    pub overrides: Option<RunOverrides>,
478    /// The branch an explicit `magi review <branch>` asked to review. Unlike
479    /// [`Task::review_branch`] (a recovery choice the daemon consumes and
480    /// drops back to a competition when the branch is gone), this is the whole
481    /// request: it is never consumed, and a missing branch fails the attempt
482    /// rather than paying for an implementation nobody asked for.
483    /// `#[serde(default)]`.
484    #[serde(default)]
485    pub review_of: Option<String>,
486    /// When the current hold began. Set by the transitions into
487    /// [`TaskStatus::Held`] (a re-hold keeps it), cleared by
488    /// [`Task::release`]. `crate::notices` uses it as the identity of the
489    /// cause: a question filed before it is about something older.
490    #[serde(default)]
491    pub held_at: Option<Timestamp>,
492    /// Why the run parked, while the task is [`TaskStatus::Parked`]. Kept apart
493    /// from [`Task::last_error`] because nothing failed. Cleared by every
494    /// transition out of the parked state. `#[serde(default)]`.
495    #[serde(default)]
496    pub park_reason: Option<String>,
497    /// The last final state reported to the originating chat. See
498    /// [`Task::chat_notice_due`]. Never cleared by a release or requeue.
499    #[serde(default)]
500    pub chat_notice: Option<ChatNotice>,
501    /// When the task was filed.
502    pub created_at: Timestamp,
503    /// Last change to this file.
504    pub updated_at: Timestamp,
505}
506
507/// Current and legacy completion keys; legacy run counts contain only digits.
508pub(crate) fn is_done_notice_key(key: &str) -> bool {
509    key == "done"
510        || key
511            .strip_prefix("done#")
512            .is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()))
513}
514
515/// What became of a completion notice. See [`Task::chat_notice`].
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(rename_all = "lowercase")]
518pub enum ChatNoticeOutcome {
519    /// Queued as a draft of the originating talk.
520    Posted,
521    /// The talk is gone or closed; nothing was queued, and nothing will be.
522    Skipped,
523}
524
525/// The final state of a chat-filed task that was already reported (or skipped).
526#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527pub struct ChatNotice {
528    /// Identity of the cause, see [`Task::chat_notice_key`].
529    pub key: String,
530    /// When it was recorded.
531    pub at: Timestamp,
532    /// Posted or skipped.
533    pub outcome: ChatNoticeOutcome,
534}
535
536/// Command-line choices carried by a task. See [`Task::overrides`].
537#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
538pub struct RunOverrides {
539    /// Count edits on ordinary queued tasks inherit the loop's config and merge.
540    /// False for direct-run overrides and old records, even when otherwise empty.
541    #[serde(default)]
542    pub inherit_daemon_options: bool,
543    /// `--merge`: `none`, `local` or `pr`.
544    #[serde(default)]
545    pub merge: Option<String>,
546    /// `-c` / `--implementers`: parallel implementations (persisted as `candidates`).
547    #[serde(default)]
548    pub candidates: Option<usize>,
549    /// `-j`: independent judges.
550    #[serde(default)]
551    pub judges: Option<usize>,
552    /// `--reviewers`: reviewers per round.
553    #[serde(default)]
554    pub reviewers: Option<usize>,
555    /// `--review-rounds`.
556    #[serde(default)]
557    pub review_rounds: Option<usize>,
558    /// `--seed`.
559    #[serde(default)]
560    pub seed: Option<u64>,
561    /// `--config`, absolute: the daemon's own working directory differs.
562    #[serde(default)]
563    pub config: Option<PathBuf>,
564}
565
566impl RunOverrides {
567    /// Lay `other` over these: every choice `other` made wins, the rest stay.
568    pub fn merge_over(&mut self, other: &RunOverrides) {
569        macro_rules! take {
570            ($($f:ident),*) => {$(
571                if other.$f.is_some() {
572                    self.$f.clone_from(&other.$f);
573                }
574            )*};
575        }
576        take!(
577            merge,
578            candidates,
579            judges,
580            reviewers,
581            review_rounds,
582            seed,
583            config
584        );
585    }
586
587    /// Apply these on top of `config`.
588    pub fn apply(&self, config: &mut crate::config::Config) {
589        if let Some(n) = self.candidates {
590            config.graph.implementers = n;
591        }
592        if let Some(n) = self.judges {
593            config.graph.judges = n;
594        }
595        if let Some(n) = self.reviewers {
596            config.graph.reviewers = n;
597        }
598        if let Some(n) = self.review_rounds {
599            config.graph.review_rounds = n;
600        }
601        if let Some(m) = &self.merge
602            && let Ok(mode) = crate::daemon::merge_mode(m)
603        {
604            config.merge.mode = mode;
605        }
606        if let Some(s) = self.seed {
607            config.blind.seed = Some(s);
608        }
609    }
610}
611
612/// What a follow-up task came from. See [`Task::followup`].
613#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
614pub struct FollowUp {
615    /// The merged run whose open findings this task carries.
616    pub run: String,
617    /// The queue task that run served, when it had one.
618    #[serde(default)]
619    pub origin_task: Option<String>,
620    /// The merged pull request.
621    pub pr: String,
622    /// Ids of the findings (e.g. `R3-1-1`) this task covers.
623    pub findings: Vec<String>,
624    /// Follow-up depth: 1 for a follow-up of an ordinary task's run, 2 for a
625    /// follow-up of that, and so on. An ordinary task is generation 0.
626    pub generation: u32,
627}
628
629/// A triage "resume" answer and what became of it. See
630/// [`Task::resume_override`].
631#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
632pub struct OperatorResume {
633    /// The triage question the operator answered.
634    pub question_id: String,
635    /// When the answer was applied.
636    pub at: Timestamp,
637    /// The reason `crate::conduct` gave for holding the task again after the
638    /// answer, if it did. The conductor may do this once.
639    #[serde(default)]
640    pub conductor_rehold: Option<String>,
641    /// The operator answered "resume" a second time, to the question about
642    /// that contradiction: the conductor may no longer hold this task.
643    #[serde(default)]
644    pub forced: bool,
645    /// Set when the answer was a structured `resume` action on a `magi ask`
646    /// choice ([`crate::ask::ChoiceAction::Resume`]): the run the operator
647    /// chose to continue. Its presence also stops `crate::conduct` from
648    /// requeuing the task into a fresh competition, which would discard
649    /// exactly the run that was named. Cleared with the rest of the record
650    /// once the task runs. `#[serde(default)]` so older records read as `None`.
651    #[serde(default)]
652    pub pinned_run: Option<String>,
653}
654
655/// One question `crate::conduct` asked about a task, and what the operator
656/// said back. See [`Task::answers`].
657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
658pub struct AnsweredQuestion {
659    /// The question as asked, e.g. [`crate::ask::Question::summary`].
660    pub question: String,
661    /// What the operator answered.
662    pub answer: String,
663}
664
665impl Task {
666    /// File a new task. Persist it with [`Queue::put`].
667    pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
668        let now = Timestamp::now();
669        let origin_chat = match &source {
670            Source::Agent { run, node } if node == CHAT_NODE => Some(run.clone()),
671            _ => None,
672        };
673        Self {
674            schema: SCHEMA,
675            id: new_id(),
676            title,
677            instruction,
678            repo,
679            source,
680            priority: 0,
681            solo: false,
682            status: TaskStatus::Queued,
683            attempts: 0,
684            runs: Vec::new(),
685            last_error: None,
686            hold_reason: None,
687            hold_source: None,
688            diagnostic: None,
689            blocked_by: Vec::new(),
690            block_reason: None,
691            blocked_from: None,
692            answers: Vec::new(),
693            triage_applied: Vec::new(),
694            actions_applied: Vec::new(),
695            resume_override: None,
696            review_branch: None,
697            fresh_start: false,
698            interrupt: false,
699            urgent: false,
700            attachments: Vec::new(),
701            followup: None,
702            origin_chat,
703            overrides: None,
704            review_of: None,
705            held_at: None,
706            park_reason: None,
707            chat_notice: None,
708            created_at: now,
709            updated_at: now,
710        }
711    }
712
713    /// The chat this task itself says it came from: the recorded
714    /// [`Task::origin_chat`], else its own source when a chat filed it.
715    pub fn chat_talk(&self) -> Option<&str> {
716        if let Some(id) = &self.origin_chat {
717            return Some(id);
718        }
719        match &self.source {
720            Source::Agent { run, node } if node == CHAT_NODE => Some(run),
721            _ => None,
722        }
723    }
724
725    /// The talk that filed this task *itself* (`Source::Agent` with
726    /// [`CHAT_NODE`]); unlike [`Task::chat_talk`] a follow-up's inherited
727    /// `origin_chat` does not count.
728    pub fn filed_by_chat(&self) -> Option<&str> {
729        match &self.source {
730            Source::Agent { run, node } if node == CHAT_NODE => Some(run),
731            _ => None,
732        }
733    }
734
735    /// Identity of the final state a chat should hear about, or `None` while
736    /// the task is intermediate (queued, running, parked, a failed attempt the
737    /// loop will retry - the last attempt turns into a hold). A hold is keyed
738    /// by when it began and a block by what it waits on. Completion is keyed
739    /// by the task itself, so requeues never report completion twice.
740    pub fn chat_notice_key(&self) -> Option<String> {
741        match self.status {
742            TaskStatus::Done => Some("done".to_owned()),
743            TaskStatus::Held => Some(format!(
744                "held#{}",
745                self.held_at.map(|t| t.to_string()).unwrap_or_default()
746            )),
747            TaskStatus::Blocked => Some(format!(
748                "blocked#{}#{}",
749                self.blocked_by.join(","),
750                self.block_reason.as_deref().unwrap_or_default()
751            )),
752            _ => None,
753        }
754    }
755
756    /// The key of a completion notice owed to the originating chat now, if
757    /// any: filed by a chat, schema 13 or newer, in a final state, and not the
758    /// state already recorded in [`Task::chat_notice`].
759    pub fn chat_notice_due(&self) -> Option<String> {
760        self.filed_by_chat()?;
761        if self.schema < 13 {
762            return None;
763        }
764        let key = self.chat_notice_key()?;
765        match &self.chat_notice {
766            Some(n) if n.key == key || (key == "done" && is_done_notice_key(&n.key)) => None,
767            _ => Some(key),
768        }
769    }
770
771    /// Short form used in reports, matching a run's short id.
772    pub fn short(&self) -> &str {
773        short(&self.id)
774    }
775
776    /// Record that triage question `question_id`'s answer has been applied.
777    pub fn mark_triage_applied(&mut self, question_id: &str) {
778        if !self.triage_applied(question_id) {
779            self.triage_applied.push(question_id.to_owned());
780        }
781    }
782
783    /// Has the action of `magi ask` question `question_id` already been
784    /// applied, or is it being applied now? See [`Task::actions_applied`].
785    pub fn action_applied(&self, question_id: &str) -> bool {
786        self.actions_applied.iter().any(|id| id == question_id)
787    }
788
789    /// Record that `magi ask` question `question_id`'s action was applied.
790    pub fn mark_action_applied(&mut self, question_id: &str) {
791        if !self.action_applied(question_id) {
792            self.actions_applied.push(question_id.to_owned());
793        }
794    }
795
796    /// Has triage question `question_id`'s answer already been applied?
797    pub fn triage_applied(&self, question_id: &str) -> bool {
798        self.triage_applied.iter().any(|id| id == question_id)
799    }
800
801    /// Record that a run has started for this task.
802    ///
803    /// Clears [`Task::interrupt`]: a mark to run ahead of whatever else is
804    /// in flight is fulfilled the moment this task actually gets its turn,
805    /// dispatched same as any other. Without this, a task whose run fails
806    /// and requeues - still `runnable`, still carrying the mark from its
807    /// first attempt - would keep re-triggering `crate::daemon`'s interrupt
808    /// scheduler and re-parking whatever it interrupted on every later
809    /// boundary, for as long as its attempts hold out, instead of the
810    /// one-shot "let this go next" the mark is meant to be.
811    pub fn start(&mut self, run: String) {
812        self.stamp_schema();
813        self.status = TaskStatus::Running;
814        self.attempts += 1;
815        self.runs.push(run);
816        self.last_error = None;
817        self.park_reason = None;
818        self.fresh_start = false;
819        self.interrupt = false;
820        // The answer has been honoured: the task got its turn.
821        self.resume_override = None;
822    }
823
824    /// Note that `run` serves this task without otherwise changing its life:
825    /// no status, attempt or interrupt change, unlike [`Task::start`]. For a
826    /// run somebody else started on the task's behalf (`magi run --task`);
827    /// idempotent. Returns whether anything was added.
828    pub fn link_run(&mut self, run: &str) -> bool {
829        if self.runs.iter().any(|r| r == run) {
830            return false;
831        }
832        self.runs.push(run.to_owned());
833        true
834    }
835
836    /// Record a successful run.
837    ///
838    /// Both `magi task done` and `POST /api/queue/{id}/done` can close a held
839    /// *or blocked* task directly, with no release in between, so this clears
840    /// `hold_reason` and `blocked_by`/`block_reason` the same way
841    /// [`Task::release`] does. Otherwise a task held for "waiting on 3ed9", or
842    /// blocked on a dependency that never actually finished, and then closed
843    /// as done without ever being released would still read as waiting on
844    /// something in `magi task show` and on its card, after it no longer is.
845    pub fn succeed(&mut self) {
846        self.stamp_schema();
847        self.status = TaskStatus::Done;
848        self.resume_override = None;
849        self.last_error = None;
850        self.park_reason = None;
851        self.hold_reason = None;
852        self.hold_source = None;
853        self.diagnostic = None;
854        self.blocked_by.clear();
855        self.block_reason = None;
856        self.blocked_from = None;
857    }
858
859    /// Finish the task because the run's whole change turned out to be on the
860    /// base already ([`crate::run::RunStatus::AlreadyInBase`]): `Done`, with
861    /// `note` kept in [`Task::last_error`] so `magi task show` says why, and
862    /// the attempt refunded. Nothing went wrong and nothing was spent
863    /// misbehaving, so this is neither [`Task::fail`] nor
864    /// [`Task::handed_off`]; and unlike [`Task::succeed`] it does not pretend
865    /// the run landed anything.
866    pub fn already_landed(&mut self, note: impl Into<String>) {
867        self.succeed();
868        self.attempts = self.attempts.saturating_sub(1);
869        self.last_error = Some(note.into());
870    }
871
872    /// Earlier attempts at this task that a later one has since made moot —
873    /// empty unless the task is [`TaskStatus::Done`] *and* `last_run_succeeded`
874    /// says `runs.last()` is actually why.
875    ///
876    /// `runs` is oldest first, and [`Task::start`] is the only thing that
877    /// pushes to it, always right before the attempt it names either succeeds
878    /// or fails; `succeed` itself never touches `runs`. So whenever `status`
879    /// is `Done` *because* the loop itself saw that last attempt land
880    /// (`Merged`/`Ready`), everything before it in the same list is a retry
881    /// this task no longer needs. But `succeed` is also reachable directly —
882    /// `magi task done`, the web UI's equivalent, and the conductor's
883    /// `Recovery::Done` all call it on a task in *any* status, including one
884    /// whose last recorded attempt never landed at all (closed by hand after
885    /// a merge magi's own loop never saw). `runs.last()` alone cannot tell
886    /// those two cases apart — that requires the caller to have actually
887    /// looked at that run's own `status`, which this module has no way to
888    /// do — so `last_run_succeeded` is the caller's answer to exactly that
889    /// question, not something this function can derive from `Task` alone.
890    ///
891    /// Deliberately narrower than [`Queue::superseded`]'s "every earlier
892    /// attempt has a later one" walk, which fires the moment a retry starts
893    /// even though the retry itself might still fail: that reading is right
894    /// for the web UI's "a newer attempt exists, go look at that one
895    /// instead" note, but wrong for deciding a run no longer needs a human's
896    /// attention, which is only true once the task's story has actually
897    /// ended well. Two Blocked runs sitting side by side while a third
898    /// attempt is still in flight must not be touched by this — see
899    /// `daemon::supersede_prior_runs`, the caller that turns this list into
900    /// rewritten `run.json` files.
901    pub fn superseded_attempts(&self, last_run_succeeded: bool) -> &[String] {
902        if self.status != TaskStatus::Done || !last_run_succeeded || self.runs.len() < 2 {
903            return &[];
904        }
905        &self.runs[..self.runs.len() - 1]
906    }
907
908    /// The attempt right after `run` in this task's history, if there is one.
909    ///
910    /// The one place "a later attempt replaced this run" is decided:
911    /// [`Queue::superseded`] and [`Queue::superseded_by`] (the web UI's
912    /// note) read it, and so does [`Task::earlier_attempts`], so what is shown
913    /// and what `crate::handover` acts on cannot disagree.
914    pub fn successor_of(&self, run: &str) -> Option<&String> {
915        let pos = self.runs.iter().position(|r| r == run)?;
916        self.runs.get(pos + 1)
917    }
918
919    /// Every run already recorded for this task, i.e. every run that an
920    /// attempt being minted *right now* supersedes.
921    ///
922    /// The new run is not in [`Task::runs`] yet when it decides what to take
923    /// over (`Task::start` pushes it afterwards), so "has a later attempt" is
924    /// true of everything listed here by the time the new run exists.
925    pub fn earlier_attempts(&self) -> &[String] {
926        &self.runs
927    }
928
929    /// Stamp [`Task::held_at`] on the way into [`TaskStatus::Held`]; a task
930    /// that is already held keeps the time its hold began.
931    /// A transition this build makes (start, hold, block, done) makes the task
932    /// eligible for a chat notice even when it was filed under an older
933    /// schema; only records no build has touched since stay silent
934    /// ([`Task::chat_notice_due`]).
935    fn stamp_schema(&mut self) {
936        self.schema = self.schema.max(SCHEMA);
937    }
938
939    fn note_held(&mut self) {
940        self.stamp_schema();
941        if self.status != TaskStatus::Held || self.held_at.is_none() {
942            self.held_at = Some(Timestamp::now());
943        }
944    }
945
946    /// Record a failed attempt. Out of attempts means held for a human, rather
947    /// than retried until the money runs out.
948    ///
949    /// Clears [`Task::diagnostic`] unconditionally: it belongs to whatever run
950    /// produced it, and a caller that has one for *this* attempt sets it
951    /// itself right after calling this, once it knows the task actually ended
952    /// up [`TaskStatus::Held`] - see `daemon::diagnostic`. Without the clear, a
953    /// task released after a diagnosed hold and then failed again for an
954    /// unrelated, undiagnosed reason (a config error, say) would go on
955    /// showing the previous run's diagnostic as if it explained the new one.
956    ///
957    /// Also records `why` into [`Task::hold_reason`] when this ends up
958    /// [`TaskStatus::Held`], so the notification and `magi task show` say the
959    /// same thing as [`Task::last_error`] instead of leaving the hold's own
960    /// reason blank.
961    pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
962        let why = why.into();
963        self.diagnostic = None;
964        self.park_reason = None;
965        self.status = if self.attempts >= max_attempts {
966            self.note_held();
967            self.hold_source = Some(HoldSource::Machine);
968            self.hold_reason = Some(why.clone());
969            TaskStatus::Held
970        } else {
971            TaskStatus::Failed
972        };
973        self.last_error = Some(why);
974    }
975
976    /// Record an attempt that failed for a reason the task is not responsible
977    /// for - the agent CLIs ran out of quota and the judging panel collapsed.
978    ///
979    /// This refunds the attempt on purpose. A quota window closing at 4am must
980    /// not spend the backlog's retry budget: the operator would come back to a
981    /// queue of held tasks that were never actually judged, and would have to
982    /// release every one by hand to find out which had a real problem. The task
983    /// goes back to `Failed`, which the loop retries, so a reset quota picks the
984    /// work up where it stopped.
985    pub fn stall(&mut self, why: impl Into<String>) {
986        self.last_error = Some(why.into());
987        self.diagnostic = None;
988        self.attempts = self.attempts.saturating_sub(1);
989        self.status = TaskStatus::Failed;
990    }
991
992    /// Record a run that parked at a node boundary. Nothing failed: the attempt
993    /// is refunded, [`Task::last_error`] is left as it was, the run history and
994    /// `fresh_start` are kept so the next loop resumes the same run, and the
995    /// reason goes to [`Task::park_reason`]. Runnable like a requeued task and
996    /// never counted against `max_attempts`.
997    pub fn park(&mut self, why: impl Into<String>) {
998        self.diagnostic = None;
999        self.attempts = self.attempts.saturating_sub(1);
1000        self.status = TaskStatus::Parked;
1001        self.park_reason = Some(why.into());
1002        // The variant and field are schema 11; stamp it so an older build
1003        // refuses the record instead of half-reading it.
1004        self.schema = self.schema.max(SCHEMA);
1005    }
1006
1007    /// Whether this held task may only be released by an operator.
1008    ///
1009    /// Old files did not record a source. Preserve every such hold rather
1010    /// than guessing that it was automatic and risking duplicate work. New
1011    /// automatic holds record [`HoldSource::Machine`] and remain recoverable.
1012    pub fn operator_held(&self) -> bool {
1013        self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
1014    }
1015
1016    /// Take this task out of the loop's reach by an operator action.
1017    ///
1018    /// Clears `blocked_by`/`block_reason` unconditionally, the same as
1019    /// [`Task::release`] and for the same reason its own comment already
1020    /// gives: a human choosing to hold a *blocked* task overrides its wait
1021    /// outright, the same as it overrides an ordinary hold. Without this, a
1022    /// task held straight out of [`TaskStatus::Blocked`] - the web UI's "Hold"
1023    /// button is reachable on a blocked task, same as "Mark done" - kept
1024    /// reading as still waiting on a dependency it no longer had any claim on.
1025    pub fn hold_manual(&mut self, reason: Option<String>) {
1026        self.park_reason = None;
1027        self.note_held();
1028        self.status = TaskStatus::Held;
1029        if reason.is_some() {
1030            self.hold_reason = reason;
1031        }
1032        self.hold_source = Some(HoldSource::Manual);
1033        self.blocked_by.clear();
1034        self.block_reason = None;
1035        self.blocked_from = None;
1036    }
1037
1038    /// Take this task out of the loop's reach during automatic recovery.
1039    ///
1040    /// Clears `blocked_by`/`block_reason` for the same reason
1041    /// [`Task::hold_manual`] does.
1042    pub fn hold_machine(&mut self, reason: Option<String>) {
1043        self.park_reason = None;
1044        self.note_held();
1045        self.status = TaskStatus::Held;
1046        if reason.is_some() {
1047            self.hold_reason = reason;
1048        }
1049        self.hold_source = Some(HoldSource::Machine);
1050        self.blocked_by.clear();
1051        self.block_reason = None;
1052        self.blocked_from = None;
1053    }
1054
1055    /// Block this task on other task ids and/or open question ids, chosen by
1056    /// `crate::conduct`. Pure: the caller still owns writing it back with
1057    /// [`Queue::put`].
1058    ///
1059    /// Records [`Task::blocked_from`] the first time this moves the task into
1060    /// [`TaskStatus::Blocked`], and leaves it alone on a later call that adds
1061    /// or replaces `blocked_by` while the task is already `Blocked` - a
1062    /// second question about an already-blocked task must not overwrite the
1063    /// status it should eventually return to with `Blocked` itself.
1064    pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
1065        self.stamp_schema();
1066        if self.status != TaskStatus::Blocked {
1067            self.blocked_from = Some(self.status);
1068        }
1069        self.status = TaskStatus::Blocked;
1070        self.blocked_by = blocked_by;
1071        self.block_reason = reason;
1072    }
1073
1074    /// Remove one resolved dependency (a task id that became [`TaskStatus::Done`],
1075    /// or a question id that became [`crate::ask::QuestionStatus::Answered`]).
1076    /// Once nothing is left in [`Task::blocked_by`], the task returns to
1077    /// whatever [`Task::blocked_from`] recorded - deciding *why* a task was
1078    /// blocked was `crate::conduct`'s job, but noticing a dependency resolved
1079    /// needs no model at all, and restoring the status it interrupted needs
1080    /// nothing more than what `block` already wrote down.
1081    ///
1082    /// A task blocked while `Running` restores to [`TaskStatus::Queued`]
1083    /// instead: whatever process was running it is gone by the time this
1084    /// runs, so there is nothing left to resume. A task with no recorded
1085    /// `blocked_from` - a pre-schema-4 record, or one blocked before this
1086    /// field existed - falls back to [`TaskStatus::Held`] when it still
1087    /// carries hold evidence ([`Task::hold_reason`] or [`Task::hold_source`],
1088    /// neither ever cleared by `block`), and to `Queued` otherwise: the same
1089    /// choice `block` itself would have recorded, reconstructed from what
1090    /// survived.
1091    ///
1092    /// A no-op, on purpose, for a task that is not [`TaskStatus::Blocked`]:
1093    /// `crate::daemon`'s deterministic resolver runs over every task on every
1094    /// poll, and a task that moved on for some other reason must not be
1095    /// dragged back by a stale id it still happens to carry.
1096    pub fn unblock(&mut self, resolved_id: &str) {
1097        if self.status != TaskStatus::Blocked {
1098            return;
1099        }
1100        self.blocked_by.retain(|id| id != resolved_id);
1101        self.restore_if_unblocked();
1102    }
1103
1104    /// A dependency this task waited on was deleted on purpose
1105    /// ([`Queue::remove`]): drop just that id from [`Task::blocked_by`] and,
1106    /// once nothing is left, return to the status the block interrupted, the
1107    /// same way [`Task::unblock`] does. Returns whether anything changed.
1108    ///
1109    /// Never a hold: the operator chose the deletion, so the dependent is not
1110    /// in trouble. Touches nothing but a `Blocked` task that actually names
1111    /// `deleted_id`, so a `Held` task (or a manual hold) is left exactly as it
1112    /// was. A dependency that vanished *without* going through a deletion is
1113    /// not this method's case - that stays a machine hold.
1114    pub fn dependency_deleted(&mut self, deleted_id: &str) -> bool {
1115        if self.status != TaskStatus::Blocked || !self.blocked_by.iter().any(|b| b == deleted_id) {
1116            return false;
1117        }
1118        self.blocked_by.retain(|id| id != deleted_id);
1119        self.restore_if_unblocked();
1120        true
1121    }
1122
1123    /// The shared tail of [`Task::unblock`] and [`Task::dependency_deleted`]:
1124    /// with nothing left in `blocked_by`, restore the interrupted status and
1125    /// clear the now-stale block bookkeeping.
1126    fn restore_if_unblocked(&mut self) {
1127        if self.blocked_by.is_empty() {
1128            self.status = match self.blocked_from {
1129                Some(TaskStatus::Running) => TaskStatus::Queued,
1130                Some(other) => other,
1131                None if self.hold_reason.is_some() || self.hold_source.is_some() => {
1132                    TaskStatus::Held
1133                }
1134                None => TaskStatus::Queued,
1135            };
1136            self.block_reason = None;
1137            self.blocked_from = None;
1138        }
1139    }
1140
1141    /// Record that a question `crate::conduct` asked about this task has been
1142    /// answered, so the answer's content — not just the fact that the
1143    /// question is gone — reaches the next conductor prompt and the next
1144    /// run's instruction. See [`Task::answers`].
1145    pub fn record_answer(&mut self, question: String, answer: String) {
1146        self.answers.push(AnsweredQuestion { question, answer });
1147    }
1148
1149    /// Requeue this task to reopen its last run as a review-only pass against
1150    /// `branch` (`crate::graph::Runner::review`) rather than competing from
1151    /// scratch. See [`Task::review_branch`].
1152    pub fn request_review(&mut self, branch: String) {
1153        self.release();
1154        self.review_branch = Some(branch);
1155    }
1156
1157    /// Requeue after a conductor chose a new competition. Unlike an ordinary
1158    /// operator release, this deliberately does not resume the old run.
1159    pub fn requeue(&mut self) {
1160        self.release();
1161        // A fresh competition is the opposite of a review of the old branch.
1162        self.review_branch = None;
1163        self.fresh_start = true;
1164    }
1165
1166    /// Hold this task because a takeover of `branch` was refused, keeping
1167    /// [`Task::review_branch`] so that once the operator has cleaned up and
1168    /// released it, the retry is a review of the same branch rather than a
1169    /// resume or a fresh competition. Spends no attempt.
1170    ///
1171    /// This is the only way a machine hold keeps a `review_branch`: the
1172    /// daemon takes it at the start of every attempt, so [`Task::release`]
1173    /// relies on that to tell this hold from every other.
1174    pub fn hold_for_handover(&mut self, branch: Option<String>, reason: String) {
1175        if branch.is_some() {
1176            self.review_branch = branch;
1177        }
1178        self.hold_machine(Some(reason));
1179    }
1180
1181    /// Change how urgently this task should run next.
1182    ///
1183    /// Refused once the task is `running`: priority only feeds the sort
1184    /// [`Queue::next_runnable`] does over tasks waiting to be claimed, and a
1185    /// running task has already left that pool. Accepting the write anyway
1186    /// would look like it worked while changing nothing until - and unless -
1187    /// this attempt fails and the task becomes runnable again, which is a
1188    /// surprise the phone should not hand back as a success.
1189    pub fn set_priority(&mut self, priority: i32) -> Result<()> {
1190        if self.status == TaskStatus::Running {
1191            bail!(
1192                "task {} is running; its priority cannot be changed until \
1193                 this attempt finishes",
1194                self.short()
1195            );
1196        }
1197        self.priority = priority;
1198        Ok(())
1199    }
1200
1201    /// Mark (or unmark) this task to interrupt whatever `magi serve` already
1202    /// has in flight, once `[daemon] pause_for_interrupts` is on. See
1203    /// [`Task::interrupt`].
1204    ///
1205    /// Setting it is restricted to a task the loop could pick up on its own
1206    /// right now - [`TaskStatus::runnable`] - for the same reason as
1207    /// [`Task::set_priority`]: a task already `running` has been claimed, and
1208    /// a task that is `done`, `held`, or `blocked` is not going to compete
1209    /// for the daemon's attention regardless of this flag. Unlike priority,
1210    /// this is never silently inert while `running` - it is refused outright,
1211    /// because the entire feature this flag drives (`crate::daemon`'s
1212    /// interrupt scheduler) is scoped to tasks still waiting to be claimed.
1213    /// Clearing it back to `false` carries no such risk and is always
1214    /// allowed, including on a task that moved on since it was set.
1215    pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
1216        if interrupt && !self.status.runnable() {
1217            bail!(
1218                "task {} is {}; only a queued or failed task can be marked \
1219                 to interrupt",
1220                self.short(),
1221                self.status.as_str()
1222            );
1223        }
1224        self.interrupt = interrupt;
1225        Ok(())
1226    }
1227
1228    /// Explicit implementer choice, with Solo taking precedence for old records.
1229    pub fn implementers(&self) -> Option<usize> {
1230        if self.solo {
1231            Some(1)
1232        } else {
1233            self.overrides.as_ref().and_then(|o| o.candidates)
1234        }
1235    }
1236
1237    /// Validate against this task's configuration and change only its seat count.
1238    /// Callers must hold the queue claim and reload the task before calling.
1239    pub fn change_implementers(&mut self, count: u8) -> Result<()> {
1240        self.check_implementers_editable()?;
1241        let explicit = self.overrides.as_ref().and_then(|o| o.config.as_deref());
1242        let (mut config, _) = crate::config::Config::discover(&self.repo, explicit)?;
1243        if let Some(o) = &self.overrides {
1244            o.apply(&mut config);
1245        }
1246        self.set_implementers(count, &config)
1247    }
1248
1249    fn check_implementers_editable(&self) -> Result<()> {
1250        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1251            bail!(
1252                "task {} is {}; only queued or held tasks can change implementers",
1253                self.short(),
1254                self.status.as_str()
1255            );
1256        }
1257        Ok(())
1258    }
1259
1260    /// Normalize Solo and candidate overrides without altering task history.
1261    pub fn set_implementers(&mut self, count: u8, config: &crate::config::Config) -> Result<()> {
1262        self.check_implementers_editable()?;
1263        crate::talk::check_implementers(count, config).map_err(anyhow::Error::msg)?;
1264        self.stamp_schema();
1265        self.solo = count == 1;
1266        let mut overrides = self.overrides.clone().unwrap_or_else(|| RunOverrides {
1267            inherit_daemon_options: true,
1268            ..Default::default()
1269        });
1270        overrides.candidates = (count > 1).then_some(usize::from(count));
1271        let mut settings = overrides.clone();
1272        settings.inherit_daemon_options = false;
1273        // Some(empty) is a direct-run setting too: retain its isolation on Solo.
1274        self.overrides = if overrides.inherit_daemon_options && settings == RunOverrides::default()
1275        {
1276            None
1277        } else {
1278            Some(overrides)
1279        };
1280        Ok(())
1281    }
1282
1283    /// Replace this task's title and instruction wholesale.
1284    ///
1285    /// Restricted to `queued` and `held`. A `running` task's instruction has
1286    /// already been handed to the graph, so a run in flight and the file on
1287    /// disk must not be allowed to disagree about what was asked; a `done` or
1288    /// `failed` task is a record of what actually happened and editing it
1289    /// after the fact would falsify that record. `id`, `created_at`,
1290    /// `source`, and `runs` are left untouched on purpose - an edit stands in
1291    /// for "delete and refile", and keeping the id, the timestamp, the
1292    /// attribution, and the run history is the entire reason it exists
1293    /// instead.
1294    pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
1295        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1296            bail!(
1297                "task {} is {}; only a queued or held task's instruction can \
1298                 be edited",
1299                self.short(),
1300                self.status.as_str()
1301            );
1302        }
1303        self.title = title;
1304        self.instruction = instruction;
1305        Ok(())
1306    }
1307
1308    /// Record a run that produced a pull request without merging it.
1309    ///
1310    /// The task is held rather than retried, and it costs no further attempt
1311    /// either way. The work the task asked for exists: it is sitting on a
1312    /// branch, in a pull request, waiting for CI or for a person. Retrying
1313    /// would spend the whole competition budget a second time and then race a
1314    /// second branch against the pull request the first one opened - which is
1315    /// exactly what happened to run 01c2, whose finished and green pull request
1316    /// was re-competed from scratch four seconds after it opened.
1317    ///
1318    /// A pull request nobody merged is a request for a person, not a failure.
1319    ///
1320    /// Records `why` into [`Task::hold_reason`] as well as
1321    /// [`Task::last_error`], so the notification centre and `magi task show`
1322    /// say why the task is held rather than "no reason recorded". This is
1323    /// also the path a verified no-op with an outstanding `magi ask` question
1324    /// settles through - see `daemon::settle` and `daemon::settle_and_diagnose`,
1325    /// which append the question id to `hold_reason` when one is still open
1326    /// for the run.
1327    pub fn handed_off(&mut self, why: impl Into<String>) {
1328        let why = why.into();
1329        self.diagnostic = None;
1330        self.note_held();
1331        self.status = TaskStatus::Held;
1332        self.hold_source = Some(HoldSource::Machine);
1333        self.hold_reason = Some(why.clone());
1334        self.last_error = Some(why);
1335    }
1336
1337    /// Put a held or finished task back in line, with its attempt count reset
1338    /// so a release is a real second chance rather than an instant re-hold.
1339    /// The run history is kept: attempts reset, evidence does not.
1340    pub fn release(&mut self) {
1341        let refused_handover = self.status == TaskStatus::Held
1342            && self.hold_source == Some(HoldSource::Machine)
1343            && self.review_branch.is_some();
1344        self.status = TaskStatus::Queued;
1345        self.held_at = None;
1346        self.attempts = 0;
1347        self.last_error = None;
1348        self.park_reason = None;
1349        // Otherwise the next person who holds this task reads a reason that
1350        // belonged to whatever it was waiting on last time.
1351        self.hold_reason = None;
1352        self.hold_source = None;
1353        self.diagnostic = None;
1354        // A release also un-blocks: the dependency or question `blocked_by`
1355        // named may still be unresolved, but a human (or `crate::conduct`)
1356        // choosing to release the task overrides that wait outright, the same
1357        // as it overrides an ordinary hold.
1358        self.blocked_by.clear();
1359        self.block_reason = None;
1360        self.blocked_from = None;
1361        // A machine hold that still names a review branch is a refused
1362        // handover (see [`Task::hold_for_handover`]): the retry must review
1363        // that same branch. Any other release drops it.
1364        if !refused_handover {
1365            self.review_branch = None;
1366        }
1367        self.fresh_start = false;
1368    }
1369}
1370
1371/// Copy `src` into `dir` as `name`, or `stem-2.ext`, `stem-3.ext`, ... when
1372/// that is taken. `create_new` makes the collision check atomic and also
1373/// catches a case-insensitive filesystem. Returns the stored name and path.
1374fn copy_new(dir: &Path, src: &Path, name: &str) -> Result<(String, PathBuf)> {
1375    use std::io::ErrorKind;
1376    let (stem, ext) = match name.rfind('.') {
1377        Some(i) if i > 0 => (&name[..i], &name[i..]),
1378        _ => (name, ""),
1379    };
1380    for n in 1u32.. {
1381        let candidate = if n == 1 {
1382            name.to_owned()
1383        } else {
1384            let suffix = format!("-{n}");
1385            let room = 64usize.saturating_sub(suffix.len() + ext.len());
1386            let stem: String = stem.chars().take(room).collect();
1387            format!("{stem}{suffix}{ext}")
1388        };
1389        if !crate::ask::valid_asset_name(&candidate) {
1390            bail!("no valid attachment name is left for `{name}`");
1391        }
1392        let path = dir.join(&candidate);
1393        match std::fs::OpenOptions::new()
1394            .write(true)
1395            .create_new(true)
1396            .open(&path)
1397        {
1398            Ok(mut out) => {
1399                let copied = std::fs::File::open(src)
1400                    .and_then(|mut input| std::io::copy(&mut input, &mut out));
1401                if let Err(e) = copied {
1402                    drop(out);
1403                    let _ = std::fs::remove_file(&path);
1404                    return Err(e).with_context(|| format!("copy {}", src.display()));
1405                }
1406                return Ok((candidate, path));
1407            }
1408            Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
1409            Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
1410        }
1411    }
1412    unreachable!("the counter never runs out")
1413}
1414
1415/// How long a task's write lock may stand before it is read as left behind by
1416/// a writer that died.
1417const TASK_LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
1418
1419/// Guard for [`Queue::lock_task`]; removes the lock file on drop, but only
1420/// while the file still carries this guard's owner token.
1421struct TaskLock {
1422    path: PathBuf,
1423    token: String,
1424}
1425
1426/// A token no other holder shares: pid, then a mix of entropy and a process
1427/// counter so threads born in the same nanosecond still differ.
1428fn owner_token() -> String {
1429    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1430    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1431    let mut r = crate::rng::SplitMix64::new(crate::rng::entropy() ^ n.rotate_left(32));
1432    format!("{}-{:016x}", std::process::id(), r.next_u64())
1433}
1434
1435/// The marker that serializes everyone removing the lock that carries `token`.
1436/// Hashed (FNV-1a) so an empty or foreign token is still a valid file name.
1437fn break_marker(path: &Path, token: &str) -> PathBuf {
1438    let mut h: u64 = 0xcbf29ce484222325;
1439    for b in token.bytes() {
1440        h ^= u64::from(b);
1441        h = h.wrapping_mul(0x100000001b3);
1442    }
1443    let mut name = path.as_os_str().to_owned();
1444    name.push(format!(".break-{h:016x}"));
1445    PathBuf::from(name)
1446}
1447
1448fn older_than_stale(path: &Path) -> bool {
1449    std::fs::metadata(path)
1450        .and_then(|m| m.modified())
1451        .ok()
1452        .and_then(|t| t.elapsed().ok())
1453        .is_some_and(|age| age > TASK_LOCK_STALE)
1454}
1455
1456/// How many levels of "a stale marker guarding a stale marker" are recovered
1457/// before giving up. Each level needs its own crashed remover, so the cap is
1458/// never reached without several independent crashes.
1459const MAX_MARKER_DEPTH: u8 = 3;
1460
1461/// Take the break marker for `token`, or `None` if another remover holds it.
1462/// The marker carries its own owner token. One older than [`TASK_LOCK_STALE`]
1463/// was left by a remover that died; it is cleared by the same compare-and-
1464/// remove discipline as a lock, guarded by a marker of its own, so a recoverer
1465/// that judged it stale cannot delete a fresh marker that replaced it.
1466fn take_marker(path: &Path, token: &str, depth: u8) -> Option<(PathBuf, String)> {
1467    use std::io::Write;
1468    let marker = break_marker(path, token);
1469    for _ in 0..2 {
1470        match std::fs::OpenOptions::new()
1471            .write(true)
1472            .create_new(true)
1473            .open(&marker)
1474        {
1475            Ok(mut f) => {
1476                let mine = owner_token();
1477                if f.write_all(mine.as_bytes()).is_err() {
1478                    drop(f);
1479                    let _ = std::fs::remove_file(&marker);
1480                    return None;
1481                }
1482                return Some((marker, mine));
1483            }
1484            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1485                let judged = std::fs::read_to_string(&marker).ok();
1486                match judged.filter(|_| older_than_stale(&marker)) {
1487                    Some(judged) if depth < MAX_MARKER_DEPTH => {
1488                        remove_lock_if(&marker, &judged, true, depth + 1)?;
1489                    }
1490                    _ => return None,
1491                }
1492            }
1493            Err(_) => return None,
1494        }
1495    }
1496    None
1497}
1498
1499/// Remove the lock at `path` if it still carries `token` (and, when
1500/// `require_stale`, is still stale). Removal is serialized per token by the
1501/// break marker, so a remover that judged an old lock cannot delete the fresh
1502/// one that replaced it. `None` means the marker was busy: try again.
1503fn remove_lock_if(path: &Path, token: &str, require_stale: bool, depth: u8) -> Option<bool> {
1504    let (marker, mine) = take_marker(path, token, depth)?;
1505    let still = std::fs::read_to_string(path).is_ok_and(|c| c == token)
1506        && (!require_stale || older_than_stale(path));
1507    if still {
1508        let _ = std::fs::remove_file(path);
1509    }
1510    // Release the marker only if it is still ours.
1511    if depth >= MAX_MARKER_DEPTH {
1512        if std::fs::read_to_string(&marker).is_ok_and(|c| c == mine) {
1513            let _ = std::fs::remove_file(&marker);
1514        }
1515    } else {
1516        let _ = remove_lock_if(&marker, &mine, false, depth + 1);
1517    }
1518    Some(still)
1519}
1520
1521/// Break the lock judged stale while it held `judged`. Returns whether it was
1522/// removed; a lock that has since been replaced is left alone.
1523fn break_stale(path: &Path, judged: &str) -> bool {
1524    remove_lock_if(path, judged, true, 0).unwrap_or(false)
1525}
1526
1527impl Drop for TaskLock {
1528    fn drop(&mut self) {
1529        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1530        loop {
1531            match remove_lock_if(&self.path, &self.token, false, 0) {
1532                Some(_) => return,
1533                None if std::time::Instant::now() > deadline => return,
1534                None => std::thread::sleep(std::time::Duration::from_millis(5)),
1535            }
1536        }
1537    }
1538}
1539
1540/// A queue on disk.
1541#[derive(Debug, Clone)]
1542pub struct Queue {
1543    root: PathBuf,
1544}
1545
1546impl Queue {
1547    /// The operator's queue, `<home>/queue`.
1548    pub fn open() -> Self {
1549        Self::at(crate::run::home().join("queue"))
1550    }
1551
1552    /// A queue at an explicit root. Tests use this; so could an operator who
1553    /// wants a queue per project.
1554    pub fn at(root: PathBuf) -> Self {
1555        Self { root }
1556    }
1557
1558    /// Directory holding the task files.
1559    pub fn root(&self) -> &Path {
1560        &self.root
1561    }
1562
1563    /// Path for one task id.
1564    pub fn path_of(&self, id: &str) -> PathBuf {
1565        self.root.join(format!("{id}.json"))
1566    }
1567
1568    /// Directory holding a task's attachments. A sibling of the task file,
1569    /// not a `*.json`, so [`Queue::list`] and [`Queue::revision`] never see it.
1570    pub fn attachments_dir(&self, id: &str) -> PathBuf {
1571        self.root.join(format!("{id}.attachments"))
1572    }
1573
1574    /// Copy each of `sources` into `task`'s attachment directory and record
1575    /// the stored names on `task` (persist with [`Queue::put`]). Returns the
1576    /// names, in order.
1577    ///
1578    /// Every source is checked (a readable file, a name passing
1579    /// [`crate::ask::valid_asset_name`]) before anything is copied, and a
1580    /// copy that fails part-way removes only what this call created. A name
1581    /// already taken is never overwritten: it becomes `stem-2.ext`,
1582    /// `stem-3.ext`, ... A name that does not validate is refused rather than
1583    /// sanitised - the operator renames the file.
1584    pub fn attach(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
1585        let mut wanted = Vec::new();
1586        for src in sources {
1587            let name = src
1588                .file_name()
1589                .and_then(|n| n.to_str())
1590                .with_context(|| format!("`{}` has no usable file name", src.display()))?;
1591            if !crate::ask::valid_asset_name(name) {
1592                bail!(
1593                    "attachment name `{name}` must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ \
1594                     with no `..`; rename the file and try again"
1595                );
1596            }
1597            if !src.is_file() {
1598                bail!("attachment `{}` is not a file", src.display());
1599            }
1600            wanted.push((src, name));
1601        }
1602        let dir = self.attachments_dir(&task.id);
1603        let existed = dir.is_dir();
1604        std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
1605        let mut created: Vec<PathBuf> = Vec::new();
1606        let mut names = Vec::new();
1607        let mut copy_all = || -> Result<()> {
1608            for (src, name) in &wanted {
1609                let (stored, path) = copy_new(&dir, src, name)?;
1610                created.push(path);
1611                names.push(stored);
1612            }
1613            Ok(())
1614        };
1615        if let Err(e) = copy_all() {
1616            for path in &created {
1617                let _ = std::fs::remove_file(path);
1618            }
1619            if !existed {
1620                let _ = std::fs::remove_dir(&dir);
1621            }
1622            return Err(e);
1623        }
1624        task.attachments.extend(names.iter().cloned());
1625        Ok(names)
1626    }
1627
1628    /// [`Queue::attach`] then [`Queue::put`], undoing the copies when the
1629    /// record cannot be written. Without the undo the files sit in
1630    /// `<id>.attachments/` with no task pointing at them.
1631    ///
1632    /// Only what this call copied is removed (the names `attach` returned,
1633    /// and the directory itself only when it did not exist before and is
1634    /// empty), never an attachment an earlier save recorded. `task` is rolled
1635    /// back too, and the error the caller sees is `put`'s own. This relies on
1636    /// a failed `put` leaving the stored record untouched, which its
1637    /// write-then-rename guarantees.
1638    pub fn attach_and_put(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
1639        let dir = self.attachments_dir(&task.id);
1640        let existed = dir.is_dir();
1641        let before = task.attachments.len();
1642        let names = self.attach(task, sources)?;
1643        if let Err(e) = self.put(task) {
1644            for name in &names {
1645                let _ = std::fs::remove_file(dir.join(name));
1646            }
1647            if !existed {
1648                let _ = std::fs::remove_dir(&dir);
1649            }
1650            task.attachments.truncate(before);
1651            return Err(e);
1652        }
1653        Ok(names)
1654    }
1655
1656    /// Absolute paths of `task`'s attachments, whatever shape this queue's
1657    /// root has. Absolute because the prompt hands them to an agent whose
1658    /// working directory is somewhere else entirely.
1659    pub fn attachment_paths(&self, task: &Task) -> Vec<PathBuf> {
1660        let dir = self.attachments_dir(&task.id);
1661        task.attachments
1662            .iter()
1663            .map(|n| {
1664                let p = dir.join(n);
1665                std::path::absolute(&p).unwrap_or(p)
1666            })
1667            .collect()
1668    }
1669
1670    /// Write a task, atomically, so a daemon killed mid-write leaves the
1671    /// previous state readable rather than a truncated file.
1672    ///
1673    /// Runs already recorded on disk that `task` does not carry are kept, not
1674    /// dropped: `magi run --task` links a run into a task from another
1675    /// process ([`Queue::link_run`]) while a daemon may hold a snapshot taken
1676    /// before it, and writing that snapshot back whole would erase the link.
1677    /// Nothing ever removes a run from a task, so the union loses nothing.
1678    pub fn put(&self, task: &mut Task) -> Result<()> {
1679        let _lock = self.lock_task(&task.id)?;
1680        self.put_unlocked(task)
1681    }
1682
1683    /// Write `task` only if no task with its id exists yet; `Ok(false)` when
1684    /// one does. Unlike [`Queue::put`] it never replaces a record, so a
1685    /// producer with a deterministic id cannot rewind a task that has since
1686    /// started running or finished.
1687    pub fn create_new(&self, task: &mut Task) -> Result<bool> {
1688        let _lock = self.lock_task(&task.id)?;
1689        if self.path_of(&task.id).exists() {
1690            return Ok(false);
1691        }
1692        self.put_unlocked(task)?;
1693        Ok(true)
1694    }
1695
1696    /// [`Queue::put`] for a caller already holding [`Queue::lock_task`].
1697    fn put_unlocked(&self, task: &mut Task) -> Result<()> {
1698        if let Ok(stored) = read_path(&self.path_of(&task.id)) {
1699            for run in stored.runs {
1700                if !task.runs.contains(&run) {
1701                    task.runs.push(run);
1702                }
1703            }
1704            // A completion notice already recorded is never rewound by an
1705            // older snapshot: that would report the same state twice.
1706            if let Some(n) = stored.chat_notice
1707                && task.chat_notice.as_ref().is_none_or(|mine| mine.at < n.at)
1708            {
1709                task.chat_notice = Some(n);
1710            }
1711        }
1712        task.updated_at = Timestamp::now();
1713        std::fs::create_dir_all(&self.root)
1714            .with_context(|| format!("create {}", self.root.display()))?;
1715        let body = serde_json::to_string_pretty(task).context("serialize task")?;
1716        let path = self.path_of(&task.id);
1717        let tmp = path.with_extension("json.tmp");
1718        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
1719        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
1720        // A machine hold is news for the notification centre, filed beside
1721        // this queue (`<home>/queue` -> `<home>/notifications`) rather than
1722        // through a process-global, so a queue in a temp directory notifies
1723        // into that directory.
1724        if let (Some(notice), Some(home)) = (
1725            crate::notices::task_held(task),
1726            self.root.parent().filter(|p| !p.as_os_str().is_empty()),
1727        ) {
1728            crate::notices::raise_in(home, notice);
1729        }
1730        Ok(())
1731    }
1732
1733    /// Change the task `id` in place: read it inside its write lock, let `f`
1734    /// edit it, and write it back only when `f` returns `true`. The check a
1735    /// caller makes in `f` therefore sees the record as stored now, not a
1736    /// snapshot taken before another writer got in. Returns whether it wrote.
1737    pub fn modify(&self, id: &str, f: impl FnOnce(&mut Task) -> bool) -> Result<bool> {
1738        let id = self.resolve_id(id)?;
1739        let _lock = self.lock_task(&id)?;
1740        let mut task = self.get(&id)?;
1741        if !f(&mut task) {
1742            return Ok(false);
1743        }
1744        self.put_unlocked(&mut task)?;
1745        Ok(true)
1746    }
1747
1748    /// Append `run` to the runs of the task `id` (or an unambiguous prefix),
1749    /// leaving everything else about it alone — see [`Task::link_run`]. Read
1750    /// and written back in one breath, because [`Queue::put`] replaces the
1751    /// whole record. Returns the task as stored.
1752    pub fn link_run(&self, id: &str, run: &str) -> Result<Task> {
1753        let id = self.resolve_id(id)?;
1754        // Read inside the lock, so the record written back is the current one
1755        // and a daemon's write cannot slip between the read and the write.
1756        let _lock = self.lock_task(&id)?;
1757        let mut task = self.get(&id)?;
1758        if task.link_run(run) {
1759            self.put_unlocked(&mut task)?;
1760        }
1761        Ok(task)
1762    }
1763
1764    /// Exclusive right to rewrite task `id`'s record, held until the guard
1765    /// drops. Every writer ([`Queue::put`], [`Queue::link_run`]) takes it, so
1766    /// a process linking a run and the daemon saving a transition are
1767    /// serialized instead of last-writer-wins. A lock older than
1768    /// [`TASK_LOCK_STALE`] belongs to a writer that died mid-update and is
1769    /// broken. Not the claim lock (`<id>.lock`), which a daemon holds for a
1770    /// whole run.
1771    fn lock_task(&self, id: &str) -> Result<TaskLock> {
1772        std::fs::create_dir_all(&self.root)
1773            .with_context(|| format!("create {}", self.root.display()))?;
1774        let path = self.root.join(format!("{id}.write-lock"));
1775        let started = std::time::Instant::now();
1776        loop {
1777            match std::fs::OpenOptions::new()
1778                .write(true)
1779                .create_new(true)
1780                .open(&path)
1781            {
1782                Ok(mut file) => {
1783                    use std::io::Write;
1784                    let token = owner_token();
1785                    if let Err(e) = file.write_all(token.as_bytes()) {
1786                        drop(file);
1787                        let _ = std::fs::remove_file(&path);
1788                        return Err(e).with_context(|| format!("lock {}", path.display()));
1789                    }
1790                    return Ok(TaskLock { path, token });
1791                }
1792                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1793                    // Content first, then age: a lock replaced in between
1794                    // reads fresh, never stale.
1795                    let judged = std::fs::read_to_string(&path).ok();
1796                    let broken = judged
1797                        .filter(|_| older_than_stale(&path))
1798                        .is_some_and(|judged| break_stale(&path, &judged));
1799                    // A break that does not happen (marker busy, recovery
1800                    // exhausted) is a wait like any other, so it meets the
1801                    // same deadline instead of spinning forever.
1802                    if !broken {
1803                        if started.elapsed() > TASK_LOCK_STALE {
1804                            bail!("could not lock task {id}");
1805                        }
1806                        std::thread::sleep(std::time::Duration::from_millis(15));
1807                    }
1808                }
1809                // On Windows, `create_new` on a file whose delete is still
1810                // pending fails with ERROR_ACCESS_DENIED, not `AlreadyExists`.
1811                // Treat it as a race and retry until the deadline.
1812                Err(e)
1813                    if e.kind() == std::io::ErrorKind::PermissionDenied
1814                        && started.elapsed() <= TASK_LOCK_STALE =>
1815                {
1816                    std::thread::sleep(std::time::Duration::from_millis(15));
1817                }
1818                Err(e) => return Err(e).with_context(|| format!("lock {}", path.display())),
1819            }
1820        }
1821    }
1822
1823    /// Load a task by id or unambiguous id prefix.
1824    pub fn get(&self, id: &str) -> Result<Task> {
1825        let resolved = self.resolve_id(id)?;
1826        read_path(&self.path_of(&resolved))
1827    }
1828
1829    /// Remove a task, and the claim lock that belongs to it.
1830    ///
1831    /// `in_flight` comes from the caller — a live daemon's heartbeat naming
1832    /// this task — because the task's own `running` status cannot answer the
1833    /// question. A daemon killed mid-competition leaves the status at
1834    /// `running` and an orphaned `.lock` behind, and a guard that trusted
1835    /// either would make the task undeletable for good: the phone showed
1836    /// exactly that, refusing a task whose daemon had been gone for an hour.
1837    ///
1838    /// So the lock is removed with the task rather than respected. Any lock
1839    /// still there once no live daemon claims the task is by definition stale,
1840    /// and leaving it would make a deleted task look claimed to
1841    /// [`Queue::claim`] and to whoever reads the directory.
1842    ///
1843    /// A deletion is deliberate, so whatever was `blocked` on the id is
1844    /// resolved, not held: a `<id>.removed` tombstone is written first (it
1845    /// is what tells [`missing_blockers`] and the daemon's resolver that this
1846    /// id was deleted on purpose, so a pass that races this call cannot
1847    /// mistake it for a vanished dependency), the record is deleted (the
1848    /// tombstone is taken back if that fails), and then each dependent is
1849    /// rewritten under its claim - see [`Queue::release_dependents_of`].
1850    /// Best-effort for the dependents: one claimed by something else right
1851    /// now, or whose write fails, is released silently by
1852    /// `crate::daemon::resolve_blockers` (or `crate::triage::run_once`) on
1853    /// its next pass, because the tombstone is still there. It does not fail
1854    /// this removal.
1855    ///
1856    /// `questions` is the store [`missing_blockers`] checks a `blocked_by` id
1857    /// against - the same store the caller already resolves `id`'s own home
1858    /// from, passed in rather than reopened here so a test queue at an
1859    /// explicit root never reads the operator's real questions directory.
1860    pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
1861        let _ = questions;
1862        let resolved = self.resolve_id(id)?;
1863        if in_flight {
1864            bail!("task {resolved} is being run by a live daemon right now");
1865        }
1866        self.write_tombstone(&resolved)?;
1867        if let Err(e) = self.remove_record_with_attachments(&resolved, |p| std::fs::remove_file(p))
1868        {
1869            // A record that is still there was not deleted: take the marker
1870            // back. One already gone keeps it, so the deletion still reads
1871            // as deliberate.
1872            if self.path_of(&resolved).exists() {
1873                let _ = std::fs::remove_file(self.tombstone_path(&resolved));
1874            }
1875            return Err(e);
1876        }
1877        let (released, still_blocked) = self.release_dependents_of(&resolved);
1878        Ok(Removal {
1879            id: resolved,
1880            released,
1881            still_blocked,
1882        })
1883    }
1884
1885    /// Path of the marker saying task `id` was deleted on purpose. Not a
1886    /// `*.json`, so [`Queue::list`] and [`Queue::revision`] never see it.
1887    fn tombstone_path(&self, id: &str) -> PathBuf {
1888        self.root.join(format!("{id}.removed"))
1889    }
1890
1891    fn write_tombstone(&self, id: &str) -> Result<()> {
1892        let path = self.tombstone_path(id);
1893        let tmp = self.root.join(format!("{id}.removed.tmp"));
1894        std::fs::write(&tmp, b"")
1895            .and_then(|()| std::fs::rename(&tmp, &path))
1896            .with_context(|| format!("write {}", path.display()))
1897    }
1898
1899    /// Was `id` deleted through [`Queue::remove`] (and is its record gone)?
1900    /// A live record always wins: a tombstone never makes an existing task
1901    /// look deleted.
1902    fn was_deleted(&self, id: &str) -> bool {
1903        self.tombstone_path(id).is_file() && !self.path_of(id).exists()
1904    }
1905
1906    /// Apply [`Task::dependency_deleted`] to `task` for every `blocked_by` id
1907    /// that [`Queue::remove`] deleted. Returns those ids. The caller holds
1908    /// the task's claim and saves it.
1909    pub fn apply_deleted_blockers(&self, task: &mut Task) -> Vec<String> {
1910        let deleted = deleted_blockers(self, &task.blocked_by);
1911        deleted
1912            .into_iter()
1913            .filter(|id| task.dependency_deleted(id))
1914            .collect()
1915    }
1916
1917    /// Record, as a fixed-wording info notice, that `dependent` stopped
1918    /// waiting on the deleted task `deleted`. Best-effort and beside the
1919    /// queue, like the hold notice [`Queue::put`] files.
1920    pub fn note_dependency_deleted(&self, dependent: &Task, deleted: &str) {
1921        let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty()) else {
1922            return;
1923        };
1924        let ja = crate::lang::is_japanese(&crate::lang::of_repo(&dependent.repo));
1925        let waiting = dependent.status == TaskStatus::Blocked;
1926        let message = match (ja, waiting) {
1927            (true, false) => format!(
1928                "タスク {} は、待っていた {} が削除されたため待機を解除し、元の状態に戻しました",
1929                dependent.short(),
1930                short(deleted)
1931            ),
1932            (true, true) => format!(
1933                "タスク {} は、待っていた {} が削除されたため、残りの依存を待っています",
1934                dependent.short(),
1935                short(deleted)
1936            ),
1937            (false, false) => format!(
1938                "Task {} stopped waiting on {} because it was deleted, and returned to its previous state",
1939                dependent.short(),
1940                short(deleted)
1941            ),
1942            (false, true) => format!(
1943                "Task {} stopped waiting on {} because it was deleted, and is still waiting on its other dependencies",
1944                dependent.short(),
1945                short(deleted)
1946            ),
1947        };
1948        crate::notices::raise_in(
1949            home,
1950            crate::notices::Notice::info(
1951                &format!("unblocked:{}:{}", dependent.id, deleted),
1952                message,
1953            )
1954            .link(crate::notices::Link::Task {
1955                id: dependent.id.clone(),
1956            }),
1957        );
1958    }
1959
1960    /// Resolve every `blocked` task naming `dependency` (just deleted) with
1961    /// [`Task::dependency_deleted`]. Returns the ids released to another
1962    /// status and those that remain `blocked` on something else. Each
1963    /// dependent is re-read inside its claim, never written from a stale copy.
1964    fn release_dependents_of(&self, dependency: &str) -> (Vec<String>, Vec<String>) {
1965        let (mut released, mut still_blocked) = (Vec::new(), Vec::new());
1966        for listed in self.list() {
1967            if listed.status != TaskStatus::Blocked
1968                || !listed.blocked_by.iter().any(|b| b == dependency)
1969            {
1970                continue;
1971            }
1972            let Ok(_claim) = self.claim(&listed.id) else {
1973                continue;
1974            };
1975            let Ok(mut task) = self.get(&listed.id) else {
1976                continue;
1977            };
1978            if !task.dependency_deleted(dependency) {
1979                continue;
1980            }
1981            if self.put(&mut task).is_err() {
1982                continue;
1983            }
1984            self.note_dependency_deleted(&task, dependency);
1985            if task.status == TaskStatus::Blocked {
1986                still_blocked.push(task.id.clone());
1987            } else {
1988                released.push(task.id.clone());
1989            }
1990        }
1991        (released, still_blocked)
1992    }
1993
1994    /// The attachment-and-record half of [`Queue::remove`]. `remove_record` is
1995    /// how the record file is deleted; production passes `remove_file`, and a
1996    /// test passes a failing one to prove the attachments come back.
1997    fn remove_record_with_attachments(
1998        &self,
1999        resolved: &str,
2000        remove_record: impl FnOnce(&Path) -> std::io::Result<()>,
2001    ) -> Result<()> {
2002        // Neither step may strand the other. The attachments are set aside by a
2003        // rename (cheap, and undone if the record cannot be removed), then the
2004        // record goes, and only then is the set-aside directory deleted for
2005        // real. A failure before the record is gone loses nothing; a failure
2006        // after it leaves at most an orphan `.removing` directory, which the
2007        // next removal sweeps.
2008        self.sweep_removed_attachments();
2009        let attachments = self.attachments_dir(resolved);
2010        let aside = self.root.join(format!("{resolved}.attachments.removing"));
2011        let moved = match std::fs::rename(&attachments, &aside) {
2012            Ok(()) => true,
2013            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
2014            Err(e) => {
2015                return Err(e).with_context(|| format!("remove {}", attachments.display()));
2016            }
2017        };
2018        let path = self.path_of(resolved);
2019        if let Err(e) = remove_record(&path) {
2020            if moved {
2021                let _ = std::fs::rename(&aside, &attachments);
2022            }
2023            return Err(e).with_context(|| format!("remove {}", path.display()));
2024        }
2025        if moved {
2026            if let Err(e) = std::fs::remove_dir_all(&aside) {
2027                tracing::warn!("leftover attachments {}: {e}", aside.display());
2028            }
2029        }
2030        let lock = self.lock_path(resolved);
2031        if let Err(e) = std::fs::remove_file(&lock) {
2032            if e.kind() != std::io::ErrorKind::NotFound {
2033                return Err(e).with_context(|| format!("remove {}", lock.display()));
2034            }
2035        }
2036        Ok(())
2037    }
2038
2039    /// Delete `*.attachments.removing` directories a previous [`Queue::remove`]
2040    /// could not finish deleting. The task record is already gone by then, so
2041    /// the id no longer resolves and the removal cannot be retried by name;
2042    /// every later removal sweeps them instead. A directory whose record still
2043    /// exists belongs to a removal in progress and is left alone. Best-effort.
2044    fn sweep_removed_attachments(&self) {
2045        let Ok(entries) = std::fs::read_dir(&self.root) else {
2046            return;
2047        };
2048        for entry in entries.flatten() {
2049            let name = entry.file_name();
2050            let name = name.to_string_lossy();
2051            let Some(id) = name.strip_suffix(".attachments.removing") else {
2052                continue;
2053            };
2054            // A record still on disk means a removal is mid-flight (or about
2055            // to roll back): the directory is its to delete or restore.
2056            if !self.path_of(id).exists() {
2057                if let Err(e) = std::fs::remove_dir_all(entry.path()) {
2058                    tracing::warn!("leftover attachments {}: {e}", entry.path().display());
2059                }
2060            }
2061        }
2062        self.sweep_tombstones();
2063    }
2064
2065    /// Delete `*.removed` markers that are no longer needed: the record is
2066    /// gone, no task's `blocked_by` names the id, and the marker is older than
2067    /// [`TOMBSTONE_GRACE`]. The age is what makes this safe against a
2068    /// concurrent writer: the task list read here can be stale, so a dependent
2069    /// that was just blocked on an id (a conductor applying a decision while a
2070    /// removal is under way) would otherwise lose the marker and be held as a
2071    /// vanished dependency. A marker whose record still exists belongs to a
2072    /// removal in progress and is never touched.
2073    fn sweep_tombstones(&self) {
2074        let Ok(entries) = std::fs::read_dir(&self.root) else {
2075            return;
2076        };
2077        let tasks = self.list();
2078        for entry in entries.flatten() {
2079            let name = entry.file_name();
2080            let name = name.to_string_lossy();
2081            let Some(id) = name.strip_suffix(".removed") else {
2082                continue;
2083            };
2084            if self.path_of(id).exists()
2085                || tasks.iter().any(|t| t.blocked_by.iter().any(|b| b == id))
2086            {
2087                continue;
2088            }
2089            let old = entry
2090                .metadata()
2091                .and_then(|m| m.modified())
2092                .ok()
2093                .and_then(|m| m.elapsed().ok())
2094                .is_some_and(|age| age >= TOMBSTONE_GRACE);
2095            if old {
2096                let _ = std::fs::remove_file(entry.path());
2097            }
2098        }
2099    }
2100
2101    /// Path of the claim lock for a task. One definition, so `claim` and
2102    /// `remove` cannot end up naming different files.
2103    fn lock_path(&self, id: &str) -> PathBuf {
2104        self.root.join(format!("{id}.lock"))
2105    }
2106
2107    /// Every task on disk, highest priority first and newest first within a
2108    /// priority. This is what `magi task list` and `GET /api/queue` print, so
2109    /// a raised priority has to move a task here the moment it is saved, not
2110    /// only in [`Queue::next_runnable`]'s own ordering - the operator reading
2111    /// the backlog and the loop about to drain it must agree on what "first"
2112    /// means. Every existing task defaults to priority 0, so this is a no-op
2113    /// change from the old newest-first order for a queue nobody has
2114    /// reprioritised.
2115    ///
2116    /// Unreadable files are skipped rather than fatal: one corrupt task must
2117    /// not take the queue - or the web UI, or an unattended daemon - down
2118    /// with it.
2119    pub fn list(&self) -> Vec<Task> {
2120        let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
2121            .into_iter()
2122            .flatten()
2123            .flatten()
2124            .map(|e| e.path())
2125            .filter(|p| p.extension().is_some_and(|x| x == "json"))
2126            .filter_map(|p| read_path(&p).ok())
2127            .collect();
2128        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
2129        tasks
2130    }
2131
2132    /// Runs that a later attempt at the same task replaced, mapped to the id
2133    /// of the attempt that replaced them.
2134    ///
2135    /// A task keeps its attempts in order, and the deck showed them as two
2136    /// cards with the same title and no hint which was which: yukimemi asked
2137    /// why `stalled` and `blocked` appeared twice for one task, and the
2138    /// answer - "those are two tries, and the second one exists because of a
2139    /// bug since fixed" - was not on the screen anywhere.
2140    ///
2141    /// Read from the queue rather than stored on the run, because the
2142    /// ordering is the queue's fact: a `RunState` has no idea another attempt
2143    /// happened after it.
2144    pub fn superseded(&self) -> HashMap<String, String> {
2145        let mut by = HashMap::new();
2146        for task in self.list() {
2147            for earlier in &task.runs {
2148                if let Some(later) = task.successor_of(earlier) {
2149                    by.insert(earlier.clone(), later.clone());
2150                }
2151            }
2152        }
2153        by
2154    }
2155
2156    /// Whether `run` is an earlier attempt a later one replaced, and if so
2157    /// the id of that later attempt.
2158    ///
2159    /// Same walk as [`Queue::superseded`], narrowed to one run: a run detail
2160    /// page asks about exactly one run at a time, and this keeps that call
2161    /// site from building (and discarding) the whole map's `HashMap` just to
2162    /// read one entry out of it.
2163    pub fn superseded_by(&self, run: &str) -> Option<String> {
2164        for task in self.list() {
2165            if task.runs.iter().any(|r| r == run) {
2166                return task.successor_of(run).cloned();
2167            }
2168        }
2169        None
2170    }
2171
2172    /// The task's own most recent attempt, when `run` belongs to that task
2173    /// but is not already that attempt.
2174    ///
2175    /// Distinct from [`Queue::superseded_by`], which names only the very
2176    /// next attempt: a chain of retries (A superseded by B superseded by C)
2177    /// leaves an older run pointing at an intermediate one that may itself
2178    /// be unresolved, and a run's own detail page needs to know where the
2179    /// task's story currently stands - the chain's current head, C - not an
2180    /// attempt in the middle of it that a client would otherwise have to
2181    /// walk to by hand.
2182    pub fn latest_attempt(&self, run: &str) -> Option<String> {
2183        for task in self.list() {
2184            if task.runs.iter().any(|r| r == run) {
2185                return task.runs.last().filter(|last| **last != run).cloned();
2186            }
2187        }
2188        None
2189    }
2190
2191    /// The task a daemon should run next, or `None` when the queue is idle.
2192    ///
2193    /// Highest priority first, oldest first within a priority, so a burst of
2194    /// agent-filed work cannot starve the task a human filed this morning.
2195    pub fn next_runnable(&self) -> Option<Task> {
2196        let mut runnable: Vec<Task> = self
2197            .list()
2198            .into_iter()
2199            .filter(|t| t.status.runnable())
2200            .collect();
2201        runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
2202        runnable.into_iter().next()
2203    }
2204
2205    /// Take exclusive ownership of a task.
2206    ///
2207    /// The lock is a `create_new` file next to the task, which is atomic on
2208    /// every platform magi targets. It exists so two daemons - or a daemon and
2209    /// a human running `magi run` - cannot drive one task into two competing
2210    /// runs. The returned guard releases on drop, including on panic.
2211    pub fn claim(&self, id: &str) -> Result<Claim> {
2212        std::fs::create_dir_all(&self.root)
2213            .with_context(|| format!("create {}", self.root.display()))?;
2214        let path = self.lock_path(id);
2215        match std::fs::OpenOptions::new()
2216            .write(true)
2217            .create_new(true)
2218            .open(&path)
2219        {
2220            Ok(mut f) => {
2221                use std::io::Write as _;
2222                // Best effort: the pid is for the human looking at a stale lock.
2223                let _ = writeln!(f, "{}", std::process::id());
2224                Ok(Claim { path })
2225            }
2226            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2227                bail!("task {id} is already claimed ({} exists)", path.display())
2228            }
2229            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
2230        }
2231    }
2232
2233    /// Expand an id prefix to exactly one task id.
2234    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
2235        if self.path_of(prefix).is_file() {
2236            return Ok(prefix.to_owned());
2237        }
2238        let hits: Vec<String> = self
2239            .list()
2240            .into_iter()
2241            .map(|t| t.id)
2242            .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
2243            .collect();
2244        match hits.len() {
2245            1 => Ok(hits.into_iter().next().expect("exactly one hit")),
2246            0 => bail!("no task matches `{prefix}`"),
2247            _ => bail!(
2248                "`{prefix}` matches {} tasks: {}",
2249                hits.len(),
2250                hits.join(", ")
2251            ),
2252        }
2253    }
2254
2255    /// Change detection token for the queue.
2256    ///
2257    /// Combines file names and modification times of all task files in the
2258    /// queue, so adding, modifying, or deleting any task — even an older one —
2259    /// moves the revision and notifies connected clients via the change stream.
2260    /// Returns 0 when the queue is completely empty.
2261    pub fn revision(&self) -> u64 {
2262        self.revision_excluding(&std::collections::BTreeSet::new())
2263    }
2264
2265    /// [`Queue::revision`] ignoring the files of the named task ids, for a
2266    /// caller that tracks those tasks' content some other way.
2267    pub fn revision_excluding(&self, skip: &std::collections::BTreeSet<String>) -> u64 {
2268        use std::hash::{Hash as _, Hasher as _};
2269
2270        let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
2271            .into_iter()
2272            .flatten()
2273            .flatten()
2274            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
2275            .filter(|e| {
2276                let path = e.path();
2277                !path
2278                    .file_stem()
2279                    .is_some_and(|stem| skip.contains(stem.to_string_lossy().as_ref()))
2280            })
2281            .filter_map(|e| {
2282                let name = e.file_name().to_string_lossy().into_owned();
2283                let mtime = e
2284                    .metadata()
2285                    .ok()?
2286                    .modified()
2287                    .ok()?
2288                    .duration_since(std::time::UNIX_EPOCH)
2289                    .ok()?
2290                    .as_millis() as u64;
2291                Some((name, mtime))
2292            })
2293            .collect();
2294
2295        if entries.is_empty() {
2296            return 0;
2297        }
2298
2299        entries.sort_unstable();
2300        let mut hasher = std::hash::DefaultHasher::new();
2301        for (name, mtime) in &entries {
2302            name.hash(&mut hasher);
2303            mtime.hash(&mut hasher);
2304        }
2305        let h = hasher.finish();
2306        if h == 0 { 1 } else { h }
2307    }
2308}
2309
2310/// How long a deletion marker is kept after its record is gone, even with
2311/// nothing referencing it. See [`Queue::sweep_tombstones`].
2312const TOMBSTONE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
2313
2314/// What [`Queue::remove`] did, beyond deleting the named task's own file.
2315#[derive(Debug, Clone)]
2316pub struct Removal {
2317    /// The id actually removed - `id` expanded from a prefix, if it was one.
2318    pub id: String,
2319    /// Every task that was `blocked` on [`Removal::id`] and left the block
2320    /// (back to the status it interrupted) because that was its only
2321    /// remaining dependency.
2322    pub released: Vec<String>,
2323    /// Every task that named [`Removal::id`] and is still `blocked` on
2324    /// another dependency.
2325    pub still_blocked: Vec<String>,
2326}
2327
2328/// Exclusive ownership of a task, released on drop.
2329#[derive(Debug)]
2330pub struct Claim {
2331    path: PathBuf,
2332}
2333
2334impl Drop for Claim {
2335    fn drop(&mut self) {
2336        let _ = std::fs::remove_file(&self.path);
2337    }
2338}
2339
2340/// The first line of a task, trimmed to a title. Used when the caller gives a
2341/// body but no title, which is the normal case for an agent piping a file in.
2342pub fn title_from(instruction: &str, max: usize) -> String {
2343    let Some(line) = first_line(instruction) else {
2344        return "(empty task)".to_owned();
2345    };
2346    if line.chars().count() <= max {
2347        return line.to_owned();
2348    }
2349    let head: String = line.chars().take(max.saturating_sub(1)).collect();
2350    format!("{head}…")
2351}
2352
2353/// The first non-blank line, whatever it is. A markdown heading is the
2354/// task's own summary - agents pipe in `# Rework the config loader` and mean
2355/// exactly that - so it is preferred over the prose beneath it rather than
2356/// skipped as decoration. Leading list and heading markers are stripped
2357/// because they are syntax, not words. `None` when nothing is left.
2358pub(crate) fn first_line(instruction: &str) -> Option<&str> {
2359    let line = instruction
2360        .lines()
2361        .map(str::trim)
2362        .find(|l| !l.is_empty())?
2363        .trim_start_matches(['#', '-', '*', '>', ' '])
2364        .trim();
2365    (!line.is_empty()).then_some(line)
2366}
2367
2368fn read_path(path: &Path) -> Result<Task> {
2369    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
2370    let task: Task =
2371        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2372    // Greater-than, not not-equal: every field added since schema 1 carries
2373    // `#[serde(default)]`, so an older task has nothing to say about it and
2374    // defaulting is exactly as good a reading as a value that build never had
2375    // a chance to write. Only a schema *ahead* of this build - a meaning it
2376    // cannot possibly know - is refused rather than guessed at.
2377    if task.schema > SCHEMA {
2378        bail!(
2379            "task {} was written by a different magi (schema {}, this build \
2380             speaks {SCHEMA})",
2381            task.id,
2382            task.schema
2383        );
2384    }
2385    Ok(task)
2386}
2387
2388/// Ids inside a `blocked_by` list that name neither an existing task file nor
2389/// an existing question file - a dependency deleted (`magi task rm`, or by
2390/// hand) while something was still waiting on it.
2391///
2392/// Existence is decided by [`Queue::path_of`]/[`Questions::path_of`]
2393/// `is_file()` alone, never by [`Queue::get`]/[`Questions::get`] succeeding:
2394/// those also fail on a merely unreadable file - mid-write, corrupt, or from
2395/// a schema ahead of this build (see [`read_path`]) - and misreading "cannot
2396/// read it right now" as "it was deleted" would quarantine a task over a
2397/// transient failure. `blocked_by` always carries a full id, written by
2398/// `crate::conduct` or `crate::triage` from a real task's or question's own
2399/// `id`/`short`, never a prefix a caller typed - so the exact-path check is
2400/// complete on its own, with no [`Queue::resolve_id`] fallback needed.
2401pub fn missing_blockers(
2402    queue: &Queue,
2403    questions: &Questions,
2404    blocked_by: &[String],
2405) -> Vec<String> {
2406    blocked_by
2407        .iter()
2408        .filter(|id| {
2409            !queue.path_of(id).is_file()
2410                && !questions.path_of(id).is_file()
2411                && !queue.was_deleted(id)
2412        })
2413        .cloned()
2414        .collect()
2415}
2416
2417/// Ids in `blocked_by` that [`Queue::remove`] deleted on purpose: tombstoned
2418/// and with no record. A live task is never included, tombstone or not. These
2419/// are resolved with [`Task::dependency_deleted`], not held; an id that
2420/// vanished without a tombstone stays [`missing_blockers`]' to report.
2421pub fn deleted_blockers(queue: &Queue, blocked_by: &[String]) -> Vec<String> {
2422    blocked_by
2423        .iter()
2424        .filter(|id| queue.was_deleted(id))
2425        .cloned()
2426        .collect()
2427}
2428
2429/// The `hold_reason` text for a task quarantined because one or more of its
2430/// `blocked_by` ids no longer exist. Shared by `crate::daemon::resolve_blockers`,
2431/// `crate::triage::run_once`, and [`Queue::remove`]'s own dependent
2432/// quarantine, so the three call sites read as the same event to an operator
2433/// looking at `magi task show` rather than three different wordings for it.
2434///
2435/// Names the full original `blocked_by` list, not just `missing` - a task
2436/// quarantined here can also have named a dependency that was still
2437/// perfectly valid, and [`Task::hold_machine`] clears `blocked_by` on the way
2438/// in, so this text is the only place that information survives for an
2439/// operator deciding whether to release the task outright.
2440pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
2441    missing_blocker_hold_reason_in(blocked_by, missing, "en")
2442}
2443
2444/// [`missing_blocker_hold_reason`] in `language` (Japanese, else English).
2445pub fn missing_blocker_hold_reason_in(
2446    blocked_by: &[String],
2447    missing: &[String],
2448    language: &str,
2449) -> String {
2450    if crate::lang::is_japanese(language) {
2451        format!(
2452            "{} を待っていましたが、{} はディスク上に存在しません - `magi task triage` を参照",
2453            blocked_by.join(", "),
2454            missing.join(", "),
2455        )
2456    } else {
2457        format!(
2458            "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
2459            blocked_by.join(", "),
2460            missing.join(", "),
2461        )
2462    }
2463}
2464
2465/// The last dash-separated part of an id, the form people say aloud.
2466pub fn short(id: &str) -> &str {
2467    id.split('-').next_back().unwrap_or(id)
2468}
2469
2470fn new_id() -> String {
2471    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
2472    let seed = crate::rng::entropy();
2473    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
2474}
2475
2476#[cfg(test)]
2477mod tests {
2478    #[test]
2479    fn merge_over_lets_the_later_choice_win_and_keeps_the_rest() {
2480        let mut base = RunOverrides {
2481            merge: Some("pr".to_owned()),
2482            candidates: Some(3),
2483            ..RunOverrides::default()
2484        };
2485        base.merge_over(&RunOverrides {
2486            merge: Some("none".to_owned()),
2487            seed: Some(7),
2488            ..RunOverrides::default()
2489        });
2490        assert_eq!(base.merge.as_deref(), Some("none"));
2491        assert_eq!(base.candidates, Some(3));
2492        assert_eq!(base.seed, Some(7));
2493    }
2494
2495    #[test]
2496    fn an_already_landed_task_is_done_with_its_attempt_refunded() {
2497        let mut t = task("relanded");
2498        t.attempts = 1;
2499        t.status = TaskStatus::Running;
2500        t.already_landed("already in main as 0e368de");
2501        assert_eq!(t.status, TaskStatus::Done);
2502        assert_eq!(t.attempts, 0);
2503        assert!(t.hold_reason.is_none());
2504        assert_eq!(t.last_error.as_deref(), Some("already in main as 0e368de"));
2505    }
2506
2507    #[test]
2508    fn missing_blocker_reason_follows_the_language() {
2509        let b = vec!["a".to_owned()];
2510        let en = missing_blocker_hold_reason_in(&b, &b, "en");
2511        assert_eq!(en, missing_blocker_hold_reason(&b, &b));
2512        assert!(en.starts_with("blocked on a"));
2513        assert!(missing_blocker_hold_reason_in(&b, &b, "ja").contains("存在しません"));
2514        assert_eq!(missing_blocker_hold_reason_in(&b, &b, "de"), en);
2515    }
2516
2517    use super::*;
2518
2519    #[test]
2520    fn triage_applied_survives_release_and_old_records_read_as_empty() {
2521        let mut t = Task::new(
2522            "t".to_owned(),
2523            "i".to_owned(),
2524            PathBuf::from("r"),
2525            Source::Human,
2526        );
2527        t.mark_triage_applied("q1");
2528        t.mark_triage_applied("q1");
2529        t.hold_machine(Some("x".to_owned()));
2530        t.release();
2531        assert_eq!(t.triage_applied, ["q1"]);
2532        assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
2533
2534        let mut v = serde_json::to_value(&t).unwrap();
2535        v.as_object_mut().unwrap().remove("triage_applied");
2536        let old: Task = serde_json::from_value(v).unwrap();
2537        assert!(old.triage_applied.is_empty());
2538    }
2539
2540    #[test]
2541    fn task_counts_of_empty_is_all_zero() {
2542        assert_eq!(TaskCounts::of(&[]), TaskCounts::default());
2543    }
2544
2545    #[test]
2546    fn task_counts_of_tallies_every_status() {
2547        let mut queued = Task::new(
2548            "q".to_owned(),
2549            "i".to_owned(),
2550            PathBuf::from("."),
2551            Source::Human,
2552        );
2553        queued.status = TaskStatus::Queued;
2554        let mut running = queued.clone();
2555        running.status = TaskStatus::Running;
2556        let mut done = queued.clone();
2557        done.status = TaskStatus::Done;
2558        let mut failed = queued.clone();
2559        failed.status = TaskStatus::Failed;
2560        let mut held = queued.clone();
2561        held.status = TaskStatus::Held;
2562        let mut blocked = queued.clone();
2563        blocked.status = TaskStatus::Blocked;
2564
2565        let mut parked = queued.clone();
2566        parked.status = TaskStatus::Parked;
2567
2568        let counts = TaskCounts::of(&[
2569            queued,
2570            running,
2571            done.clone(),
2572            done,
2573            failed,
2574            held,
2575            blocked,
2576            parked,
2577        ]);
2578        assert_eq!(
2579            counts,
2580            TaskCounts {
2581                queued: 1,
2582                running: 1,
2583                done: 2,
2584                failed: 1,
2585                held: 1,
2586                blocked: 1,
2587                parked: 1,
2588            }
2589        );
2590    }
2591
2592    #[test]
2593    fn task_implementers_persist_normalized_without_losing_history() {
2594        let (_dir, q) = queue();
2595        let mut t = task("keep me");
2596        t.hold_manual(Some("waiting".into()));
2597        t.runs.push("earlier-run".into());
2598        t.attachments.push("reference.txt".into());
2599        let cfg: crate::config::Config =
2600            toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
2601                .unwrap();
2602        q.put(&mut t).unwrap();
2603        let original = serde_json::to_value(&t).unwrap();
2604        for n in [2, 1, 2] {
2605            t.set_implementers(n, &cfg).unwrap();
2606            q.put(&mut t).unwrap();
2607            t = q.get(&t.id).unwrap();
2608            assert_eq!(t.implementers(), Some(usize::from(n)));
2609            assert_eq!(t.solo, n == 1);
2610            if n == 1 {
2611                assert!(t.overrides.is_none());
2612            }
2613            let mut actual = serde_json::to_value(&t).unwrap();
2614            for field in ["solo", "overrides", "updated_at"] {
2615                actual[field] = original[field].clone();
2616            }
2617            assert_eq!(actual, original);
2618        }
2619        t.overrides.as_mut().unwrap().judges = Some(2);
2620        t.set_implementers(1, &cfg).unwrap();
2621        assert_eq!(t.overrides.as_ref().unwrap().judges, Some(2));
2622        assert_eq!(t.overrides.as_ref().unwrap().candidates, None);
2623    }
2624
2625    #[test]
2626    fn task_implementers_old_records_keep_inheritance_until_changed() {
2627        let mut value = serde_json::to_value(task("old task")).unwrap();
2628        value.as_object_mut().unwrap().remove("solo");
2629        value.as_object_mut().unwrap().remove("overrides");
2630        let mut old: Task = serde_json::from_value(value).unwrap();
2631        assert_eq!(old.implementers(), None);
2632        old.set_implementers(1, &crate::config::Config::default())
2633            .unwrap();
2634        assert!(old.solo);
2635        assert!(old.overrides.is_none());
2636    }
2637
2638    #[test]
2639    fn task_implementers_reject_invalid_counts_and_states() {
2640        let cfg = crate::config::Config::default();
2641        let mut t = task("unchanged");
2642        for n in [0, 4, 255] {
2643            assert!(t.set_implementers(n, &cfg).is_err());
2644        }
2645        for status in [
2646            TaskStatus::Running,
2647            TaskStatus::Done,
2648            TaskStatus::Failed,
2649            TaskStatus::Blocked,
2650            TaskStatus::Parked,
2651        ] {
2652            t.status = status;
2653            let before = serde_json::to_value(&t).unwrap();
2654            assert!(t.set_implementers(1, &cfg).is_err());
2655            assert_eq!(serde_json::to_value(&t).unwrap(), before);
2656        }
2657    }
2658
2659    /// A queue of its own, with no process-global state - which is the point of
2660    /// `Queue::at`, and why these can run in parallel.
2661    fn queue() -> (tempfile::TempDir, Queue) {
2662        let dir = tempfile::tempdir().unwrap();
2663        let q = Queue::at(dir.path().join("queue"));
2664        (dir, q)
2665    }
2666
2667    #[test]
2668    fn putting_a_machine_held_task_files_a_notification_beside_the_queue() {
2669        let dir = tempfile::tempdir().unwrap();
2670        let q = Queue::at(dir.path().join("queue"));
2671        let mut t = task("held");
2672        q.put(&mut t).unwrap();
2673        assert_eq!(
2674            crate::notices::Notices::at(dir.path().join("notifications"))
2675                .list()
2676                .len(),
2677            0
2678        );
2679        t.hold_machine(Some("out of attempts".to_owned()));
2680        q.put(&mut t).unwrap();
2681        let listed = crate::notices::Notices::at(dir.path().join("notifications")).list();
2682        assert_eq!(listed.len(), 1);
2683        assert!(listed[0].message.contains("out of attempts"));
2684    }
2685
2686    fn task(title: &str) -> Task {
2687        Task::new(
2688            title.to_owned(),
2689            format!("do {title}"),
2690            PathBuf::from("."),
2691            Source::Human,
2692        )
2693    }
2694
2695    #[test]
2696    fn earlier_attempts_is_every_recorded_run_and_agrees_with_the_display() {
2697        let mut t = task("retried");
2698        assert!(
2699            t.earlier_attempts().is_empty(),
2700            "a first attempt takes nothing over"
2701        );
2702        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
2703        assert_eq!(t.earlier_attempts(), ["aaaa", "bbbb"]);
2704        // What the web note shows and what a takeover acts on are one rule.
2705        assert_eq!(t.successor_of("aaaa"), Some(&"bbbb".to_owned()));
2706        assert_eq!(t.successor_of("bbbb"), None);
2707        assert_eq!(t.successor_of("zzzz"), None);
2708    }
2709
2710    #[test]
2711    fn superseded_by_names_the_next_attempt_and_none_for_the_last() {
2712        let (_dir, q) = queue();
2713        let mut t = task("retried");
2714        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
2715        q.put(&mut t).unwrap();
2716
2717        assert_eq!(q.superseded_by("aaaa"), Some("bbbb".to_owned()));
2718        assert_eq!(q.superseded_by("bbbb"), Some("cccc".to_owned()));
2719        assert_eq!(
2720            q.superseded_by("cccc"),
2721            None,
2722            "the latest attempt replaces nothing"
2723        );
2724        assert_eq!(
2725            q.superseded_by("never-heard-of-it"),
2726            None,
2727            "a run belonging to no task on this queue is not superseded"
2728        );
2729
2730        let mut by = HashMap::new();
2731        by.insert("aaaa".to_owned(), "bbbb".to_owned());
2732        by.insert("bbbb".to_owned(), "cccc".to_owned());
2733        assert_eq!(
2734            q.superseded(),
2735            by,
2736            "the whole-map and single-run forms must agree"
2737        );
2738    }
2739
2740    #[test]
2741    fn superseded_attempts_is_empty_until_the_task_is_done() {
2742        let mut t = task("retried");
2743        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
2744        t.status = TaskStatus::Failed;
2745        assert_eq!(
2746            t.superseded_attempts(true),
2747            &[] as &[String],
2748            "a task still retrying has no attempt yet that a later one made moot"
2749        );
2750
2751        t.status = TaskStatus::Running;
2752        assert_eq!(t.superseded_attempts(true), &[] as &[String]);
2753    }
2754
2755    #[test]
2756    fn superseded_attempts_names_every_run_before_the_one_that_succeeded() {
2757        let mut t = task("retried");
2758        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
2759        t.status = TaskStatus::Done;
2760        assert_eq!(
2761            t.superseded_attempts(true),
2762            &["aaaa".to_owned(), "bbbb".to_owned()],
2763            "cccc is the attempt whose success made the task done, and stays out"
2764        );
2765    }
2766
2767    #[test]
2768    fn superseded_attempts_is_empty_for_a_done_task_with_only_one_attempt() {
2769        let mut t = task("first try landed");
2770        t.runs = vec!["aaaa".to_owned()];
2771        t.status = TaskStatus::Done;
2772        assert_eq!(t.superseded_attempts(true), &[] as &[String]);
2773    }
2774
2775    #[test]
2776    fn superseded_attempts_is_empty_when_the_last_run_never_actually_succeeded() {
2777        // `succeed` is reachable directly - `magi task done`, its web
2778        // equivalent, and the conductor's `Recovery::Done` - on a task in
2779        // any status, including one whose last recorded attempt is itself
2780        // `Blocked`/`Failed`/anything but `Merged`/`Ready`. `runs.last()`
2781        // alone cannot tell that apart from the loop's own settle path, so
2782        // the caller's own read of that run's status is what decides this.
2783        let mut t = task("closed by hand after a manual merge");
2784        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
2785        t.status = TaskStatus::Done;
2786        assert_eq!(
2787            t.superseded_attempts(false),
2788            &[] as &[String],
2789            "nothing here is provably why the task is done, so nothing is superseded"
2790        );
2791    }
2792
2793    #[test]
2794    fn latest_attempt_names_the_chain_s_current_head_not_just_the_next_one() {
2795        let (_dir, q) = queue();
2796        let mut t = task("retried twice");
2797        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
2798        q.put(&mut t).unwrap();
2799
2800        assert_eq!(
2801            q.latest_attempt("aaaa"),
2802            Some("cccc".to_owned()),
2803            "an old attempt points straight at the chain's current head, not the \
2804             next attempt in the middle of it"
2805        );
2806        assert_eq!(q.latest_attempt("bbbb"), Some("cccc".to_owned()));
2807        assert_eq!(
2808            q.latest_attempt("cccc"),
2809            None,
2810            "the latest attempt is not superseded by anything"
2811        );
2812        assert_eq!(
2813            q.latest_attempt("never-heard-of-it"),
2814            None,
2815            "a run belonging to no task on this queue is not superseded"
2816        );
2817    }
2818
2819    #[test]
2820    fn a_markdown_heading_is_the_title_not_decoration() {
2821        // A task file's heading is the summary its author already wrote, so it
2822        // beats the prose underneath. Getting this backwards was visible in the
2823        // first smoke test: a task titled "# Rework the config loader" listed
2824        // as "It re-reads the file on every lookup".
2825        assert_eq!(
2826            title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
2827            "Rework the config loader"
2828        );
2829        assert_eq!(title_from("- fix the thing", 40), "fix the thing");
2830        assert_eq!(title_from("> quoted task", 40), "quoted task");
2831        // Nothing usable at all still has to produce something printable.
2832        assert_eq!(title_from("   \n\n", 40), "(empty task)");
2833        assert_eq!(title_from("###\n", 40), "(empty task)");
2834    }
2835
2836    #[test]
2837    fn a_long_title_is_elided_by_characters_not_bytes() {
2838        // Byte truncation would split a multi-byte character and panic.
2839        let long = "課題".repeat(30);
2840        let title = title_from(&long, 10);
2841        assert_eq!(title.chars().count(), 10);
2842        assert!(title.ends_with('…'));
2843    }
2844
2845    #[test]
2846    fn priority_wins_and_ties_break_oldest_first() {
2847        let (_dir, q) = queue();
2848        let mut a = task("first");
2849        let mut b = task("second");
2850        let mut c = task("urgent");
2851        // Ids carry a timestamp, so force a known order.
2852        a.id = "20260101-000001-aaaa".to_owned();
2853        b.id = "20260101-000002-bbbb".to_owned();
2854        c.id = "20260101-000003-cccc".to_owned();
2855        c.priority = 5;
2856        for t in [&mut a, &mut b, &mut c] {
2857            q.put(t).unwrap();
2858        }
2859
2860        // Priority first...
2861        assert_eq!(q.next_runnable().unwrap().id, c.id);
2862        c.hold_machine(None);
2863        q.put(&mut c).unwrap();
2864        // ...then oldest, so a burst of new work cannot starve older work.
2865        assert_eq!(q.next_runnable().unwrap().id, a.id);
2866        assert_eq!(q.list().len(), 3, "b is still waiting its turn");
2867    }
2868
2869    #[test]
2870    fn a_blocked_task_never_starves_another_runnable_one() {
2871        let (_dir, q) = queue();
2872        let mut blocked = task("blocked");
2873        blocked.block(vec!["something".to_owned()], None);
2874        q.put(&mut blocked).unwrap();
2875
2876        let mut runnable = task("free to go");
2877        q.put(&mut runnable).unwrap();
2878
2879        let next = q.next_runnable().expect("a runnable task is still offered");
2880        assert_eq!(next.id, runnable.id);
2881    }
2882
2883    #[test]
2884    fn a_held_task_is_never_offered_to_the_loop() {
2885        let (_dir, q) = queue();
2886        let mut t = task("held");
2887        q.put(&mut t).unwrap();
2888        assert!(q.next_runnable().is_some());
2889
2890        t.hold_machine(None);
2891        q.put(&mut t).unwrap();
2892        assert!(
2893            q.next_runnable().is_none(),
2894            "a held task must wait for a human"
2895        );
2896
2897        // A failed task, by contrast, is exactly what the loop should retry.
2898        t.status = TaskStatus::Failed;
2899        q.put(&mut t).unwrap();
2900        assert!(q.next_runnable().is_some());
2901    }
2902
2903    #[test]
2904    fn attempts_are_capped_and_then_the_task_is_held() {
2905        let mut t = task("doomed");
2906
2907        t.start("run-1".to_owned());
2908        t.fail("gate red", 2);
2909        assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
2910
2911        t.start("run-2".to_owned());
2912        t.fail("gate red", 2);
2913        assert_eq!(
2914            t.status,
2915            TaskStatus::Held,
2916            "out of attempts: stop spending money on it"
2917        );
2918        assert_eq!(t.runs, ["run-1", "run-2"]);
2919        assert_eq!(t.last_error.as_deref(), Some("gate red"));
2920        assert_eq!(
2921            t.hold_reason.as_deref(),
2922            Some("gate red"),
2923            "the hold must say why, not leave hold_reason null next to a \
2924             populated last_error"
2925        );
2926    }
2927
2928    #[test]
2929    fn handing_off_a_task_records_a_hold_reason_too() {
2930        let mut t = task("left a pull request");
2931        t.start("run-1".to_owned());
2932        t.handed_off("run ended with a pull request open [run run-1]");
2933        assert_eq!(t.status, TaskStatus::Held);
2934        assert_eq!(t.hold_source, Some(HoldSource::Machine));
2935        assert_eq!(
2936            t.hold_reason.as_deref(),
2937            Some("run ended with a pull request open [run run-1]")
2938        );
2939        assert_eq!(t.hold_reason, t.last_error);
2940    }
2941
2942    #[test]
2943    fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
2944        let mut t = task("stalled by quota");
2945
2946        t.start("run-1".to_owned());
2947        assert_eq!(t.attempts, 1);
2948        t.stall("judge-1, judge-2 out of quota");
2949        assert_eq!(
2950            t.attempts, 0,
2951            "a closed quota window must not spend the task's retry budget"
2952        );
2953        assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
2954        assert_eq!(
2955            t.last_error.as_deref(),
2956            Some("judge-1, judge-2 out of quota")
2957        );
2958
2959        // A task can therefore stall all night and still get its real attempts
2960        // once the quota resets - which is the whole point.
2961        for _ in 0..20 {
2962            t.start("run-n".to_owned());
2963            t.stall("still out of quota");
2964        }
2965        t.start("run-real".to_owned());
2966        t.fail("gate red", 2);
2967        assert_eq!(
2968            t.status,
2969            TaskStatus::Failed,
2970            "the first attempt that was really judged is attempt one"
2971        );
2972    }
2973
2974    #[test]
2975    fn releasing_a_held_task_gives_it_a_real_second_chance() {
2976        let mut t = task("retry me");
2977        t.start("run-1".to_owned());
2978        t.fail("gate red", 1);
2979        assert_eq!(t.status, TaskStatus::Held);
2980
2981        t.release();
2982        assert_eq!(t.status, TaskStatus::Queued);
2983        // Without resetting attempts the next failure would re-hold at once,
2984        // and a release would be a no-op the operator cannot see.
2985        assert_eq!(t.attempts, 0);
2986        assert!(t.last_error.is_none());
2987        assert_eq!(
2988            t.runs.len(),
2989            1,
2990            "history is kept: attempts reset, evidence does not"
2991        );
2992    }
2993
2994    #[test]
2995    fn a_hold_reason_survives_and_a_release_clears_it() {
2996        let mut t = task("waiting on something else");
2997        t.hold_manual(Some(
2998            "waiting for 20260101-000000-aaaa to land first".to_owned(),
2999        ));
3000        assert_eq!(t.status, TaskStatus::Held);
3001        assert_eq!(
3002            t.hold_reason.as_deref(),
3003            Some("waiting for 20260101-000000-aaaa to land first")
3004        );
3005
3006        // Holding again with no reason must not erase the one already there.
3007        t.hold_manual(None);
3008        assert_eq!(
3009            t.hold_reason.as_deref(),
3010            Some("waiting for 20260101-000000-aaaa to land first"),
3011            "a bare re-hold keeps whatever a human already wrote down"
3012        );
3013
3014        // A hold with no reason at all is still an ordinary, allowed hold.
3015        let mut plain = task("no reason given");
3016        plain.hold_manual(None);
3017        assert_eq!(plain.status, TaskStatus::Held);
3018        assert!(plain.hold_reason.is_none());
3019
3020        t.release();
3021        assert_eq!(t.status, TaskStatus::Queued);
3022        assert!(
3023            t.hold_reason.is_none(),
3024            "a stale reason must not greet the next person who holds this task"
3025        );
3026    }
3027
3028    #[test]
3029    fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
3030        // `done` can close a held task directly - neither `magi task done`
3031        // nor `POST /api/queue/{id}/done` requires a release first - so a
3032        // task held for "waiting on 3ed9" and then closed without ever being
3033        // released must not still read as waiting on it afterwards.
3034        let mut t = task("landed by hand while held");
3035        t.hold_manual(Some("waiting on 3ed9".to_owned()));
3036        assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
3037
3038        t.succeed();
3039        assert_eq!(t.status, TaskStatus::Done);
3040        assert!(
3041            t.hold_reason.is_none(),
3042            "a done task cannot still be waiting on something"
3043        );
3044    }
3045
3046    #[test]
3047    fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
3048        // The web UI's "Hold" and "Mark done" buttons are both reachable on a
3049        // `blocked` task, not just on `queued`/`held` ones - neither requires
3050        // a release first. A task moved off `Blocked` that way must not still
3051        // carry the dependency it was waiting on: a dependency graph built
3052        // from `blocked_by` would otherwise keep drawing an edge for a task
3053        // that is not blocked on anything any more.
3054        let mut held = task("held straight out of blocked");
3055        held.block(
3056            vec!["20260101-000000-dead".to_owned()],
3057            Some("waiting on the migration script".to_owned()),
3058        );
3059        assert_eq!(held.status, TaskStatus::Blocked);
3060
3061        held.hold_manual(None);
3062        assert_eq!(held.status, TaskStatus::Held);
3063        assert!(
3064            held.blocked_by.is_empty(),
3065            "hold overrides the wait, same as release"
3066        );
3067        assert!(held.block_reason.is_none());
3068
3069        let mut done = task("closed straight out of blocked");
3070        done.block(
3071            vec!["20260101-000000-dead".to_owned()],
3072            Some("waiting on the migration script".to_owned()),
3073        );
3074        done.succeed();
3075        assert_eq!(done.status, TaskStatus::Done);
3076        assert!(
3077            done.blocked_by.is_empty(),
3078            "a done task cannot still be waiting on a dependency"
3079        );
3080        assert!(done.block_reason.is_none());
3081    }
3082
3083    #[test]
3084    fn a_blocked_task_is_never_offered_to_the_loop() {
3085        let mut t = task("blocked");
3086        assert!(t.status.runnable());
3087        t.block(
3088            vec!["dep-id".to_owned()],
3089            Some("waits on dep-id".to_owned()),
3090        );
3091        assert_eq!(t.status, TaskStatus::Blocked);
3092        assert!(!t.status.runnable());
3093        assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
3094    }
3095
3096    #[test]
3097    fn unblocking_the_last_dependency_returns_the_task_to_queued() {
3098        let mut t = task("blocked on two");
3099        t.block(
3100            vec!["a".to_owned(), "b".to_owned()],
3101            Some("waits on a and b".to_owned()),
3102        );
3103
3104        t.unblock("a");
3105        assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
3106        assert_eq!(t.blocked_by, ["b"]);
3107
3108        t.unblock("b");
3109        assert_eq!(t.status, TaskStatus::Queued);
3110        assert!(t.blocked_by.is_empty());
3111        assert!(t.block_reason.is_none());
3112    }
3113
3114    #[test]
3115    fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
3116        let mut t = task("never blocked");
3117        t.unblock("whatever");
3118        assert_eq!(t.status, TaskStatus::Queued);
3119    }
3120
3121    #[test]
3122    fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
3123        // The bug this guards: a task an operator (or `crate::triage`) has
3124        // deliberately held, once `crate::conduct` blocks it on a follow-up
3125        // question, must not silently re-enter the competition queue the
3126        // moment that question is answered - whatever the answer said.
3127        let mut t = task("held, then asked about");
3128        t.hold_machine(Some("out of attempts".to_owned()));
3129        assert_eq!(t.status, TaskStatus::Held);
3130
3131        t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
3132        assert_eq!(t.status, TaskStatus::Blocked);
3133
3134        t.record_answer("what now?".to_owned(), "leave it held".to_owned());
3135        t.unblock("q1");
3136        assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
3137        assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
3138        assert_eq!(t.hold_source, Some(HoldSource::Machine));
3139        assert!(t.blocked_from.is_none(), "consumed once restored");
3140    }
3141
3142    #[test]
3143    fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
3144        let mut t = task("manually held, then asked about");
3145        t.hold_manual(Some("waiting on a dependency".to_owned()));
3146
3147        t.block(vec!["q1".to_owned()], None);
3148        t.unblock("q1");
3149
3150        assert_eq!(t.status, TaskStatus::Held);
3151        assert_eq!(t.hold_source, Some(HoldSource::Manual));
3152    }
3153
3154    #[test]
3155    fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
3156        // A second `Task::block` call - `crate::conduct` adding a question on
3157        // top of an existing block - must not overwrite `blocked_from` with
3158        // `Blocked` itself, or the task would restore into itself.
3159        let mut t = task("held, blocked twice");
3160        t.hold_machine(None);
3161        t.block(vec!["q1".to_owned()], Some("first".to_owned()));
3162        t.block(
3163            vec!["q1".to_owned(), "q2".to_owned()],
3164            Some("second".to_owned()),
3165        );
3166
3167        t.unblock("q1");
3168        assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
3169        t.unblock("q2");
3170        assert_eq!(t.status, TaskStatus::Held);
3171    }
3172
3173    #[test]
3174    fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
3175        // Whatever process was driving the run is gone by the time a
3176        // conductor's question about it gets answered - there is nothing left
3177        // to resume into.
3178        let mut t = task("blocked mid-run");
3179        t.start("run-1".to_owned());
3180        assert_eq!(t.status, TaskStatus::Running);
3181
3182        t.block(vec!["q1".to_owned()], None);
3183        t.unblock("q1");
3184        assert_eq!(t.status, TaskStatus::Queued);
3185    }
3186
3187    #[test]
3188    fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
3189        // `blocked_from` is `None` for a record written before schema 4 (or,
3190        // equivalently, deserialized straight from an on-disk file that never
3191        // had the field). Held evidence surviving on the task - never cleared
3192        // by `block` - is the only way left to tell such a record apart from
3193        // one blocked straight out of `Queued`.
3194        let mut t = task("legacy record, held before it was blocked");
3195        t.hold_source = Some(HoldSource::Machine);
3196        t.hold_reason = Some("legacy hold reason".to_owned());
3197        t.status = TaskStatus::Blocked;
3198        t.blocked_by = vec!["q1".to_owned()];
3199        t.blocked_from = None;
3200
3201        t.unblock("q1");
3202        assert_eq!(t.status, TaskStatus::Held);
3203    }
3204
3205    #[test]
3206    fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
3207        let mut t = task("legacy record, ordinary dependency block");
3208        t.status = TaskStatus::Blocked;
3209        t.blocked_by = vec!["dep".to_owned()];
3210        t.blocked_from = None;
3211
3212        t.unblock("dep");
3213        assert_eq!(t.status, TaskStatus::Queued);
3214    }
3215
3216    #[test]
3217    fn answering_a_question_is_recorded_and_survives_a_release() {
3218        let mut t = task("asked something");
3219        t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
3220        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
3221        t.unblock("q1");
3222        assert_eq!(t.status, TaskStatus::Queued);
3223        assert_eq!(t.answers.len(), 1);
3224        assert_eq!(t.answers[0].answer, "SQLite");
3225
3226        // A release resets attempts, not evidence - the same rule
3227        // `releasing_a_held_task_gives_it_a_real_second_chance` asserts for
3228        // `runs`.
3229        t.release();
3230        assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
3231    }
3232
3233    #[test]
3234    fn a_refused_handover_keeps_the_review_branch_across_release() {
3235        let mut t = task("refused takeover");
3236        t.start("run-1".to_owned());
3237        t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
3238        assert_eq!(t.status, TaskStatus::Held);
3239        assert_eq!(t.attempts, 1);
3240        t.release();
3241        assert_eq!(t.status, TaskStatus::Queued);
3242        assert_eq!(t.attempts, 0);
3243        assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3244
3245        // A fresh competition chosen afterwards is not a review.
3246        t.hold_for_handover(None, "again".to_owned());
3247        t.requeue();
3248        assert!(t.review_branch.is_none());
3249
3250        // A manual hold never keeps one.
3251        let mut m = task("manual");
3252        m.review_branch = Some("magi/x/A".to_owned());
3253        m.hold_manual(None);
3254        m.release();
3255        assert!(m.review_branch.is_none());
3256    }
3257
3258    #[test]
3259    fn requesting_review_requeues_the_task_and_remembers_the_branch() {
3260        let mut t = task("blocked run with a surviving branch");
3261        t.start("run-1".to_owned());
3262        t.fail("blocked with major findings", 5);
3263        assert_eq!(t.status, TaskStatus::Failed);
3264
3265        t.request_review("magi/eba2/A".to_owned());
3266        assert_eq!(t.status, TaskStatus::Queued);
3267        assert_eq!(t.attempts, 0);
3268        assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3269
3270        // An ordinary release (a human overriding the choice) drops it again.
3271        t.release();
3272        assert!(t.review_branch.is_none());
3273    }
3274
3275    #[test]
3276    fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
3277        let mut t = task("retry");
3278        t.start("run-1".to_owned());
3279        t.requeue();
3280        assert!(t.fresh_start);
3281
3282        t.release();
3283        assert!(!t.fresh_start);
3284    }
3285
3286    #[test]
3287    fn priority_can_be_changed_while_queued_but_not_while_running() {
3288        let mut t = task("reprioritise me");
3289        t.set_priority(5).unwrap();
3290        assert_eq!(t.priority, 5);
3291
3292        t.start("run-1".to_owned());
3293        let err = t.set_priority(9).unwrap_err().to_string();
3294        assert!(err.contains("running"), "{err}");
3295        assert_eq!(t.priority, 5, "the rejected write must not partially apply");
3296    }
3297
3298    #[test]
3299    fn interrupt_can_be_marked_while_queued_but_not_while_running() {
3300        let mut t = task("interrupt me");
3301        assert!(!t.interrupt, "off unless asked, same as any other task");
3302
3303        t.set_interrupt(true).unwrap();
3304        assert!(t.interrupt);
3305
3306        t.start("run-1".to_owned());
3307        assert!(
3308            !t.interrupt,
3309            "the mark is one-shot: dispatching the task fulfils it, \
3310             whatever the run that follows ends up doing"
3311        );
3312        let err = t.set_interrupt(true).unwrap_err().to_string();
3313        assert!(err.contains("running"), "{err}");
3314        // Clearing is always allowed, even on a running task - there is
3315        // nothing left for it to interrupt once it has been claimed.
3316        t.set_interrupt(false).unwrap();
3317        assert!(!t.interrupt);
3318    }
3319
3320    /// R2-1-1: a task whose run fails and requeues must not go on
3321    /// re-triggering `crate::daemon`'s interrupt scheduler on every later
3322    /// boundary, attempt after attempt, until it exhausts its budget.
3323    #[test]
3324    fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
3325        let mut t = task("interrupt me");
3326        t.set_interrupt(true).unwrap();
3327        t.start("run-1".to_owned());
3328        t.fail("mock failure", 5);
3329        assert_eq!(t.status, TaskStatus::Failed);
3330        assert!(
3331            !t.interrupt,
3332            "one attempt already spent the mark; a retry is an ordinary \
3333             requeue, not a fresh interrupt request"
3334        );
3335    }
3336
3337    #[test]
3338    fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
3339        let (_dir, q) = queue();
3340        let mut a = task("first filed");
3341        let mut b = task("second filed");
3342        a.id = "20260101-000001-aaaa".to_owned();
3343        b.id = "20260101-000002-bbbb".to_owned();
3344        q.put(&mut a).unwrap();
3345        q.put(&mut b).unwrap();
3346
3347        assert_eq!(
3348            q.next_runnable().unwrap().id,
3349            a.id,
3350            "with equal priority the older task goes first, so a burst of \
3351             new work cannot starve it"
3352        );
3353        assert_eq!(
3354            q.list()[0].id,
3355            b.id,
3356            "but the list an operator reads is newest first, the same as \
3357             before priority existed - a's turn to run does not make it the \
3358             newest task"
3359        );
3360
3361        let mut a = q.get(&a.id).unwrap();
3362        a.set_priority(10).unwrap();
3363        q.put(&mut a).unwrap();
3364
3365        assert_eq!(
3366            q.next_runnable().unwrap().id,
3367            a.id,
3368            "a raised priority must be reflected the moment it is saved"
3369        );
3370        // `magi task list` and `GET /api/queue` both print `Queue::list()`
3371        // directly, so the raised task has to lead there too - not only in
3372        // what the loop would claim next.
3373        assert_eq!(
3374            q.list()[0].id,
3375            a.id,
3376            "the raised task must sort first in the list an operator reads, \
3377             not only in next_runnable's own ordering"
3378        );
3379    }
3380
3381    #[test]
3382    fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
3383        let mut t = Task::new(
3384            "old title".to_owned(),
3385            "old instruction".to_owned(),
3386            PathBuf::from("/repo"),
3387            Source::Agent {
3388                run: "20260101-000000-beef".to_owned(),
3389                node: "implement".to_owned(),
3390            },
3391        );
3392        let id = t.id.clone();
3393        let created_at = t.created_at;
3394        t.runs.push("20260101-000000-beef".to_owned());
3395
3396        t.edit("new title".to_owned(), "new instruction".to_owned())
3397            .unwrap();
3398
3399        assert_eq!(t.title, "new title");
3400        assert_eq!(t.instruction, "new instruction");
3401        assert_eq!(t.id, id, "editing must not mint a new id");
3402        assert_eq!(t.created_at, created_at);
3403        assert_eq!(
3404            t.source,
3405            Source::Agent {
3406                run: "20260101-000000-beef".to_owned(),
3407                node: "implement".to_owned(),
3408            },
3409            "editing must not turn agent attribution into human"
3410        );
3411        assert_eq!(t.runs, ["20260101-000000-beef"]);
3412    }
3413
3414    #[test]
3415    fn editing_is_refused_once_a_task_is_running_or_finished() {
3416        let mut running = task("in flight");
3417        running.start("run-1".to_owned());
3418        let err = running
3419            .edit("x".to_owned(), "y".to_owned())
3420            .unwrap_err()
3421            .to_string();
3422        assert!(err.contains("running"), "{err}");
3423
3424        let mut done = task("finished");
3425        done.succeed();
3426        let err = done
3427            .edit("x".to_owned(), "y".to_owned())
3428            .unwrap_err()
3429            .to_string();
3430        assert!(err.contains("done"), "{err}");
3431
3432        // Both queued and held are the point of the feature and must work.
3433        let mut queued = task("waiting");
3434        queued.edit("x".to_owned(), "y".to_owned()).unwrap();
3435        let mut held = task("parked");
3436        held.hold_machine(None);
3437        held.edit("x".to_owned(), "y".to_owned()).unwrap();
3438    }
3439
3440    #[test]
3441    fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
3442        let (_dir, q) = queue();
3443        let path = q.path_of("20260101-000000-aaaa");
3444        std::fs::create_dir_all(q.root()).unwrap();
3445        std::fs::write(
3446            &path,
3447            serde_json::json!({
3448                "schema": SCHEMA,
3449                "id": "20260101-000000-aaaa",
3450                "title": "from before hold reasons existed",
3451                "instruction": "from before hold reasons existed",
3452                "repo": ".",
3453                "source": { "kind": "human" },
3454                "status": "held",
3455                "created_at": Timestamp::now().to_string(),
3456                "updated_at": Timestamp::now().to_string(),
3457            })
3458            .to_string(),
3459        )
3460        .unwrap();
3461
3462        let task = q.get("20260101-000000-aaaa").expect("must still read");
3463        assert!(task.hold_reason.is_none());
3464        assert!(task.operator_held());
3465    }
3466
3467    #[test]
3468    fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
3469        let (_dir, q) = queue();
3470        let path = q.path_of("20260101-000000-bbbb");
3471        std::fs::create_dir_all(q.root()).unwrap();
3472        std::fs::write(
3473            &path,
3474            serde_json::json!({
3475                "schema": 2,
3476                "id": "20260101-000000-bbbb",
3477                "title": "old manual recovery",
3478                "instruction": "old manual recovery",
3479                "repo": ".",
3480                "source": { "kind": "human" },
3481                "status": "held",
3482                "hold_reason": "active manual recovery run20260912-224242-daf5",
3483                "created_at": Timestamp::now().to_string(),
3484                "updated_at": Timestamp::now().to_string(),
3485            })
3486            .to_string(),
3487        )
3488        .unwrap();
3489
3490        let task = q.get("20260101-000000-bbbb").expect("must still read");
3491        assert_eq!(task.hold_source, None);
3492        assert!(task.operator_held());
3493    }
3494
3495    #[test]
3496    fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
3497        let (_dir, q) = queue();
3498        let path = q.path_of("20260101-000000-aaaa");
3499        std::fs::create_dir_all(q.root()).unwrap();
3500        std::fs::write(
3501            &path,
3502            serde_json::json!({
3503                "schema": SCHEMA,
3504                "id": "20260101-000000-aaaa",
3505                "title": "from before diagnostics existed",
3506                "instruction": "from before diagnostics existed",
3507                "repo": ".",
3508                "source": { "kind": "human" },
3509                "status": "held",
3510                "created_at": Timestamp::now().to_string(),
3511                "updated_at": Timestamp::now().to_string(),
3512            })
3513            .to_string(),
3514        )
3515        .unwrap();
3516
3517        let task = q.get("20260101-000000-aaaa").expect("must still read");
3518        assert!(task.diagnostic.is_none());
3519    }
3520
3521    #[test]
3522    fn a_schema_1_task_with_no_blocking_fields_still_reads() {
3523        // Written by a build that predates `blocked_by`, `block_reason`,
3524        // `answers` and `review_branch` entirely - literal `"schema": 1`,
3525        // not `SCHEMA`, since the whole point is a build older than this one.
3526        let (_dir, q) = queue();
3527        let path = q.path_of("20260101-000000-aaaa");
3528        std::fs::create_dir_all(q.root()).unwrap();
3529        std::fs::write(
3530            &path,
3531            serde_json::json!({
3532                "schema": 1,
3533                "id": "20260101-000000-aaaa",
3534                "title": "from before blocking existed",
3535                "instruction": "from before blocking existed",
3536                "repo": ".",
3537                "source": { "kind": "human" },
3538                "status": "queued",
3539                "created_at": Timestamp::now().to_string(),
3540                "updated_at": Timestamp::now().to_string(),
3541            })
3542            .to_string(),
3543        )
3544        .unwrap();
3545
3546        let task = q.get("20260101-000000-aaaa").expect("must still read");
3547        assert!(task.blocked_by.is_empty());
3548        assert!(task.block_reason.is_none());
3549        assert!(task.answers.is_empty());
3550        assert!(task.review_branch.is_none());
3551    }
3552
3553    #[test]
3554    fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
3555        // A diagnostic belongs to the run that produced it. Left in place
3556        // across a release, an unrelated later failure - a config error, say -
3557        // would go on showing evidence for a problem that is no longer why the
3558        // task is stuck.
3559        let mut held = task("diagnosed");
3560        held.start("run-1".to_owned());
3561        held.fail("gate red", 1);
3562        held.diagnostic = Some("cargo test failed: ...".to_owned());
3563        assert_eq!(held.status, TaskStatus::Held);
3564
3565        held.release();
3566        assert!(held.diagnostic.is_none());
3567
3568        held.diagnostic = Some("cargo test failed: ...".to_owned());
3569        held.succeed();
3570        assert!(held.diagnostic.is_none());
3571    }
3572
3573    #[test]
3574    fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
3575        let mut t = task("retried");
3576        t.start("run-1".to_owned());
3577        t.diagnostic = Some("stale evidence from a previous hold".to_owned());
3578        t.fail("unrelated config error", 5);
3579        assert_eq!(t.status, TaskStatus::Failed);
3580        assert!(
3581            t.diagnostic.is_none(),
3582            "fail() must not let an old diagnostic outlive the run that produced it"
3583        );
3584    }
3585
3586    #[test]
3587    fn a_claim_is_exclusive_and_releases_on_drop() {
3588        let (_dir, q) = queue();
3589        let mut t = task("contended");
3590        q.put(&mut t).unwrap();
3591
3592        let held = q.claim(&t.id).unwrap();
3593        assert!(
3594            q.claim(&t.id).is_err(),
3595            "two daemons must not drive one task into two runs"
3596        );
3597        drop(held);
3598        assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
3599    }
3600
3601    #[test]
3602    fn a_round_trip_survives_disk() {
3603        let (_dir, q) = queue();
3604        let mut t = Task::new(
3605            "titled".to_owned(),
3606            "body".to_owned(),
3607            PathBuf::from("/repo"),
3608            Source::Agent {
3609                run: "20260101-000000-beef".to_owned(),
3610                node: "implement".to_owned(),
3611            },
3612        );
3613        t.priority = 3;
3614        q.put(&mut t).unwrap();
3615
3616        let back = q.get(&t.id).unwrap();
3617        assert_eq!(back.id, t.id);
3618        assert_eq!(back.priority, 3);
3619        assert_eq!(back.source.label(), "implement@beef");
3620        // A prefix is enough, the way run ids work everywhere else.
3621        assert_eq!(q.get(t.short()).unwrap().id, t.id);
3622    }
3623
3624    #[test]
3625    fn an_unreadable_task_does_not_take_the_queue_down() {
3626        let (_dir, q) = queue();
3627        let mut t = task("fine");
3628        q.put(&mut t).unwrap();
3629        std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
3630
3631        let listed = q.list();
3632        assert_eq!(listed.len(), 1, "the readable task still lists");
3633        assert_eq!(listed[0].id, t.id);
3634    }
3635
3636    #[test]
3637    fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
3638        let (_dir, q) = queue();
3639        let path = q.path_of("20260101-000000-aaaa");
3640        std::fs::create_dir_all(q.root()).unwrap();
3641        std::fs::write(
3642            &path,
3643            serde_json::json!({
3644                "schema": SCHEMA,
3645                "id": "20260101-000000-aaaa",
3646                "title": "from before solo existed",
3647                "instruction": "from before solo existed",
3648                "repo": ".",
3649                "source": { "kind": "human" },
3650                "status": "queued",
3651                "created_at": Timestamp::now().to_string(),
3652                "updated_at": Timestamp::now().to_string(),
3653            })
3654            .to_string(),
3655        )
3656        .unwrap();
3657
3658        let task = q.get("20260101-000000-aaaa").expect("must still read");
3659        assert!(!task.solo, "a queue file with no `solo` field means false");
3660    }
3661
3662    #[test]
3663    fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
3664        let (_dir, q) = queue();
3665        let path = q.path_of("20260101-000000-bbbb");
3666        std::fs::create_dir_all(q.root()).unwrap();
3667        std::fs::write(
3668            &path,
3669            serde_json::json!({
3670                "schema": SCHEMA,
3671                "id": "20260101-000000-bbbb",
3672                "title": "from before urgent existed",
3673                "instruction": "from before urgent existed",
3674                "repo": ".",
3675                "source": { "kind": "human" },
3676                "status": "queued",
3677                "created_at": Timestamp::now().to_string(),
3678                "updated_at": Timestamp::now().to_string(),
3679            })
3680            .to_string(),
3681        )
3682        .unwrap();
3683
3684        let task = q.get("20260101-000000-bbbb").expect("must still read");
3685        assert!(
3686            !task.urgent,
3687            "a queue file with no `urgent` field means false, same as `solo`"
3688        );
3689    }
3690
3691    #[test]
3692    fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
3693        let (_dir, q) = queue();
3694        let mut t = task("from the future");
3695        q.put(&mut t).unwrap();
3696        let path = q.path_of(&t.id);
3697        let body = std::fs::read_to_string(&path)
3698            .unwrap()
3699            .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
3700        std::fs::write(&path, body).unwrap();
3701
3702        let err = q.get(&t.id).unwrap_err().to_string();
3703        assert!(err.contains("schema 99"), "{err}");
3704    }
3705
3706    #[test]
3707    fn revision_moves_when_the_queue_changes() {
3708        let (_dir, q) = queue();
3709        assert_eq!(q.revision(), 0, "an empty queue has no revision");
3710        let mut t = task("first");
3711        q.put(&mut t).unwrap();
3712        assert!(q.revision() > 0, "a written task moves the revision");
3713    }
3714
3715    #[test]
3716    fn revision_moves_when_deleting_an_older_task() {
3717        let (dir, q) = queue();
3718        let questions = Questions::at(dir.path().join("questions"));
3719        let mut t1 = task("older");
3720        q.put(&mut t1).unwrap();
3721        // Ensure mtime ticks forward.
3722        std::thread::sleep(std::time::Duration::from_millis(10));
3723        let mut t2 = task("newer");
3724        q.put(&mut t2).unwrap();
3725
3726        let rev_before = q.revision();
3727        q.remove(&t1.id, false, &questions).unwrap();
3728        let rev_after = q.revision();
3729
3730        assert_ne!(
3731            rev_before, rev_after,
3732            "deleting an older task must change the revision so other clients see the deletion"
3733        );
3734    }
3735
3736    #[test]
3737    fn removing_a_task_takes_it_out_of_the_listing() {
3738        let (dir, q) = queue();
3739        let questions = Questions::at(dir.path().join("questions"));
3740        let mut t = task("delete me");
3741        q.put(&mut t).unwrap();
3742        let removed = q.remove(t.short(), false, &questions).unwrap();
3743        assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
3744        assert!(removed.released.is_empty() && removed.still_blocked.is_empty());
3745        assert!(q.list().is_empty());
3746        assert!(
3747            q.remove(&t.id, false, &questions).is_err(),
3748            "removing twice is an error"
3749        );
3750    }
3751
3752    #[test]
3753    fn removing_a_task_takes_its_stale_lock_with_it() {
3754        let (dir, q) = queue();
3755        let questions = Questions::at(dir.path().join("questions"));
3756        let mut t = task("interrupted");
3757        q.put(&mut t).unwrap();
3758
3759        // A daemon killed mid-run leaves this behind. Nothing holds it: the
3760        // process that would have dropped the guard is gone.
3761        let claim = q.claim(&t.id).unwrap();
3762        std::mem::forget(claim);
3763        assert!(
3764            q.claim(&t.id).is_err(),
3765            "the orphaned lock is what makes the task look claimed"
3766        );
3767
3768        // A live daemon on this task is refused, whatever the lock says.
3769        let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
3770        assert!(err.contains("live daemon"), "{err}");
3771        assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
3772
3773        // With no daemon behind it, the lock is stale and goes with the task.
3774        q.remove(&t.id, false, &questions).unwrap();
3775        assert!(q.list().is_empty());
3776        let mut again = task("interrupted");
3777        again.id = t.id.clone();
3778        q.put(&mut again).unwrap();
3779        assert!(
3780            q.claim(&t.id).is_ok(),
3781            "a task that comes back must be claimable, which a left-behind lock would prevent"
3782        );
3783    }
3784
3785    fn notices_of(dir: &Path) -> Vec<crate::notices::Notice> {
3786        crate::notices::Notices::at(dir.join("notifications")).list()
3787    }
3788
3789    #[test]
3790    fn removing_a_sole_dependency_releases_the_dependent_without_a_hold() {
3791        let (dir, q) = queue();
3792        let questions = Questions::at(dir.path().join("questions"));
3793        let mut dep = task("dependency");
3794        q.put(&mut dep).unwrap();
3795        let mut blocked = task("waiting");
3796        blocked.block(vec![dep.id.clone()], Some("waits".to_owned()));
3797        q.put(&mut blocked).unwrap();
3798
3799        let removed = q.remove(&dep.id, false, &questions).unwrap();
3800        assert_eq!(removed.released, [blocked.id.clone()]);
3801        assert!(removed.still_blocked.is_empty());
3802
3803        let after = q.get(&blocked.id).unwrap();
3804        assert_eq!(after.status, TaskStatus::Queued);
3805        assert!(after.blocked_by.is_empty());
3806        assert!(after.block_reason.is_none());
3807        let notes = notices_of(dir.path());
3808        assert_eq!(notes.len(), 1, "{notes:?}");
3809        assert_eq!(notes[0].severity, crate::notices::Severity::Info);
3810        assert_eq!(
3811            notes[0].link,
3812            Some(crate::notices::Link::Task {
3813                id: blocked.id.clone(),
3814            })
3815        );
3816    }
3817
3818    #[test]
3819    fn removing_one_of_two_dependencies_keeps_the_other() {
3820        let (dir, q) = queue();
3821        let questions = Questions::at(dir.path().join("questions"));
3822        let mut dep = task("dependency");
3823        q.put(&mut dep).unwrap();
3824        let mut other = task("other");
3825        q.put(&mut other).unwrap();
3826        let mut blocked = task("waiting");
3827        blocked.block(
3828            vec![dep.id.clone(), other.id.clone()],
3829            Some("waits on both".to_owned()),
3830        );
3831        q.put(&mut blocked).unwrap();
3832
3833        let removed = q.remove(&dep.id, false, &questions).unwrap();
3834        assert!(removed.released.is_empty());
3835        assert_eq!(removed.still_blocked, [blocked.id.clone()]);
3836
3837        let after = q.get(&blocked.id).unwrap();
3838        assert_eq!(after.status, TaskStatus::Blocked);
3839        assert_eq!(after.blocked_by, [other.id.clone()]);
3840        // The tombstone stays while a task still names the id... only this
3841        // one's dependency is gone, so nothing references it and it may go.
3842        assert!(missing_blockers(&q, &questions, &after.blocked_by).is_empty());
3843    }
3844
3845    #[test]
3846    fn a_dependency_deleted_before_its_dependents_were_rewritten_is_released_later() {
3847        let (dir, q) = queue();
3848        let questions = Questions::at(dir.path().join("questions"));
3849        let mut dep = task("dependency");
3850        q.put(&mut dep).unwrap();
3851        let mut blocked = task("waiting");
3852        blocked.block(vec![dep.id.clone()], None);
3853        q.put(&mut blocked).unwrap();
3854
3855        // The dependent is claimed elsewhere, so `remove` has to skip it.
3856        let claim = q.claim(&blocked.id).unwrap();
3857        let removed = q.remove(&dep.id, false, &questions).unwrap();
3858        assert!(removed.released.is_empty());
3859        drop(claim);
3860
3861        let mut task = q.get(&blocked.id).unwrap();
3862        assert_eq!(task.status, TaskStatus::Blocked);
3863        assert!(missing_blockers(&q, &questions, &task.blocked_by).is_empty());
3864        assert_eq!(q.apply_deleted_blockers(&mut task), [dep.id.clone()]);
3865        assert_eq!(task.status, TaskStatus::Queued);
3866    }
3867
3868    #[test]
3869    fn a_dependency_without_a_tombstone_is_still_missing() {
3870        let (dir, q) = queue();
3871        let questions = Questions::at(dir.path().join("questions"));
3872        let mut dep = task("dependency");
3873        q.put(&mut dep).unwrap();
3874        std::fs::remove_file(q.path_of(&dep.id)).unwrap();
3875        assert_eq!(
3876            missing_blockers(&q, &questions, std::slice::from_ref(&dep.id)),
3877            [dep.id.clone()]
3878        );
3879        assert!(deleted_blockers(&q, std::slice::from_ref(&dep.id)).is_empty());
3880    }
3881
3882    #[test]
3883    fn a_held_dependent_is_left_alone_by_a_dependency_deletion() {
3884        let mut t = task("held");
3885        t.hold_machine(Some("because".to_owned()));
3886        t.blocked_by = vec!["gone".to_owned()];
3887        assert!(!t.dependency_deleted("gone"));
3888        assert_eq!(t.status, TaskStatus::Held);
3889    }
3890
3891    #[test]
3892    fn a_failed_record_removal_takes_the_tombstone_back() {
3893        let (_dir, q) = queue();
3894        let mut t = task("stays");
3895        q.put(&mut t).unwrap();
3896        q.write_tombstone(&t.id).unwrap();
3897        let err = q.remove_record_with_attachments(&t.id, |_| Err(std::io::Error::other("nope")));
3898        assert!(err.is_err());
3899        // `remove` is what withdraws the marker; a live record is never
3900        // treated as deleted whatever is on disk.
3901        assert!(!q.was_deleted(&t.id));
3902    }
3903
3904    fn source_file(dir: &Path, name: &str, body: &str) -> PathBuf {
3905        let p = dir.join(name);
3906        std::fs::write(&p, body).unwrap();
3907        p
3908    }
3909
3910    #[test]
3911    fn an_attachment_copy_survives_deleting_its_source() {
3912        let (dir, q) = queue();
3913        let src = source_file(dir.path(), "shot.png", "pixels");
3914        let mut t = task("with a picture");
3915        let names = q.attach(&mut t, std::slice::from_ref(&src)).unwrap();
3916        q.put(&mut t).unwrap();
3917        std::fs::remove_file(&src).unwrap();
3918        assert_eq!(names, ["shot.png"]);
3919        let loaded = q.get(&t.id).unwrap();
3920        let paths = q.attachment_paths(&loaded);
3921        assert_eq!(paths.len(), 1);
3922        assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "pixels");
3923    }
3924
3925    #[test]
3926    fn attachment_names_that_could_traverse_or_are_odd_are_refused() {
3927        let (dir, q) = queue();
3928        let mut t = task("bad names");
3929        for name in ["a..b.png", ".hidden", "with space.png", "-x.png"] {
3930            let src = source_file(dir.path(), name, "x");
3931            assert!(
3932                q.attach(&mut t, &[src]).is_err(),
3933                "`{name}` must be refused"
3934            );
3935        }
3936        // A drive-qualified name cannot exist as a file on Windows (joining it
3937        // to a directory yields a drive-relative path to `foo.png` instead),
3938        // so the rule is asserted on the name itself, everywhere.
3939        assert!(!crate::ask::valid_asset_name("C:foo.png"));
3940        #[cfg(not(windows))]
3941        {
3942            let src = source_file(dir.path(), "C:foo.png", "x");
3943            assert!(q.attach(&mut t, &[src]).is_err());
3944        }
3945        let long = format!("{}.png", "a".repeat(70));
3946        let src = source_file(dir.path(), &long, "x");
3947        assert!(q.attach(&mut t, &[src]).is_err());
3948        assert!(t.attachments.is_empty());
3949        assert!(!q.attachments_dir(&t.id).exists());
3950    }
3951
3952    #[test]
3953    fn a_taken_attachment_name_is_numbered_not_overwritten() {
3954        let (dir, q) = queue();
3955        let a = source_file(dir.path(), "shot.png", "one");
3956        let sub = dir.path().join("other");
3957        std::fs::create_dir_all(&sub).unwrap();
3958        let b = source_file(&sub, "shot.png", "two");
3959        let mut t = task("collision");
3960        q.attach(&mut t, &[a]).unwrap();
3961        q.attach(&mut t, &[b]).unwrap();
3962        assert_eq!(t.attachments, ["shot.png", "shot-2.png"]);
3963        let paths = q.attachment_paths(&t);
3964        assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "one");
3965        assert_eq!(std::fs::read_to_string(&paths[1]).unwrap(), "two");
3966    }
3967
3968    #[test]
3969    fn a_renumbered_name_stays_inside_the_length_bound() {
3970        let (dir, q) = queue();
3971        let name = format!("{}.png", "a".repeat(60));
3972        assert_eq!(name.len(), 64);
3973        let a = source_file(dir.path(), &name, "one");
3974        let sub = dir.path().join("other");
3975        std::fs::create_dir_all(&sub).unwrap();
3976        let b = source_file(&sub, &name, "two");
3977        let mut t = task("long");
3978        q.attach(&mut t, &[a, b]).unwrap();
3979        assert_eq!(t.attachments.len(), 2);
3980        assert!(
3981            t.attachments
3982                .iter()
3983                .all(|n| crate::ask::valid_asset_name(n))
3984        );
3985        assert!(t.attachments[1].ends_with("-2.png"));
3986    }
3987
3988    #[test]
3989    fn a_failed_attach_keeps_existing_attachments_and_leaves_no_partial_copy() {
3990        let (dir, q) = queue();
3991        let good = source_file(dir.path(), "good.png", "ok");
3992        let mut t = task("partial");
3993        q.attach(&mut t, &[good]).unwrap();
3994        let more = source_file(dir.path(), "more.png", "ok");
3995        let missing = dir.path().join("missing.png");
3996        assert!(q.attach(&mut t, &[more, missing]).is_err());
3997        assert_eq!(t.attachments, ["good.png"]);
3998        let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
3999            .unwrap()
4000            .flatten()
4001            .collect();
4002        assert_eq!(on_disk.len(), 1);
4003    }
4004
4005    /// Make the next `put` of `t` fail: its temp file's path is a directory.
4006    fn block_put(q: &Queue, t: &Task) -> PathBuf {
4007        let tmp = q.path_of(&t.id).with_extension("json.tmp");
4008        std::fs::create_dir_all(&tmp).unwrap();
4009        tmp
4010    }
4011
4012    #[test]
4013    fn a_failed_put_leaves_no_new_attachment_directory() {
4014        let (dir, q) = queue();
4015        let mut t = task("fresh");
4016        let tmp = block_put(&q, &t);
4017        let src = source_file(dir.path(), "shot.png", "x");
4018        assert!(q.attach_and_put(&mut t, &[src]).is_err());
4019        assert!(t.attachments.is_empty());
4020        assert!(!q.attachments_dir(&t.id).exists());
4021        assert!(!q.path_of(&t.id).exists());
4022        std::fs::remove_dir(tmp).unwrap();
4023    }
4024
4025    #[test]
4026    fn a_failed_put_removes_only_the_copy_it_just_made() {
4027        let (dir, q) = queue();
4028        let mut t = task("edited");
4029        let first = source_file(dir.path(), "first.png", "1");
4030        q.attach_and_put(&mut t, &[first]).unwrap();
4031        block_put(&q, &t);
4032        let second = source_file(dir.path(), "second.png", "2");
4033        assert!(q.attach_and_put(&mut t, &[second]).is_err());
4034        assert_eq!(t.attachments, ["first.png"]);
4035        let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
4036            .unwrap()
4037            .flatten()
4038            .map(|e| e.file_name().to_string_lossy().into_owned())
4039            .collect();
4040        assert_eq!(on_disk, ["first.png"]);
4041        assert_eq!(q.get(&t.id).unwrap().attachments, ["first.png"]);
4042    }
4043
4044    #[test]
4045    fn a_leftover_removing_directory_is_swept_by_the_next_removal() {
4046        let (dir, q) = queue();
4047        let questions = Questions::at(dir.path().join("questions"));
4048        let gone = task("gone");
4049        let mut other = task("other");
4050        let mut live = task("live");
4051        q.put(&mut other).unwrap();
4052        q.put(&mut live).unwrap();
4053        // `gone` has no record: its removal finished deleting the record but
4054        // not the set-aside attachments.
4055        let orphan = q.root.join(format!("{}.attachments.removing", gone.id));
4056        std::fs::create_dir_all(&orphan).unwrap();
4057        std::fs::write(orphan.join("shot.png"), "x").unwrap();
4058        // `live` still has a record, so its `.removing` belongs to a removal
4059        // in progress and must be left alone.
4060        let busy = q.root.join(format!("{}.attachments.removing", live.id));
4061        std::fs::create_dir_all(&busy).unwrap();
4062
4063        q.remove(&other.id, false, &questions).unwrap();
4064        assert!(!orphan.exists(), "an orphan is swept");
4065        assert!(busy.exists(), "a removal in progress is left alone");
4066    }
4067
4068    #[test]
4069    fn a_blocked_aside_rename_fails_the_removal_and_loses_nothing() {
4070        let (dir, q) = queue();
4071        let questions = Questions::at(dir.path().join("questions"));
4072        let mut t = task("stuck");
4073        let src = source_file(dir.path(), "shot.png", "x");
4074        q.attach_and_put(&mut t, &[src]).unwrap();
4075        // A non-empty directory already at the set-aside name makes the rename
4076        // fail; its record is present, so the sweep leaves it in place.
4077        let aside = q.root.join(format!("{}.attachments.removing", t.id));
4078        std::fs::create_dir_all(&aside).unwrap();
4079        std::fs::write(aside.join("old.png"), "o").unwrap();
4080        assert!(q.remove(&t.id, false, &questions).is_err());
4081        assert!(q.path_of(&t.id).exists());
4082        assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4083    }
4084
4085    #[test]
4086    fn a_failed_record_removal_puts_the_attachments_back() {
4087        let (dir, q) = queue();
4088        let mut t = task("rollback");
4089        let src = source_file(dir.path(), "shot.png", "x");
4090        q.attach_and_put(&mut t, &[src]).unwrap();
4091        let err = q
4092            .remove_record_with_attachments(&t.id, |_| {
4093                Err(std::io::Error::other("injected failure"))
4094            })
4095            .unwrap_err();
4096        assert!(format!("{err:#}").contains("injected failure"));
4097        assert!(q.path_of(&t.id).exists());
4098        assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4099        assert!(
4100            !q.root
4101                .join(format!("{}.attachments.removing", t.id))
4102                .exists()
4103        );
4104    }
4105
4106    #[test]
4107    fn editing_a_task_keeps_its_attachments() {
4108        let (dir, q) = queue();
4109        let src = source_file(dir.path(), "shot.png", "x");
4110        let mut t = task("editable");
4111        q.attach(&mut t, &[src]).unwrap();
4112        t.edit("new".to_owned(), "new text".to_owned()).unwrap();
4113        q.put(&mut t).unwrap();
4114        assert_eq!(q.get(&t.id).unwrap().attachments, ["shot.png"]);
4115    }
4116
4117    #[test]
4118    fn removing_a_task_deletes_its_attachments() {
4119        let (dir, q) = queue();
4120        let questions = Questions::at(dir.path().join("questions"));
4121        let src = source_file(dir.path(), "shot.png", "x");
4122        let mut t = task("doomed");
4123        q.attach(&mut t, &[src]).unwrap();
4124        q.put(&mut t).unwrap();
4125        assert!(q.attachments_dir(&t.id).is_dir());
4126        q.remove(&t.id, false, &questions).unwrap();
4127        assert!(!q.attachments_dir(&t.id).exists());
4128        assert!(q.list().is_empty());
4129    }
4130
4131    #[test]
4132    fn a_task_written_before_attachments_still_reads() {
4133        let (_dir, q) = queue();
4134        let mut t = task("old");
4135        q.put(&mut t).unwrap();
4136        let path = q.path_of(&t.id);
4137        let mut v: serde_json::Value =
4138            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4139        v.as_object_mut().unwrap().remove("attachments");
4140        std::fs::write(&path, v.to_string()).unwrap();
4141        assert!(q.get(&t.id).unwrap().attachments.is_empty());
4142    }
4143
4144    #[test]
4145    fn attachment_paths_are_absolute_even_when_the_root_is_relative() {
4146        let q = Queue::at(PathBuf::from("relative-queue"));
4147        let mut t = task("rel");
4148        t.attachments.push("shot.png".to_owned());
4149        let paths = q.attachment_paths(&t);
4150        assert!(paths[0].is_absolute(), "{}", paths[0].display());
4151        assert!(paths[0].ends_with(format!("{}.attachments/shot.png", t.id)));
4152    }
4153
4154    #[test]
4155    fn link_run_adds_a_run_once_and_touches_nothing_else() {
4156        let dir = tempfile::tempdir().unwrap();
4157        let queue = Queue::at(dir.path().join("queue"));
4158        let mut t = Task::new(
4159            "t".to_owned(),
4160            "do it".to_owned(),
4161            PathBuf::from("."),
4162            Source::Human,
4163        );
4164        queue.put(&mut t).unwrap();
4165        let before = queue.get(&t.id).unwrap();
4166
4167        let linked = queue.link_run(&t.id[..4], "20260930-092817-ec34").unwrap();
4168        assert_eq!(linked.runs, vec!["20260930-092817-ec34".to_owned()]);
4169        assert_eq!(linked.status, before.status);
4170        assert_eq!(linked.attempts, before.attempts);
4171        assert_eq!(linked.interrupt, before.interrupt);
4172
4173        let again = queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
4174        assert_eq!(again.runs.len(), 1, "linking twice must not duplicate");
4175        assert_eq!(queue.get(&t.id).unwrap().runs.len(), 1);
4176        assert!(queue.link_run("no-such-task", "r").is_err());
4177    }
4178
4179    #[test]
4180    fn put_keeps_a_run_linked_after_the_writer_took_its_snapshot() {
4181        let dir = tempfile::tempdir().unwrap();
4182        let queue = Queue::at(dir.path().join("queue"));
4183        let mut t = Task::new(
4184            "t".to_owned(),
4185            "do it".to_owned(),
4186            PathBuf::from("."),
4187            Source::Human,
4188        );
4189        queue.put(&mut t).unwrap();
4190        // The daemon's copy, taken before the link.
4191        let mut snapshot = queue.get(&t.id).unwrap();
4192        queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
4193
4194        snapshot.start("20260930-000000-aaaa".to_owned());
4195        queue.put(&mut snapshot).unwrap();
4196
4197        let stored = queue.get(&t.id).unwrap();
4198        assert!(stored.runs.contains(&"20260930-092817-ec34".to_owned()));
4199        assert!(stored.runs.contains(&"20260930-000000-aaaa".to_owned()));
4200        assert_eq!(stored.attempts, 1);
4201    }
4202
4203    #[test]
4204    fn concurrent_links_and_daemon_saves_lose_nothing() {
4205        let dir = tempfile::tempdir().unwrap();
4206        let queue = Queue::at(dir.path().join("queue"));
4207        let mut t = Task::new(
4208            "t".to_owned(),
4209            "do it".to_owned(),
4210            PathBuf::from("."),
4211            Source::Human,
4212        );
4213        queue.put(&mut t).unwrap();
4214        let id = t.id.clone();
4215
4216        let linkers: Vec<_> = (0..4)
4217            .map(|n| {
4218                let (queue, id) = (queue.clone(), id.clone());
4219                std::thread::spawn(move || {
4220                    for k in 0..10 {
4221                        queue
4222                            .link_run(&id, &format!("20260930-00000{n}-l{k:03}"))
4223                            .unwrap();
4224                    }
4225                })
4226            })
4227            .collect();
4228        // The daemon's side: transitions saved from its own, ever staler,
4229        // copy of the task.
4230        let mut mine = queue.get(&id).unwrap();
4231        for k in 0..10 {
4232            mine.start(format!("20260930-000009-d{k:03}"));
4233            queue.put(&mut mine).unwrap();
4234        }
4235        for l in linkers {
4236            l.join().unwrap();
4237        }
4238
4239        let stored = queue.get(&id).unwrap();
4240        assert_eq!(stored.runs.len(), 50, "{:?}", stored.runs);
4241        assert_eq!(
4242            stored.attempts, 10,
4243            "linking never rewinds the daemon's work"
4244        );
4245    }
4246
4247    fn age_lock(path: &Path) {
4248        let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
4249        f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
4250            .unwrap();
4251    }
4252
4253    #[test]
4254    fn concurrent_stale_takeover_yields_one_holder() {
4255        use std::sync::atomic::{AtomicUsize, Ordering};
4256        use std::sync::{Arc, Barrier};
4257        for _ in 0..5 {
4258            let dir = tempfile::tempdir().unwrap();
4259            let q = Queue::at(dir.path().to_path_buf());
4260            let lock = dir.path().join("t.write-lock");
4261            std::fs::write(&lock, "dead-0000").unwrap();
4262            age_lock(&lock);
4263            let n = 6;
4264            let barrier = Arc::new(Barrier::new(n));
4265            let (now, max) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
4266            let handles: Vec<_> = (0..n)
4267                .map(|_| {
4268                    let (q, b, now, max) = (q.clone(), barrier.clone(), now.clone(), max.clone());
4269                    std::thread::spawn(move || {
4270                        b.wait();
4271                        let g = q.lock_task("t").unwrap();
4272                        let held = now.fetch_add(1, Ordering::SeqCst) + 1;
4273                        max.fetch_max(held, Ordering::SeqCst);
4274                        std::thread::sleep(std::time::Duration::from_millis(20));
4275                        now.fetch_sub(1, Ordering::SeqCst);
4276                        drop(g);
4277                    })
4278                })
4279                .collect();
4280            for h in handles {
4281                h.join().unwrap();
4282            }
4283            assert_eq!(max.load(Ordering::SeqCst), 1);
4284            assert!(!lock.exists());
4285        }
4286    }
4287
4288    #[test]
4289    fn dropping_a_stolen_lock_leaves_the_new_holders_lock() {
4290        let dir = tempfile::tempdir().unwrap();
4291        let q = Queue::at(dir.path().to_path_buf());
4292        let lock = dir.path().join("t.write-lock");
4293        let a = q.lock_task("t").unwrap();
4294        age_lock(&lock);
4295        let b = q.lock_task("t").unwrap();
4296        assert_ne!(a.token, b.token);
4297        drop(a);
4298        assert_eq!(std::fs::read_to_string(&lock).unwrap(), b.token);
4299        drop(b);
4300        assert!(!lock.exists());
4301    }
4302
4303    #[test]
4304    fn break_stale_leaves_a_lock_that_replaced_the_one_judged() {
4305        let dir = tempfile::tempdir().unwrap();
4306        let lock = dir.path().join("t.write-lock");
4307        std::fs::write(&lock, "new-token").unwrap();
4308        assert!(!break_stale(&lock, "old-token"));
4309        assert_eq!(std::fs::read_to_string(&lock).unwrap(), "new-token");
4310        // Same token but fresh: not stale any more, so also left alone.
4311        assert!(!break_stale(&lock, "new-token"));
4312        assert!(lock.exists());
4313        age_lock(&lock);
4314        assert!(break_stale(&lock, "new-token"));
4315        assert!(!lock.exists());
4316    }
4317
4318    #[test]
4319    fn a_stale_break_marker_is_recovered_and_a_fresh_one_is_respected() {
4320        let dir = tempfile::tempdir().unwrap();
4321        let lock = dir.path().join("t.write-lock");
4322        std::fs::write(&lock, "dead-1").unwrap();
4323        age_lock(&lock);
4324        let marker = break_marker(&lock, "dead-1");
4325        std::fs::write(&marker, "crashed-remover").unwrap();
4326        // Fresh marker: another remover is working, so nothing is broken.
4327        assert!(!break_stale(&lock, "dead-1"));
4328        assert!(lock.exists());
4329        // Stale marker: recovered, then the lock is broken.
4330        age_lock(&marker);
4331        assert!(break_stale(&lock, "dead-1"));
4332        assert!(!lock.exists());
4333        assert!(!marker.exists());
4334    }
4335
4336    fn chat_task() -> Task {
4337        Task::new(
4338            "t".to_owned(),
4339            "do t".to_owned(),
4340            PathBuf::from("."),
4341            Source::Agent {
4342                run: "20260902-000000-beef".to_owned(),
4343                node: CHAT_NODE.to_owned(),
4344            },
4345        )
4346    }
4347
4348    #[test]
4349    fn a_chat_notice_is_due_only_for_a_final_state_and_only_once() {
4350        let mut t = chat_task();
4351        assert_eq!(t.chat_notice_due(), None, "queued");
4352        t.start("r1".to_owned());
4353        assert_eq!(t.chat_notice_due(), None, "running");
4354        t.fail("boom", 3);
4355        assert_eq!(t.status, TaskStatus::Failed);
4356        assert_eq!(t.chat_notice_due(), None, "a retried failure is not final");
4357        t.start("r2".to_owned());
4358        t.park("upgrade");
4359        assert_eq!(t.chat_notice_due(), None, "parked");
4360        t.fail("boom", 1);
4361        assert_eq!(t.status, TaskStatus::Held);
4362        let key = t
4363            .chat_notice_due()
4364            .expect("held on the last attempt is final");
4365        t.chat_notice = Some(ChatNotice {
4366            key,
4367            at: Timestamp::now(),
4368            outcome: ChatNoticeOutcome::Posted,
4369        });
4370        assert_eq!(t.chat_notice_due(), None, "same hold, no second notice");
4371        t.release();
4372        assert_eq!(t.chat_notice_due(), None, "requeued after a release");
4373        t.start("r3".to_owned());
4374        t.succeed();
4375        assert!(t.chat_notice_due().is_some(), "done is a new final state");
4376    }
4377
4378    #[test]
4379    fn completed_chat_notices_keep_current_and_legacy_keys_after_requeue() {
4380        for key in ["done", "done#0", "done#1", "done#12"] {
4381            let mut t = chat_task();
4382            t.start("r1".to_owned());
4383            t.succeed();
4384            t.chat_notice = Some(ChatNotice {
4385                key: key.to_owned(),
4386                at: Timestamp::now(),
4387                outcome: ChatNoticeOutcome::Posted,
4388            });
4389            t.requeue();
4390            t.start("r2".to_owned());
4391            t.succeed();
4392            assert_eq!(t.chat_notice_key().as_deref(), Some("done"));
4393            assert_eq!(t.chat_notice_due(), None, "recorded key: {key}");
4394        }
4395    }
4396
4397    #[test]
4398    fn a_new_hold_after_a_release_is_a_new_cause() {
4399        let mut t = chat_task();
4400        t.hold_manual(Some("wait".to_owned()));
4401        let first = t.chat_notice_due().expect("held");
4402        t.chat_notice = Some(ChatNotice {
4403            key: first.clone(),
4404            at: Timestamp::now(),
4405            outcome: ChatNoticeOutcome::Posted,
4406        });
4407        t.hold_manual(Some("still waiting".to_owned()));
4408        assert_eq!(t.chat_notice_due(), None, "a re-hold keeps its start");
4409        t.release();
4410        std::thread::sleep(std::time::Duration::from_millis(5));
4411        t.hold_manual(None);
4412        let second = t.chat_notice_due().expect("a fresh hold");
4413        assert_ne!(first, second);
4414    }
4415
4416    #[test]
4417    fn a_chat_notice_needs_a_chat_source_and_a_current_schema() {
4418        let mut human = task("h");
4419        human.succeed();
4420        assert_eq!(human.chat_notice_due(), None);
4421        // A follow-up only inherits the chat; it did not file the task.
4422        let mut inherited = task("i");
4423        inherited.origin_chat = Some("20260902-000000-beef".to_owned());
4424        inherited.succeed();
4425        assert_eq!(inherited.chat_notice_due(), None);
4426        let mut old = chat_task();
4427        old.schema = 12;
4428        old.status = TaskStatus::Done;
4429        assert_eq!(old.chat_notice_due(), None, "history is not replayed");
4430        let mut live = chat_task();
4431        live.schema = 12;
4432        live.start("r1".to_owned());
4433        live.succeed();
4434        assert!(live.chat_notice_due().is_some(), "started by this build");
4435        let mut held = chat_task();
4436        held.schema = 12;
4437        held.hold_machine(Some("config".to_owned()));
4438        assert!(held.chat_notice_due().is_some(), "held before any start");
4439    }
4440
4441    #[test]
4442    fn a_stale_snapshot_does_not_rewind_a_recorded_chat_notice() {
4443        let tmp = tempfile::tempdir().expect("tempdir");
4444        let q = Queue::at(tmp.path().join("queue"));
4445        let mut t = chat_task();
4446        t.succeed();
4447        q.put(&mut t).expect("put");
4448        let mut stale = q.get(&t.id).expect("get");
4449        let key = t.chat_notice_due().expect("due");
4450        q.modify(&t.id, |x| {
4451            x.chat_notice = Some(ChatNotice {
4452                key: key.clone(),
4453                at: Timestamp::now(),
4454                outcome: ChatNoticeOutcome::Posted,
4455            });
4456            true
4457        })
4458        .expect("modify");
4459        q.put(&mut stale).expect("stale put");
4460        assert_eq!(q.get(&t.id).expect("get").chat_notice_due(), None);
4461    }
4462}