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::num::NonZeroUsize;
35use std::path::{Path, PathBuf};
36
37use anyhow::{Context, Result, bail};
38use jiff::Timestamp;
39use serde::{Deserialize, Serialize};
40
41use crate::ask::Questions;
42use crate::id_reserve::{IdSource, path_exists};
43
44/// On-disk format for a queued task. Bumped when a field's meaning changes.
45///
46/// 18: retained review criteria snapshots survive operator edits.
47///
48/// 17: durable explicit operator resumption, independent of run startup.
49///
50/// 7: added [`Task::attachments`], names of files copied under
51/// `<id>.attachments/` beside the task file (a field only; `#[serde(default)]`,
52/// so an older record reads as empty and [`read_path`] still accepts it).
53///
54/// 6: added [`Task::resume_override`] (a field only; `#[serde(default)]`, so
55/// an older record reads as `None` and [`read_path`] still accepts it).
56///
57/// 5: added [`Task::triage_applied`], the ids of triage questions whose
58/// answer has already been applied to this task. A "resume" answer used to
59/// leave no trace ([`Task::release`] clears [`Task::hold_reason`], which was
60/// the only place the applied marker lived), so when the released task failed
61/// its attempts and went back to `held`, the next idle pass found the same
62/// answered question "not applied" and released it again with `attempts` reset
63/// to 0 - the `max_attempts` bound never held. `#[serde(default)]` so an older
64/// record reads as empty. A task already looping when this build arrives has
65/// no record, so it is released once more, recorded, and then stays held.
66///
67/// 8: added [`Task::followup`], the origin of a task `crate::followup` filed
68/// from a merged run's leftover findings. Field-only bump, `#[serde(default)]`.
69///
70/// 4: added [`Task::blocked_from`], the status a task had the moment it
71/// became [`TaskStatus::Blocked`], so [`Task::unblock`] restores it instead
72/// of always landing on [`TaskStatus::Queued`]. Without it, a task a human
73/// or `crate::triage` had deliberately left [`TaskStatus::Held`] — machine
74/// or manual — would lose that the instant `crate::conduct` blocked it on a
75/// follow-up question, and come back `Queued` the moment the question was
76/// answered, regardless of what the answer said: exactly the loop where a
77/// task the operator told to stay held instead re-enters the competition
78/// queue every time someone answers a question about it. `#[serde(default)]`
79/// so an older record reads as `None`; [`Task::unblock`] then falls back to
80/// inferring `Held` from surviving hold evidence ([`Task::hold_reason`] /
81/// [`Task::hold_source`], never cleared by [`Task::block`]) rather than
82/// guessing `Queued` outright — see [`Task::unblock`]'s own doc.
83///
84/// 9: added [`Task::held_at`], the start of the current hold, so a notice can
85/// tell a question filed for this hold from one about an older cause.
86/// Field-only, `#[serde(default)]`.
87///
88/// 10: added [`Task::overrides`] and [`Task::review_of`], so a run started by
89/// hand (`magi run` / `magi review`) is carried by a task without losing its
90/// command-line choices. Field-only.
91///
92/// 11: added [`TaskStatus::Parked`] and [`Task::park_reason`], so a run that
93/// stopped at a node boundary (an upgrade, an operator's park) is no longer
94/// recorded as a failed task. Variant plus `#[serde(default)]` field; an older
95/// build must not half-read the new status.
96///
97/// 12: added [`Task::origin_chat`], the talk a task descends from, so a
98/// follow-up still knows its chat after an ancestor task is deleted.
99/// Field-only, `#[serde(default)]`.
100///
101/// 13: added [`Task::chat_notice`], the record that a chat-filed task's final
102/// state was reported to its conversation. Field-only, `#[serde(default)]`;
103/// a record below this schema is never notified (an upgrade must not replay
104/// the whole history into chats).
105///
106/// 3: added [`HoldSource`] so conductor recovery cannot release a hold an
107/// operator deliberately placed. Old records default to `None` and are
108/// protected as operator-held until an explicit release; the safe direction
109/// when their author was never recorded.
110///
111/// 2: added [`TaskStatus::Blocked`], [`Task::blocked_by`] and
112/// [`Task::block_reason`] (`crate::conduct`'s decisions) and
113/// [`Task::answers`] (operator answers carried forward to the next
114/// conductor prompt and the next run's instruction). All three are
115/// `#[serde(default)]`, so [`read_path`] accepts anything up to and
116/// including this schema rather than only an exact match — a task written
117/// by a build that only knew about schema 1 has nothing to say about
118/// blocking or answers, and defaulting those fields is exactly as good a
119/// reading as a value that build never had a chance to write.
120///
121/// 15: added [`Task::passes`], what each settled pass over a run did to the
122/// attempt budget and why it stopped. Field-only, `#[serde(default)]`; a task
123/// from before this has no entry for its earlier passes, which stay unknown.
124///
125/// 14: record whether a task count edit's overrides inherit daemon options.
126/// Old overrides default to isolated command-line settings, including empty ones.
127///
128/// 16: added [`Task::attempt_limit`], an optional task-specific retry budget.
129/// Field-only, `#[serde(default)]`; old records inherit the daemon limit.
130///
131/// 19: persist settled pass start times for upgrade-resume scheduling.
132/// Old passes default to unknown and use settlement order as an approximation.
133/// 20: persist the task decision chat destination, defaulting to absent.
134pub const SCHEMA: u32 = 20;
135
136/// Candidate order only: all claims, lanes and eligibility gates still apply.
137/// Upgrade-parked runs retain their pass-start order ahead of ordinary work,
138/// regardless of ordinary priority. Old records without a start time use
139/// settlement time and task id as an approximation. Other parks are ordinary.
140/// Urgent slots and explicit interrupts are handled separately by the daemon.
141pub(crate) fn dispatch_order(a: &Task, b: &Task) -> std::cmp::Ordering {
142    match (a.upgrade_resume_key(), b.upgrade_resume_key()) {
143        (Some(a), Some(b)) => a.cmp(&b),
144        (Some(_), None) => std::cmp::Ordering::Less,
145        (None, Some(_)) => std::cmp::Ordering::Greater,
146        (None, None) => b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)),
147    }
148}
149
150/// Who placed the current hold.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum HoldSource {
154    /// An operator used the CLI or web UI.
155    Manual,
156    /// The daemon or conductor placed the hold as part of its own recovery.
157    Machine,
158}
159
160impl HoldSource {
161    /// Short human-facing label for reports and the CLI.
162    pub fn label(self) -> &'static str {
163        match self {
164            Self::Manual => "manual",
165            Self::Machine => "machine",
166        }
167    }
168}
169
170/// Where a task came from. Recorded because "who asked for this" is the first
171/// question about an autonomous run, and the answer is not recoverable later.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(tag = "kind", rename_all = "lowercase")]
174pub enum Source {
175    /// A person, at a terminal or through the web UI.
176    Human,
177    /// An agent inside a run, via `magi task add`. Both ids are recorded so a
178    /// task can be traced back to the exact seat that asked for it.
179    Agent {
180        /// Run the asking agent belonged to.
181        run: String,
182        /// Node it was working in, e.g. `implement` or `review`.
183        node: String,
184    },
185    /// A GitHub issue, imported by number.
186    Issue {
187        /// Issue number.
188        number: u64,
189        /// `owner/repo`, as `gh` reports it.
190        repo: String,
191    },
192}
193
194/// The `node` a [`Source::Agent`] carries when the asker is a chat
195/// conversation (its `run` is then the conversation's id).
196pub const CHAT_NODE: &str = "chat";
197
198impl Source {
199    /// Short human-facing label, for lists and the web UI.
200    pub fn label(&self) -> String {
201        match self {
202            Self::Human => "human".to_owned(),
203            Self::Agent { run, node } => format!("{node}@{}", short(run)),
204            Self::Issue { number, .. } => format!("issue #{number}"),
205        }
206    }
207}
208
209/// Where a task is in its life.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "lowercase")]
212pub enum TaskStatus {
213    /// Waiting to be claimed.
214    Queued,
215    /// Claimed by a daemon; a run is in flight.
216    Running,
217    /// A run finished and its gate passed.
218    Done,
219    /// A run finished without passing, and attempts remain.
220    Failed,
221    /// Out of attempts, or held by hand. The loop will not pick it up.
222    Held,
223    /// Waiting on another task or an unanswered question. See
224    /// [`Task::blocked_by`]. Set and cleared by `crate::conduct` and
225    /// `crate::daemon`'s deterministic resolver, never by hand.
226    Blocked,
227    /// The run stopped at a node boundary because the process was asked to
228    /// give itself back. Nothing failed: the run's work is intact and the next
229    /// loop resumes it. Runnable like [`TaskStatus::Queued`]; the reason is in
230    /// [`Task::park_reason`], never [`Task::last_error`].
231    Parked,
232}
233
234impl TaskStatus {
235    /// Is this task eligible for a daemon to claim?
236    pub fn runnable(self) -> bool {
237        matches!(self, Self::Queued | Self::Failed | Self::Parked)
238    }
239
240    /// Lowercase name, as it appears on disk and in the API.
241    pub fn as_str(self) -> &'static str {
242        match self {
243            Self::Queued => "queued",
244            Self::Running => "running",
245            Self::Done => "done",
246            Self::Failed => "failed",
247            Self::Held => "held",
248            Self::Blocked => "blocked",
249            Self::Parked => "parked",
250        }
251    }
252}
253
254/// How many tasks sit in each [`TaskStatus`], for a dashboard tile — never a
255/// per-task view, so it carries no ids.
256#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
257pub struct TaskCounts {
258    /// Waiting to be claimed.
259    pub queued: usize,
260    /// Claimed; a run is in flight.
261    pub running: usize,
262    /// A run finished and its gate passed.
263    pub done: usize,
264    /// A run finished without passing, and attempts remain.
265    pub failed: usize,
266    /// Out of attempts, or held by hand.
267    pub held: usize,
268    /// Waiting on another task or an unanswered question.
269    pub blocked: usize,
270    /// Run parked at a node boundary; resumable, not a failure.
271    pub parked: usize,
272}
273
274impl TaskCounts {
275    /// Tally `tasks` by status. A pure function over whatever [`Queue::list`]
276    /// already read, so it needs no root of its own and stays trivially
277    /// testable against a hand-built slice.
278    pub fn of(tasks: &[Task]) -> Self {
279        Self::of_statuses(tasks.iter().map(|task| task.status))
280    }
281
282    /// Count current statuses from a reconciled display projection.
283    pub(crate) fn of_statuses(statuses: impl IntoIterator<Item = TaskStatus>) -> Self {
284        let mut counts = Self::default();
285        for status in statuses {
286            match status {
287                TaskStatus::Queued => counts.queued += 1,
288                TaskStatus::Running => counts.running += 1,
289                TaskStatus::Done => counts.done += 1,
290                TaskStatus::Failed => counts.failed += 1,
291                TaskStatus::Held => counts.held += 1,
292                TaskStatus::Blocked => counts.blocked += 1,
293                TaskStatus::Parked => counts.parked += 1,
294            }
295        }
296        counts
297    }
298}
299
300/// One unit of work.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(deny_unknown_fields)]
303pub struct Task {
304    /// On-disk format version.
305    pub schema: u32,
306    /// Task id, e.g. `20260902-140501-a1b2`.
307    pub id: String,
308    /// One line, for lists and notifications.
309    pub title: String,
310    /// The task itself, handed to the graph verbatim.
311    pub instruction: String,
312    /// Explicit, SHA-pinned implementation imports; separate from instruction text.
313    #[serde(default, skip_serializing_if = "crate::refs::Sources::is_empty")]
314    pub sources: crate::refs::Sources,
315    /// Repository to work in.
316    pub repo: PathBuf,
317    /// Who asked.
318    pub source: Source,
319    /// Durable destination for task decision messages (including tasks filed outside chat).
320    #[serde(default)]
321    pub chat: Option<String>,
322    /// Higher runs first; ties break oldest-first so nothing starves.
323    #[serde(default)]
324    pub priority: i32,
325    /// Run this task alone: one implementer, no panel of judges to convince.
326    ///
327    /// `#[serde(default)]` so a queue file written before this field existed
328    /// still reads, as `false` - the ordinary multi-candidate competition,
329    /// unchanged. A task set to `solo` still runs the whole graph; only the
330    /// candidate count the daemon builds it with changes, and
331    /// [`crate::graph::Runner`] already collapses a single-candidate run to
332    /// implement → review → gate → merge on its own (see
333    /// [`crate::graph::Runner::review`]'s doc), so nothing about judging,
334    /// deliberation or voting had to change to support this.
335    #[serde(default)]
336    pub solo: bool,
337    /// Current state.
338    pub status: TaskStatus,
339    /// How many times this task has been claimed.
340    #[serde(default)]
341    pub attempts: usize,
342    /// Total attempt budget; `None` inherits the daemon limit.
343    #[serde(default)]
344    pub attempt_limit: Option<NonZeroUsize>,
345    /// Distinct runs, ordered by their most recent claim. Older records may
346    /// contain repeated ids; execution history lives in the run pass records.
347    #[serde(default)]
348    pub runs: Vec<String>,
349    /// Why the last attempt did not land.
350    #[serde(default)]
351    pub last_error: Option<String>,
352    /// What a human hold is waiting on.
353    ///
354    /// `None` covers both the ordinary cases: a hold the loop makes itself
355    /// (out of attempts, or the disk gate closed) explains itself through
356    /// [`Task::last_error`] instead, and a human hold nobody bothered to
357    /// explain is still a valid hold. The queue has no way to express a
358    /// dependency between two tasks, so on the occasions a hold really is
359    /// "wait for that other task first", this is the only place that reason
360    /// survives - see [`Task::hold_manual`] and [`Task::release`].
361    ///
362    /// `#[serde(default)]` so a queue file written before this field existed
363    /// still reads, with no reason recorded rather than a parse error.
364    #[serde(default)]
365    pub hold_reason: Option<String>,
366    /// Who placed [`Task::hold_reason`].  `None` is a compatible old record;
367    /// see [`Task::operator_held`] for its deliberately conservative meaning.
368    #[serde(default)]
369    pub hold_source: Option<HoldSource>,
370    /// Diagnostic detail excerpted from the run that led to a hold - what a
371    /// human would have found opening `artifacts/` by hand, not the one-line
372    /// reason in [`Task::last_error`]. Set only when a run's own attempts are
373    /// exhausted and the task becomes [`TaskStatus::Held`]; `daemon` computes
374    /// it from the run's own record, since this module has no notion of a
375    /// run's internals. Bounded in length by the writer - see
376    /// `daemon::diagnostic` - so a verbose run cannot make this file grow
377    /// without limit.
378    ///
379    /// `#[serde(default)]` so a queue file written before this field existed
380    /// still reads, with no diagnostic recorded rather than a parse error.
381    #[serde(default)]
382    pub diagnostic: Option<String>,
383    /// What this task is waiting on: other task ids, unanswered
384    /// `crate::ask::Question` ids, or both. Non-empty exactly when
385    /// [`TaskStatus::Blocked`]; emptying it — see [`Task::unblock`] — is what
386    /// puts the task back at [`TaskStatus::Queued`].
387    ///
388    /// Set by `crate::conduct`'s decisions and cleared deterministically by
389    /// `crate::daemon` as each dependency resolves, never by a person. Never
390    /// `#[serde(default)]` is skipped: a queue file from before this field
391    /// existed has nothing to report here, and an empty list is exactly that.
392    #[serde(default)]
393    pub blocked_by: Vec<String>,
394    /// One line explaining the current [`Task::blocked_by`], written by
395    /// `crate::conduct`. Cleared whenever `blocked_by` empties.
396    #[serde(default)]
397    pub block_reason: Option<String>,
398    /// The status this task had the moment [`Task::block`] most recently
399    /// moved it to [`TaskStatus::Blocked`] — what [`Task::unblock`] restores
400    /// once nothing is left in `blocked_by`, instead of always landing on
401    /// [`TaskStatus::Queued`]. See [`SCHEMA`]'s doc for schema 4 on why this
402    /// exists: an answer to a question `crate::conduct` filed about a
403    /// [`TaskStatus::Held`] task must not itself be what puts the task back
404    /// in the competition queue.
405    ///
406    /// `#[serde(default)]` so a queue file written before this field existed
407    /// reads as `None`; [`Task::unblock`] treats that the same as a task
408    /// blocked straight from `Queued`, unless surviving hold evidence says
409    /// otherwise.
410    #[serde(default)]
411    pub blocked_from: Option<TaskStatus>,
412    /// Last change of blocking targets, independent of explanation or delivery writes.
413    #[serde(default)]
414    pub blocked_at: Option<Timestamp>,
415    /// Questions `crate::conduct` asked about this task that the operator has
416    /// since answered, oldest first — what was asked, and what they said.
417    ///
418    /// A blocking question's id leaves [`Task::blocked_by`] the moment
419    /// [`crate::ask::QuestionStatus::Answered`] is observed, but the id alone
420    /// tells nobody what was decided. This is what carries the answer's
421    /// *content* forward: into the next conductor prompt for this task, and
422    /// into the instruction handed to the next run — see `crate::daemon`'s
423    /// deterministic blocker resolution. Kept for the task's whole life, the
424    /// same as [`Task::runs`]: a release resets attempts, not evidence.
425    #[serde(default)]
426    pub answers: Vec<AnsweredQuestion>,
427    /// Ids of the `crate::triage` questions whose answer has been applied to
428    /// this task. Unlike [`Task::hold_reason`], [`Task::release`] and every
429    /// hold transition leave it alone, so an answer is applied at most once
430    /// however many times the task is held again. See [`SCHEMA`]'s doc for
431    /// schema 5. `#[serde(default)]` so an older record reads as empty.
432    #[serde(default)]
433    pub triage_applied: Vec<String>,
434    /// Ids of the `magi ask` questions whose [`crate::ask::ChoiceAction`] has
435    /// been applied to this task. Separate from [`Task::triage_applied`]
436    /// (a different producer) and, like it, untouched by [`Task::release`],
437    /// so one answer acts at most once however often the task is held again.
438    /// `#[serde(default)]` so an older record reads as empty.
439    #[serde(default)]
440    pub actions_applied: Vec<String>,
441    /// The operator's "resume" answer to a triage question, kept until the
442    /// task actually runs (or is done) so `crate::conduct` cannot silently
443    /// undo it and `crate::triage` can tell that a hold it sees now came
444    /// *after* the answer. See [`OperatorResume`]. `#[serde(default)]`.
445    #[serde(default)]
446    pub resume_override: Option<OperatorResume>,
447    /// Latest explicit operator release. Historical answers remain audit evidence;
448    /// this approval takes precedence over their earlier waiting premises.
449    #[serde(default)]
450    pub resumption: Option<Resumption>,
451    /// Earlier explicit approvals retained for audit and instruction suffix matching.
452    #[serde(default)]
453    pub resumption_history: Vec<Resumption>,
454    /// Latest explicit release or manual hold, used to reject delayed answers.
455    #[serde(default)]
456    pub operator_decided_at: Option<Timestamp>,
457    /// Set by `crate::conduct` when it chooses `Review` recovery for a task
458    /// whose branch survived a blocked run: the branch to reopen with
459    /// `crate::graph::Runner::review` instead of competing from scratch.
460    ///
461    /// Requeues the task the same way [`Task::release`] does, so it is
462    /// picked up by the ordinary loop; `crate::daemon` reads this field once,
463    /// when it actually starts the run, and clears it either way — consumed
464    /// on success, dropped if the branch no longer exists by then. Never set
465    /// from the conductor's own words: `crate::daemon` derives the branch
466    /// name itself from the task's last run, so a hallucinated branch can
467    /// never reach here.
468    #[serde(default)]
469    pub review_branch: Option<String>,
470    /// Authored criteria when the retained recovery review was selected.
471    #[serde(default)]
472    pub review_instruction: Option<String>,
473    /// Explicit public implementation selected by the operator; never inferred from prose.
474    #[serde(default)]
475    pub pr_recovery: Option<crate::pr_recovery::Selection>,
476    /// A release deliberately starts a new competition instead of resuming
477    /// the prior run. History remains as evidence in `runs`.
478    #[serde(default)]
479    pub fresh_start: bool,
480    /// Explicit replacement selection; persists through dispatch and resume.
481    #[serde(default)]
482    pub replacement: Option<crate::merge_decision::Replacement>,
483    /// Marked by an operator (`magi task interrupt`) to ask `magi serve` to
484    /// run this one ahead of whatever it already has in flight, once
485    /// `[daemon] pause_for_interrupts` is on - see
486    /// `crate::daemon::advance_interrupt`. Never set by the loop itself, and
487    /// deliberately a different operation from [`Task::set_priority`]: a
488    /// priority only reorders the queue a claim has not reached yet, while
489    /// this asks a run already in flight to park at its next safe boundary
490    /// and step aside. `#[serde(default)]` so a queue file written before
491    /// this field existed still reads, as `false` - no task interrupts
492    /// anything unless asked to, exactly as before.
493    #[serde(default)]
494    pub interrupt: bool,
495    /// Marked by `magi task add --urgent` or `magi task urgent`.
496    /// `crate::daemon::poll` dispatches this task through its own one-slot `urgent_sem` the moment it is
497    /// runnable, in addition to whatever is already running under the
498    /// ordinary `[daemon] max_concurrent_runs` pool - never instead of it,
499    /// and never by pausing or otherwise touching that run. This is the
500    /// opposite direction from [`Task::interrupt`]: that one asks a run
501    /// already in flight to step aside; this one never asks anything to
502    /// step aside, it only spends one additional, temporary concurrency
503    /// slot. The two are independent and may both be set on the same task,
504    /// but this exemption stops at `[daemon] pause_for_interrupts`'s own
505    /// park/resume handoff (75dd): while an interrupt sequence is actively
506    /// parking, running, or resuming - its own, or an unrelated task's -
507    /// `crate::daemon::interrupt_gate` withholds an urgent candidate exactly
508    /// like an ordinary one, never exempted. 75dd's "at most one run, ever,
509    /// at once" guarantee takes precedence, because the alternative is a run
510    /// still genuinely in flight (only *asked* to park, not yet gone) ending
511    /// up alongside a second one this feature let through - the very thing
512    /// that guarantee exists to rule out.
513    ///
514    /// `#[serde(default)]` so a queue file written before this field existed
515    /// still reads, as `false` - no task claims the urgent slot unless asked
516    /// to, exactly as before.
517    #[serde(default)]
518    pub urgent: bool,
519    /// Set by [`Task::new`], cleared once a queue has stored the task. Never
520    /// persisted: a task read from disk is always an existing one. While set,
521    /// [`Queue::put`] reserves the id (see [`Queue::file_with`]) instead of
522    /// merging into whatever record already carries it.
523    #[serde(skip)]
524    pub(crate) unfiled: bool,
525    /// Files (typically screenshots) copied into `<id>.attachments/` beside
526    /// this task's file by [`Queue::attach`], as bare validated names. The
527    /// copy is what makes them reach the implementer: the original may be
528    /// cleaned up long before the task runs. Untouched by [`Task::edit`].
529    /// `#[serde(default)]` so an older record reads as empty.
530    #[serde(default)]
531    pub attachments: Vec<String>,
532    /// Set on a task `crate::followup` filed from the findings a merged run
533    /// left open: which run and findings it carries, and how many follow-ups
534    /// deep it is. `None` for every ordinary task. `#[serde(default)]`.
535    #[serde(default)]
536    pub followup: Option<FollowUp>,
537    /// Id of the chat this task descends from: set by [`Task::new`] when a
538    /// chat files it, inherited by the follow-ups `crate::followup` files, so
539    /// the provenance survives deleting an ancestor. `None` for a task written
540    /// before the field existed (`crate::consult` then walks the ancestry).
541    /// `#[serde(default)]`.
542    #[serde(default)]
543    pub origin_chat: Option<String>,
544    /// The command-line choices (`--merge`, `-c`, `--seed`, ...) of the
545    /// `magi run` / `magi review` that filed this task, applied on top of the
546    /// repository's config by whoever executes it - a daemon included - so
547    /// handing the task over does not change what it does. A count edit on an
548    /// ordinary queue task explicitly inherits daemon options instead.
549    /// `#[serde(default)]`.
550    #[serde(default)]
551    pub overrides: Option<RunOverrides>,
552    /// The branch an explicit `magi review <branch>` asked to review. Unlike
553    /// [`Task::review_branch`] (a recovery choice the daemon consumes and
554    /// drops back to a competition when the branch is gone), this is the whole
555    /// request: it is never consumed, and a missing branch fails the attempt
556    /// rather than paying for an implementation nobody asked for.
557    /// `#[serde(default)]`.
558    #[serde(default)]
559    pub review_of: Option<String>,
560    /// When the current hold began. Set by the transitions into
561    /// [`TaskStatus::Held`] (a re-hold keeps it), cleared by
562    /// [`Task::release`]. `crate::notices` uses it as the identity of the
563    /// cause: a question filed before it is about something older.
564    #[serde(default)]
565    pub held_at: Option<Timestamp>,
566    /// Why the run parked, while the task is [`TaskStatus::Parked`]. Kept apart
567    /// from [`Task::last_error`] because nothing failed. Cleared by every
568    /// transition out of the parked state. `#[serde(default)]`.
569    #[serde(default)]
570    pub park_reason: Option<String>,
571    /// The last final state reported to the originating chat. See
572    /// [`Task::chat_notice_due`]. Never cleared by a release or requeue.
573    #[serde(default)]
574    pub chat_notice: Option<ChatNotice>,
575    /// Delivery ledger, unioned under the task lock even on stale writes.
576    #[serde(default)]
577    pub chat_notices: Vec<String>,
578    /// Structured producer identity of the unresolved hold. Requeues and prose
579    /// updates retain it; a settled run or a distinct startup stage replaces it.
580    #[serde(default)]
581    pub hold_notice_cause: Option<NoticeCause>,
582    /// Append-only: one entry per pass over a run that the loop settled. A
583    /// resumed run is one run with several passes, and `attempts` counts
584    /// claims net of refunds, so the two are not the same number; this says
585    /// which pass cost what. Kept across a release.
586    #[serde(default)]
587    pub passes: Vec<TaskPass>,
588    /// When the loop claimed this task for its latest dispatch. Stamped under
589    /// the claim, before any slow start-up work, so it is the real dispatch
590    /// order; `record_pass_at` prefers it over the graph's own pass start.
591    #[serde(default)]
592    pub dispatched_at: Option<Timestamp>,
593    /// Durable failed-input budget, unaffected by automatic release/requeue.
594    #[serde(default)]
595    pub recovery_budget: Option<RecoveryBudget>,
596    /// Inputs resolved before starting the current automatic attempt. The
597    /// settlement uses this same identity even for review-only runs.
598    #[serde(default)]
599    pub recovery_admission: Option<RecoveryAdmission>,
600    /// Previously seen input generations. Switching inputs away and back does
601    /// not refund an exhausted generation without explicit operator authority.
602    #[serde(default)]
603    pub recovery_generations: Vec<RecoveryBudget>,
604    /// When the task was filed.
605    pub created_at: Timestamp,
606    /// Last change to this file.
607    pub updated_at: Timestamp,
608}
609
610/// Admission identity shared by start and settlement.
611#[derive(Debug, Clone, Serialize, Deserialize)]
612pub struct RecoveryAdmission {
613    /// Fetched upstream commit before any run-local rebase.
614    pub base: String,
615    /// Resolved instruction references and explicit review source.
616    pub inputs: String,
617}
618
619/// Failures against one recovery input generation. An owner release explicitly
620/// starts a new generation; a machine requeue never replenishes it.
621#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct RecoveryBudget {
623    /// Authored instructions, not generated review scaffolding.
624    pub instruction: String,
625    /// Upstream commit used to prepare this generation.
626    pub base: String,
627    /// Referenced input commits, including their resolved identities.
628    pub inputs: String,
629    /// Stable processing stage of the last failure.
630    pub stage: String,
631    /// Failed passes, independent of the resettable claim counter.
632    pub failures: usize,
633    /// Fingerprint of actual candidate patches; equivalent rebased commits do
634    /// not count as progress. Unknown inputs never replenish the budget.
635    #[serde(default)]
636    pub progress: Option<String>,
637}
638
639fn recovery_progress(state: &crate::run::RunState) -> Option<String> {
640    use crate::proc::Quiet as _;
641    use std::hash::{Hash, Hasher};
642    let mut patches = Vec::new();
643    for candidate in state.candidates.iter().filter(|c| c.commits > 0) {
644        let output = std::process::Command::new("git")
645            .current_dir(&state.repo)
646            .args(["diff", "--binary", &state.base_commit, &candidate.branch])
647            .quiet()
648            .output()
649            .ok()?;
650        if !output.status.success() {
651            return None;
652        }
653        patches.push(output.stdout);
654    }
655    patches.sort();
656    patches.dedup();
657    let mut hash = std::collections::hash_map::DefaultHasher::new();
658    patches.hash(&mut hash);
659    Some(format!("{:x}", hash.finish()))
660}
661
662/// The implementation inputs a recovery budget is keyed on: the explicit,
663/// SHA-pinned sources only, never references parsed from instruction prose.
664/// No sources is `[]`, the key an input-free task always had.
665pub(crate) fn recovery_inputs(sources: &crate::refs::Sources) -> String {
666    serde_json::to_string(
667        &sources
668            .start_from
669            .iter()
670            .map(|i| ("start", &i.token, &i.sha))
671            .chain(
672                sources
673                    .pick_commits
674                    .iter()
675                    .map(|i| ("pick", &i.token, &i.sha)),
676            )
677            .collect::<Vec<_>>(),
678    )
679    .unwrap_or_default()
680}
681
682/// Facts a producer can independently attest about a held outcome.
683#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684pub struct NoticeCause {
685    /// Processing stage or verified external evidence.
686    pub stage: String,
687    /// Producer-verified blocker object, independent of the displayed error.
688    /// Omitted when absent to preserve existing persisted notice keys.
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub target: Option<String>,
691    /// An actual run that reached settlement; absent for preflight failures.
692    pub run: Option<String>,
693    /// The settled pass, so a resumed run can report a new failure.
694    pub pass: Option<usize>,
695}
696
697/// Current and legacy completion keys; legacy run counts contain only digits.
698pub(crate) fn is_done_notice_key(key: &str) -> bool {
699    key == "done"
700        || key
701            .strip_prefix("done#")
702            .is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()))
703}
704
705/// What a settled pass did to the attempt budget.
706#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
707#[serde(rename_all = "snake_case")]
708pub enum PassCost {
709    /// The attempt stays counted.
710    Spent,
711    /// The attempt was handed back.
712    Refunded,
713}
714
715/// One settled pass over a run. See [`Task::passes`].
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
717pub struct TaskPass {
718    /// The run the pass belongs to.
719    pub run: String,
720    /// [`crate::run::RunPass::index`] in that run.
721    pub pass_index: usize,
722    /// What it did to the budget.
723    pub cost: PassCost,
724    /// `attempts` once this pass was settled.
725    pub attempts_after: usize,
726    /// When this pass began, retained across daemon upgrades.
727    #[serde(default)]
728    pub started_at: Option<Timestamp>,
729    /// When the loop settled it.
730    pub settled_at: Timestamp,
731    /// Why the pass parked, when it did.
732    #[serde(default)]
733    pub parked: Option<crate::run::ParkCause>,
734}
735
736/// What became of a completion notice. See [`Task::chat_notice`].
737#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
738#[serde(rename_all = "lowercase")]
739pub enum ChatNoticeOutcome {
740    /// Queued as a draft of the originating talk.
741    Posted,
742    /// The talk is gone or closed; nothing was queued, and nothing will be.
743    Skipped,
744}
745
746/// The final state of a chat-filed task that was already reported (or skipped).
747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748pub struct ChatNotice {
749    /// Identity of the cause, see [`Task::chat_notice_key`].
750    pub key: String,
751    /// When it was recorded.
752    pub at: Timestamp,
753    /// Posted or skipped.
754    pub outcome: ChatNoticeOutcome,
755}
756
757/// Command-line choices carried by a task. See [`Task::overrides`].
758#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
759pub struct RunOverrides {
760    /// Count edits on ordinary queued tasks inherit the loop's config and merge.
761    /// False for direct-run overrides and old records, even when otherwise empty.
762    #[serde(default)]
763    pub inherit_daemon_options: bool,
764    /// `--merge`: `none`, `local` or `pr`.
765    #[serde(default)]
766    pub merge: Option<String>,
767    /// `-c` / `--implementers`: parallel implementations (persisted as `candidates`).
768    #[serde(default)]
769    pub candidates: Option<usize>,
770    /// `-j`: independent judges.
771    #[serde(default)]
772    pub judges: Option<usize>,
773    /// `--reviewers`: reviewers per round.
774    #[serde(default)]
775    pub reviewers: Option<usize>,
776    /// `--review-rounds`.
777    #[serde(default)]
778    pub review_rounds: Option<usize>,
779    /// `--seed`.
780    #[serde(default)]
781    pub seed: Option<u64>,
782    /// `--config`, absolute: the daemon's own working directory differs.
783    #[serde(default)]
784    pub config: Option<PathBuf>,
785}
786
787impl RunOverrides {
788    /// Lay `other` over these: every choice `other` made wins, the rest stay.
789    pub fn merge_over(&mut self, other: &RunOverrides) {
790        macro_rules! take {
791            ($($f:ident),*) => {$(
792                if other.$f.is_some() {
793                    self.$f.clone_from(&other.$f);
794                }
795            )*};
796        }
797        take!(
798            merge,
799            candidates,
800            judges,
801            reviewers,
802            review_rounds,
803            seed,
804            config
805        );
806    }
807
808    /// Apply these on top of `config`.
809    pub fn apply(&self, config: &mut crate::config::Config) {
810        if let Some(n) = self.candidates {
811            config.graph.implementers = n;
812        }
813        if let Some(n) = self.judges {
814            config.graph.judges = n;
815        }
816        if let Some(n) = self.reviewers {
817            config.graph.reviewers = n;
818        }
819        if let Some(n) = self.review_rounds {
820            config.graph.review_rounds = n;
821        }
822        if let Some(m) = &self.merge
823            && let Ok(mode) = crate::daemon::merge_mode(m)
824        {
825            config.merge.mode = mode;
826        }
827        if let Some(s) = self.seed {
828            config.blind.seed = Some(s);
829        }
830    }
831}
832
833/// What a follow-up task came from. See [`Task::followup`].
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835pub struct FollowUp {
836    /// The merged run whose open findings this task carries.
837    pub run: String,
838    /// The queue task that run served, when it had one.
839    #[serde(default)]
840    pub origin_task: Option<String>,
841    /// The merged pull request.
842    pub pr: String,
843    /// Ids of the findings (e.g. `R3-1-1`) this task covers.
844    pub findings: Vec<String>,
845    /// Follow-up depth: 1 for a follow-up of an ordinary task's run, 2 for a
846    /// follow-up of that, and so on. An ordinary task is generation 0.
847    pub generation: u32,
848}
849
850/// Durable scope of an explicit operator resumption, surviving run startup.
851#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
852pub struct Resumption {
853    /// Monotonic count of explicit resumptions.
854    pub generation: u64,
855    /// Mutation path that applied the approval.
856    pub source: String,
857    /// Answered question, if the approval came from one.
858    pub question_id: Option<String>,
859    /// When this approval was applied.
860    pub at: Timestamp,
861    /// Authored instruction approved at release.
862    pub instruction: String,
863    /// Number of historical answers superseded as waiting premises.
864    pub answers_through: usize,
865    /// Legacy run-list boundary retained for record compatibility. Evidence
866    /// uses approval time and run passes because resumes retain the same id.
867    pub runs_through: usize,
868    /// Previous hold or dependency reason cleared by this release.
869    pub cleared_reason: Option<String>,
870    /// Verified evidence versions already used to block after this approval.
871    #[serde(default)]
872    pub blocking_evidence: Vec<String>,
873}
874
875/// A triage "resume" answer and what became of it. See
876/// [`Task::resume_override`].
877#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
878pub struct OperatorResume {
879    /// 回答された Question の ID。Question を伴わない再開では空文字列。
880    pub question_id: String,
881    /// When the answer was applied.
882    pub at: Timestamp,
883    /// The reason `crate::conduct` gave for holding the task again after the
884    /// answer, if it did. The conductor may do this once.
885    #[serde(default)]
886    pub conductor_rehold: Option<String>,
887    /// The operator answered "resume" a second time, to the question about
888    /// that contradiction: the conductor may no longer hold this task.
889    #[serde(default)]
890    pub forced: bool,
891    /// Set when the answer was a structured `resume` action on a `magi ask`
892    /// choice ([`crate::ask::ChoiceAction::Resume`]): the run the operator
893    /// chose to continue. Its presence also stops `crate::conduct` from
894    /// requeuing the task into a fresh competition, which would discard
895    /// exactly the run that was named. Cleared with the rest of the record
896    /// once the task runs. `#[serde(default)]` so older records read as `None`.
897    #[serde(default)]
898    pub pinned_run: Option<String>,
899}
900
901/// One question `crate::conduct` asked about a task, and what the operator
902/// said back. See [`Task::answers`].
903#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
904pub struct AnsweredQuestion {
905    /// The question as asked, e.g. [`crate::ask::Question::summary`].
906    pub question: String,
907    /// What the operator answered.
908    pub answer: String,
909}
910
911impl Task {
912    /// File a new task. Persist it with [`Queue::put`].
913    pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
914        let now = Timestamp::now();
915        let origin_chat = match &source {
916            Source::Agent { run, node } if node == CHAT_NODE => Some(run.clone()),
917            _ => None,
918        };
919        Self {
920            schema: SCHEMA,
921            id: new_id(),
922            title,
923            instruction,
924            sources: crate::refs::Sources::default(),
925            repo,
926            source,
927            chat: None,
928            priority: 0,
929            solo: false,
930            status: TaskStatus::Queued,
931            attempts: 0,
932            attempt_limit: None,
933            runs: Vec::new(),
934            last_error: None,
935            hold_reason: None,
936            hold_source: None,
937            diagnostic: None,
938            blocked_by: Vec::new(),
939            block_reason: None,
940            blocked_from: None,
941            blocked_at: None,
942            answers: Vec::new(),
943            triage_applied: Vec::new(),
944            actions_applied: Vec::new(),
945            resume_override: None,
946            resumption: None,
947            resumption_history: Vec::new(),
948            operator_decided_at: None,
949            review_branch: None,
950            review_instruction: None,
951            pr_recovery: None,
952            fresh_start: false,
953            replacement: None,
954            interrupt: false,
955            urgent: false,
956            unfiled: true,
957            attachments: Vec::new(),
958            followup: None,
959            origin_chat,
960            overrides: None,
961            review_of: None,
962            held_at: None,
963            park_reason: None,
964            chat_notice: None,
965            chat_notices: Vec::new(),
966            hold_notice_cause: None,
967            passes: Vec::new(),
968            dispatched_at: None,
969            recovery_budget: None,
970            recovery_admission: None,
971            recovery_generations: Vec::new(),
972            created_at: now,
973            updated_at: now,
974        }
975    }
976
977    /// The chat this task itself says it came from: the recorded
978    /// [`Task::origin_chat`], else its own source when a chat filed it.
979    pub fn chat_talk(&self) -> Option<&str> {
980        if let Some(id) = &self.origin_chat {
981            return Some(id);
982        }
983        match &self.source {
984            Source::Agent { run, node } if node == CHAT_NODE => Some(run),
985            _ => None,
986        }
987    }
988
989    /// The talk that filed this task *itself* (`Source::Agent` with
990    /// [`CHAT_NODE`]); unlike [`Task::chat_talk`] a follow-up's inherited
991    /// `origin_chat` does not count.
992    pub fn filed_by_chat(&self) -> Option<&str> {
993        match &self.source {
994            Source::Agent { run, node } if node == CHAT_NODE => Some(run),
995            _ => None,
996        }
997    }
998
999    /// Identity of a material outcome. Prose and wall-clock times are never
1000    /// evidence. Dependencies are a set; held causes survive intermediate states.
1001    pub fn chat_notice_key(&self) -> Option<String> {
1002        match self.status {
1003            TaskStatus::Done => Some("done".to_owned()),
1004            TaskStatus::Held => {
1005                let cause = self
1006                    .hold_notice_cause
1007                    .clone()
1008                    .unwrap_or_else(|| self.unresolved_hold_cause());
1009                Some(format!(
1010                    "cause:{}",
1011                    serde_json::to_string(&cause).expect("cause")
1012                ))
1013            }
1014            TaskStatus::Blocked => {
1015                let ids: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1016                // Only new verified blocking evidence versions the dependency
1017                // set. Freezing an unrelated hold on release must not do so.
1018                let evidence = self
1019                    .hold_notice_cause
1020                    .as_ref()
1021                    .filter(|c| c.stage.starts_with("conductor-evidence:"));
1022                Some(format!(
1023                    "dependencies:{}",
1024                    serde_json::to_string(&(ids, evidence)).expect("ids")
1025                ))
1026            }
1027            _ => None,
1028        }
1029    }
1030
1031    fn unresolved_hold_cause(&self) -> NoticeCause {
1032        NoticeCause {
1033            stage: "hold".into(),
1034            target: None,
1035            run: self.runs.last().cloned(),
1036            pass: None,
1037        }
1038    }
1039
1040    /// Exact old delivery identity, only for reconciling an existing record.
1041    pub(crate) fn legacy_chat_notice_key(&self) -> Option<String> {
1042        let key = match self.status {
1043            TaskStatus::Held if self.hold_notice_cause.is_none() => Some(format!(
1044                "held#{}",
1045                self.held_at.map(|t| t.to_string()).unwrap_or_default()
1046            )),
1047            TaskStatus::Blocked => Some(format!(
1048                "blocked#{}#{}",
1049                self.blocked_by.join(","),
1050                self.block_reason.as_deref().unwrap_or_default()
1051            )),
1052            _ => None,
1053        };
1054        key.filter(|key| self.legacy_notice_matches(key))
1055    }
1056
1057    pub(crate) fn legacy_notice_matches(&self, key: &str) -> bool {
1058        match self.status {
1059            TaskStatus::Held if self.hold_notice_cause.is_none() => key
1060                .strip_prefix("held#")
1061                .is_some_and(|time| time.is_empty() || time.parse::<Timestamp>().is_ok()),
1062            TaskStatus::Blocked
1063                if self
1064                    .hold_notice_cause
1065                    .as_ref()
1066                    .is_none_or(|c| !c.stage.starts_with("conductor-evidence:")) =>
1067            {
1068                key.strip_prefix("blocked#").is_some_and(|tail| {
1069                    let Some((ids, _)) = tail.split_once('#') else {
1070                        return false;
1071                    };
1072                    let old: std::collections::BTreeSet<_> =
1073                        ids.split(',').filter(|id| !id.is_empty()).collect();
1074                    let current: std::collections::BTreeSet<_> =
1075                        self.blocked_by.iter().map(String::as_str).collect();
1076                    old == current
1077                })
1078            }
1079            _ => false,
1080        }
1081    }
1082
1083    fn import_chat_notice(&mut self) {
1084        if let Some(n) = &self.chat_notice {
1085            let key = if is_done_notice_key(&n.key) {
1086                "done".to_owned()
1087            } else if self.legacy_notice_matches(&n.key) {
1088                self.chat_notice_key().unwrap_or_else(|| n.key.clone())
1089            } else {
1090                n.key.clone()
1091            };
1092            if !self.chat_notices.contains(&key) {
1093                self.chat_notices.push(key);
1094            }
1095        }
1096    }
1097
1098    /// A producer records the processing stage and actual run/pass, never the
1099    /// error's rendered prose. No run means startup made no implementation progress.
1100    pub(crate) fn notice_cause(&mut self, stage: &str, run: Option<&str>, pass: Option<usize>) {
1101        self.import_chat_notice();
1102        self.hold_notice_cause = Some(NoticeCause {
1103            stage: stage.to_owned(),
1104            target: None,
1105            run: run.map(str::to_owned),
1106            pass,
1107        });
1108    }
1109
1110    /// Startup retries cannot replace an unresolved run outcome without doing
1111    /// work. Distinct startup stages can still explain a newly encountered blocker.
1112    pub(crate) fn startup_notice_cause(&mut self, stage: &str) {
1113        let recovery_only = matches!(
1114            stage,
1115            "startup-source" | "startup-candidates" | "startup-public-head"
1116        );
1117        match self.hold_notice_cause.as_ref() {
1118            Some(cause) if recovery_only && !cause.stage.starts_with("startup-") => return,
1119            None if recovery_only
1120                && self
1121                    .chat_notice
1122                    .as_ref()
1123                    .is_some_and(|n| !is_done_notice_key(&n.key)) =>
1124            {
1125                return;
1126            }
1127            _ => {}
1128        }
1129        self.notice_cause(stage, None, None);
1130    }
1131
1132    /// Apply a preflight failure without allowing Running's generic failure
1133    /// identity to overwrite the producer's stage or its unresolved recovery anchor.
1134    pub(crate) fn fail_startup(
1135        &mut self,
1136        stage: &str,
1137        why: impl Into<String>,
1138        max_attempts: usize,
1139    ) {
1140        let unresolved = self.hold_notice_cause.clone();
1141        self.fail(why, max_attempts);
1142        self.hold_notice_cause = unresolved;
1143        self.startup_notice_cause(stage);
1144    }
1145
1146    /// A startup failure tied to a concrete object, such as a missing attachment.
1147    pub(crate) fn fail_startup_target(
1148        &mut self,
1149        stage: &str,
1150        target: String,
1151        why: impl Into<String>,
1152        max_attempts: usize,
1153    ) {
1154        self.fail_startup(stage, why, max_attempts);
1155        if let Some(cause) = self.hold_notice_cause.as_mut() {
1156            cause.target = Some(target);
1157        }
1158    }
1159
1160    /// A chat-filed task owes each material final outcome at most one notice.
1161    pub fn chat_notice_due(&self) -> Option<String> {
1162        self.filed_by_chat()?;
1163        if self.schema < 13 {
1164            return None;
1165        }
1166        let key = self.chat_notice_key()?;
1167        if self.chat_notices.contains(&key) {
1168            return None;
1169        }
1170        match &self.chat_notice {
1171            Some(n)
1172                if n.key == key
1173                    || (key == "done" && is_done_notice_key(&n.key))
1174                    || self.legacy_notice_matches(&n.key) =>
1175            {
1176                None
1177            }
1178            _ => Some(key),
1179        }
1180    }
1181
1182    /// Short form used in reports, matching a run's short id.
1183    pub fn short(&self) -> &str {
1184        short(&self.id)
1185    }
1186
1187    /// Record that triage question `question_id`'s answer has been applied.
1188    pub fn mark_triage_applied(&mut self, question_id: &str) {
1189        if !self.triage_applied(question_id) {
1190            self.triage_applied.push(question_id.to_owned());
1191        }
1192    }
1193
1194    /// Has the action of `magi ask` question `question_id` already been
1195    /// applied, or is it being applied now? See [`Task::actions_applied`].
1196    pub fn action_applied(&self, question_id: &str) -> bool {
1197        self.actions_applied.iter().any(|id| id == question_id)
1198    }
1199
1200    /// Record that `magi ask` question `question_id`'s action was applied.
1201    pub fn mark_action_applied(&mut self, question_id: &str) {
1202        if !self.action_applied(question_id) {
1203            self.actions_applied.push(question_id.to_owned());
1204        }
1205    }
1206
1207    /// Has triage question `question_id`'s answer already been applied?
1208    pub fn triage_applied(&self, question_id: &str) -> bool {
1209        self.triage_applied.iter().any(|id| id == question_id)
1210    }
1211
1212    /// Record that a run has started for this task.
1213    /// Move an already linked run to the end so it remains the current attempt,
1214    /// with one history entry per run and separate pass records for each resume.
1215    ///
1216    /// Clears [`Task::interrupt`]: a mark to run ahead of whatever else is
1217    /// in flight is fulfilled the moment this task actually gets its turn,
1218    /// dispatched same as any other. Without this, a task whose run fails
1219    /// and requeues - still `runnable`, still carrying the mark from its
1220    /// first attempt - would keep re-triggering `crate::daemon`'s interrupt
1221    /// scheduler and re-parking whatever it interrupted on every later
1222    /// boundary, for as long as its attempts hold out, instead of the
1223    /// one-shot "let this go next" the mark is meant to be.
1224    pub fn start(&mut self, run: String) {
1225        self.stamp_schema();
1226        self.status = TaskStatus::Running;
1227        self.attempts += 1;
1228        self.runs.retain(|id| id != &run);
1229        self.runs.push(run);
1230        self.last_error = None;
1231        self.park_reason = None;
1232        self.fresh_start = false;
1233        self.interrupt = false;
1234        // The answer has been honoured: the task got its turn.
1235        self.resume_override = None;
1236    }
1237
1238    /// Note that `run` serves this task without otherwise changing its life:
1239    /// no status, attempt or interrupt change, unlike [`Task::start`]. For a
1240    /// run somebody else started on the task's behalf (`magi run --task`);
1241    /// idempotent. Returns whether anything was added.
1242    pub fn link_run(&mut self, run: &str) -> bool {
1243        if self.runs.iter().any(|r| r == run) {
1244            return false;
1245        }
1246        self.runs.push(run.to_owned());
1247        true
1248    }
1249
1250    /// Record a successful run.
1251    ///
1252    /// Both `magi task done` and `POST /api/queue/{id}/done` can close a held
1253    /// *or blocked* task directly, with no release in between, so this clears
1254    /// `hold_reason` and `blocked_by`/`block_reason` the same way
1255    /// [`Task::release`] does. Otherwise a task held for "waiting on 3ed9", or
1256    /// blocked on a dependency that never actually finished, and then closed
1257    /// as done without ever being released would still read as waiting on
1258    /// something in `magi task show` and on its card, after it no longer is.
1259    pub fn succeed(&mut self) {
1260        self.stamp_schema();
1261        self.status = TaskStatus::Done;
1262        self.resume_override = None;
1263        self.last_error = None;
1264        self.park_reason = None;
1265        self.hold_reason = None;
1266        self.hold_source = None;
1267        self.diagnostic = None;
1268        self.blocked_by.clear();
1269        self.block_reason = None;
1270        self.blocked_from = None;
1271    }
1272
1273    /// Finish the task because the run's whole change turned out to be on the
1274    /// base already ([`crate::run::RunStatus::AlreadyInBase`]): `Done`, with
1275    /// `note` kept in [`Task::last_error`] so `magi task show` says why, and
1276    /// the attempt refunded. Nothing went wrong and nothing was spent
1277    /// misbehaving, so this is neither [`Task::fail`] nor
1278    /// [`Task::handed_off`]; and unlike [`Task::succeed`] it does not pretend
1279    /// the run landed anything.
1280    pub fn already_landed(&mut self, note: impl Into<String>) {
1281        self.succeed();
1282        self.attempts = self.attempts.saturating_sub(1);
1283        self.last_error = Some(note.into());
1284    }
1285
1286    /// Earlier attempts at this task that a later one has since made moot —
1287    /// empty unless the task is [`TaskStatus::Done`] *and* `last_run_succeeded`
1288    /// says `runs.last()` is actually why.
1289    ///
1290    /// `runs` is oldest first, and [`Task::start`] is the only thing that
1291    /// pushes to it, always right before the attempt it names either succeeds
1292    /// or fails; `succeed` itself never touches `runs`. So whenever `status`
1293    /// is `Done` *because* the loop itself saw that last attempt land
1294    /// (`Merged`/`Ready`), everything before it in the same list is a retry
1295    /// this task no longer needs. But `succeed` is also reachable directly —
1296    /// `magi task done`, the web UI's equivalent, and the conductor's
1297    /// `Recovery::Done` all call it on a task in *any* status, including one
1298    /// whose last recorded attempt never landed at all (closed by hand after
1299    /// a merge magi's own loop never saw). `runs.last()` alone cannot tell
1300    /// those two cases apart — that requires the caller to have actually
1301    /// looked at that run's own `status`, which this module has no way to
1302    /// do — so `last_run_succeeded` is the caller's answer to exactly that
1303    /// question, not something this function can derive from `Task` alone.
1304    ///
1305    /// Deliberately narrower than [`Queue::superseded`]'s "every earlier
1306    /// attempt has a later one" walk, which fires the moment a retry starts
1307    /// even though the retry itself might still fail: that reading is right
1308    /// for the web UI's "a newer attempt exists, go look at that one
1309    /// instead" note, but wrong for deciding a run no longer needs a human's
1310    /// attention, which is only true once the task's story has actually
1311    /// ended well. Two Blocked runs sitting side by side while a third
1312    /// attempt is still in flight must not be touched by this — see
1313    /// `daemon::supersede_prior_runs`, the caller that turns this list into
1314    /// rewritten `run.json` files.
1315    pub fn superseded_attempts(&self, last_run_succeeded: bool) -> &[String] {
1316        if self.status != TaskStatus::Done || !last_run_succeeded || self.runs.len() < 2 {
1317            return &[];
1318        }
1319        &self.runs[..self.runs.len() - 1]
1320    }
1321
1322    /// The attempt right after the last occurrence of `run`, if there is one.
1323    /// Resuming records the same run again; it cannot supersede itself.
1324    ///
1325    /// The one place "a later attempt replaced this run" is decided:
1326    /// [`Queue::superseded`] and [`Queue::superseded_by`] (the web UI's
1327    /// note) read it, and so does [`Task::earlier_attempts`], so what is shown
1328    /// and what `crate::handover` acts on cannot disagree.
1329    pub fn successor_of(&self, run: &str) -> Option<&String> {
1330        let pos = self.runs.iter().rposition(|r| r == run)?;
1331        self.runs.get(pos + 1)
1332    }
1333
1334    /// Every run already recorded for this task, i.e. every run that an
1335    /// attempt being minted *right now* supersedes.
1336    ///
1337    /// The new run is not in [`Task::runs`] yet when it decides what to take
1338    /// over (`Task::start` pushes it afterwards), so "has a later attempt" is
1339    /// true of everything listed here by the time the new run exists.
1340    pub fn earlier_attempts(&self) -> &[String] {
1341        &self.runs
1342    }
1343
1344    /// Stamp [`Task::held_at`] on the way into [`TaskStatus::Held`]; a task
1345    /// that is already held keeps the time its hold began.
1346    /// A transition this build makes (start, hold, block, done) makes the task
1347    /// eligible for a chat notice even when it was filed under an older
1348    /// schema; only records no build has touched since stay silent
1349    /// ([`Task::chat_notice_due`]).
1350    fn stamp_schema(&mut self) {
1351        self.schema = self.schema.max(SCHEMA);
1352    }
1353
1354    fn note_held(&mut self) {
1355        self.stamp_schema();
1356        let continuing = self.status == TaskStatus::Held
1357            || (self.status == TaskStatus::Blocked
1358                && (self.blocked_from == Some(TaskStatus::Held)
1359                    || (self.blocked_from.is_none()
1360                        && (self.hold_reason.is_some() || self.hold_source.is_some()))));
1361        if !continuing || self.held_at.is_none() {
1362            self.held_at = Some(Timestamp::now());
1363        }
1364    }
1365
1366    /// Record a failed attempt. Out of attempts means held for a human, rather
1367    /// than retried until the money runs out.
1368    ///
1369    /// Clears [`Task::diagnostic`] unconditionally: it belongs to whatever run
1370    /// produced it, and a caller that has one for *this* attempt sets it
1371    /// itself right after calling this, once it knows the task actually ended
1372    /// up [`TaskStatus::Held`] - see `daemon::diagnostic`. Without the clear, a
1373    /// task released after a diagnosed hold and then failed again for an
1374    /// unrelated, undiagnosed reason (a config error, say) would go on
1375    /// showing the previous run's diagnostic as if it explained the new one.
1376    ///
1377    /// Also records `why` into [`Task::hold_reason`] when this ends up
1378    /// [`TaskStatus::Held`], so the notification and `magi task show` say the
1379    /// same thing as [`Task::last_error`] instead of leaving the hold's own
1380    /// reason blank.
1381    pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
1382        let why = why.into();
1383        if self.status == TaskStatus::Running {
1384            let run = self.runs.last().cloned();
1385            self.notice_cause("run-failed", run.as_deref(), Some(self.attempts));
1386        }
1387        self.diagnostic = None;
1388        self.park_reason = None;
1389        self.status = if self.attempts >= self.effective_attempt_limit(max_attempts) {
1390            self.note_held();
1391            self.hold_source = Some(HoldSource::Machine);
1392            self.hold_reason = Some(why.clone());
1393            TaskStatus::Held
1394        } else {
1395            TaskStatus::Failed
1396        };
1397        self.last_error = Some(why);
1398    }
1399
1400    /// Record an attempt that failed for a reason the task is not responsible
1401    /// for - the agent CLIs ran out of quota and the judging panel collapsed.
1402    ///
1403    /// This refunds the attempt on purpose. A quota window closing at 4am must
1404    /// not spend the backlog's retry budget: the operator would come back to a
1405    /// queue of held tasks that were never actually judged, and would have to
1406    /// release every one by hand to find out which had a real problem. The task
1407    /// goes back to `Failed`, which the loop retries, so a reset quota picks the
1408    /// work up where it stopped.
1409    pub fn stall(&mut self, why: impl Into<String>) {
1410        self.last_error = Some(why.into());
1411        self.diagnostic = None;
1412        self.attempts = self.attempts.saturating_sub(1);
1413        self.status = TaskStatus::Failed;
1414    }
1415
1416    /// Record a run that parked at a node boundary. Nothing failed: the attempt
1417    /// is refunded, [`Task::last_error`] is left as it was, the run history and
1418    /// `fresh_start` are kept so the next loop resumes the same run, and the
1419    /// reason goes to [`Task::park_reason`]. Runnable like a requeued task and
1420    /// never counted against `max_attempts`.
1421    pub fn park(&mut self, why: impl Into<String>) {
1422        self.diagnostic = None;
1423        self.attempts = self.attempts.saturating_sub(1);
1424        self.status = TaskStatus::Parked;
1425        self.park_reason = Some(why.into());
1426        // The variant and field are schema 11; stamp it so an older build
1427        // refuses the record instead of half-reading it.
1428        self.schema = self.schema.max(SCHEMA);
1429    }
1430
1431    /// Record a settled pass whose start time is unknown (see [`Self::record_pass_at`]).
1432    pub fn record_pass(
1433        &mut self,
1434        run: &str,
1435        pass_index: usize,
1436        attempts_before: usize,
1437        parked: Option<crate::run::ParkCause>,
1438    ) {
1439        self.record_pass_at(run, pass_index, None, attempts_before, parked);
1440    }
1441
1442    /// Record a settled pass. `attempts_before` is the count just before the
1443    /// settle, so a refund is read off what actually changed, not inferred.
1444    pub fn record_pass_at(
1445        &mut self,
1446        run: &str,
1447        pass_index: usize,
1448        started_at: Option<Timestamp>,
1449        attempts_before: usize,
1450        parked: Option<crate::run::ParkCause>,
1451    ) {
1452        if self
1453            .passes
1454            .iter()
1455            .any(|p| p.run == run && p.pass_index == pass_index)
1456        {
1457            return;
1458        }
1459        let cost = if self.attempts < attempts_before {
1460            PassCost::Refunded
1461        } else {
1462            PassCost::Spent
1463        };
1464        self.passes.push(TaskPass {
1465            run: run.to_owned(),
1466            pass_index,
1467            started_at: self.dispatched_at.or(started_at),
1468            cost,
1469            attempts_after: self.attempts,
1470            settled_at: Timestamp::now(),
1471            parked,
1472        });
1473        self.schema = self.schema.max(SCHEMA);
1474    }
1475
1476    /// Only the latest pass of the current, resumable upgrade-parked run
1477    /// retains its place. Explicit fresh starts and other park causes do not.
1478    pub(crate) fn upgrade_resume_key(&self) -> Option<(Timestamp, &str)> {
1479        if self.status != TaskStatus::Parked || self.fresh_start {
1480            return None;
1481        }
1482        let pass = self.passes.last()?;
1483        if self.runs.last() != Some(&pass.run)
1484            || !matches!(pass.parked, Some(crate::run::ParkCause::Upgrade { .. }))
1485        {
1486            return None;
1487        }
1488        // Before schema 19 the dispatch start was not copied to the task.
1489        // Settlement time is only an approximation for those old records.
1490        Some((pass.started_at.unwrap_or(pass.settled_at), &self.id))
1491    }
1492
1493    /// Whether this held task may only be released by an operator.
1494    ///
1495    /// Old files did not record a source. Preserve every such hold rather
1496    /// than guessing that it was automatic and risking duplicate work. New
1497    /// automatic holds record [`HoldSource::Machine`] and remain recoverable.
1498    pub fn operator_held(&self) -> bool {
1499        self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
1500    }
1501
1502    /// Take this task out of the loop's reach by an operator action.
1503    ///
1504    /// Clears `blocked_by`/`block_reason` unconditionally, the same as
1505    /// [`Task::release`] and for the same reason its own comment already
1506    /// gives: a human choosing to hold a *blocked* task overrides its wait
1507    /// outright, the same as it overrides an ordinary hold. Without this, a
1508    /// task held straight out of [`TaskStatus::Blocked`] - the web UI's "Hold"
1509    /// button is reachable on a blocked task, same as "Mark done" - kept
1510    /// reading as still waiting on a dependency it no longer had any claim on.
1511    pub fn hold_manual(&mut self, reason: Option<String>) {
1512        // A later explicit hold supersedes outstanding triage answers.
1513        self.operator_decided_at = Some(Timestamp::now());
1514        self.hold_unrequested_retry(reason);
1515    }
1516
1517    /// Keep a hand-started run from retrying automatically, without recording
1518    /// an operator decision that could invalidate its outstanding questions.
1519    pub(crate) fn hold_unrequested_retry(&mut self, reason: Option<String>) {
1520        self.park_reason = None;
1521        self.note_held();
1522        self.status = TaskStatus::Held;
1523        if reason.is_some() {
1524            self.hold_reason = reason;
1525        }
1526        self.hold_source = Some(HoldSource::Manual);
1527        self.blocked_by.clear();
1528        self.block_reason = None;
1529        self.blocked_from = None;
1530    }
1531
1532    /// Take this task out of the loop's reach during automatic recovery.
1533    ///
1534    /// Clears `blocked_by`/`block_reason` for the same reason
1535    /// [`Task::hold_manual`] does.
1536    pub fn hold_machine(&mut self, reason: Option<String>) {
1537        self.park_reason = None;
1538        self.note_held();
1539        self.status = TaskStatus::Held;
1540        if reason.is_some() {
1541            self.hold_reason = reason;
1542        }
1543        self.hold_source = Some(HoldSource::Machine);
1544        self.blocked_by.clear();
1545        self.block_reason = None;
1546        self.blocked_from = None;
1547    }
1548
1549    /// Block this task on other task ids and/or open question ids, chosen by
1550    /// `crate::conduct`. Pure: the caller still owns writing it back with
1551    /// [`Queue::put`].
1552    ///
1553    /// Records [`Task::blocked_from`] the first time this moves the task into
1554    /// [`TaskStatus::Blocked`], and leaves it alone on a later call that adds
1555    /// or replaces `blocked_by` while the task is already `Blocked` - a
1556    /// second question about an already-blocked task must not overwrite the
1557    /// status it should eventually return to with `Blocked` itself.
1558    pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
1559        self.stamp_schema();
1560        let before: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1561        let after: std::collections::BTreeSet<_> = blocked_by.iter().collect();
1562        if self.status != TaskStatus::Blocked || before != after {
1563            self.blocked_at = Some(Timestamp::now());
1564        }
1565        if self.status != TaskStatus::Blocked {
1566            self.blocked_from = Some(self.status);
1567        }
1568        self.status = TaskStatus::Blocked;
1569        self.blocked_by = blocked_by;
1570        self.block_reason = reason;
1571    }
1572
1573    /// Remove one resolved dependency (a task id that became [`TaskStatus::Done`],
1574    /// or a question id that became [`crate::ask::QuestionStatus::Answered`]).
1575    /// Once nothing is left in [`Task::blocked_by`], the task returns to
1576    /// whatever [`Task::blocked_from`] recorded - deciding *why* a task was
1577    /// blocked was `crate::conduct`'s job, but noticing a dependency resolved
1578    /// needs no model at all, and restoring the status it interrupted needs
1579    /// nothing more than what `block` already wrote down.
1580    ///
1581    /// A task blocked while `Running` restores to [`TaskStatus::Queued`]
1582    /// instead: whatever process was running it is gone by the time this
1583    /// runs, so there is nothing left to resume. A task with no recorded
1584    /// `blocked_from` - a pre-schema-4 record, or one blocked before this
1585    /// field existed - falls back to [`TaskStatus::Held`] when it still
1586    /// carries hold evidence ([`Task::hold_reason`] or [`Task::hold_source`],
1587    /// neither ever cleared by `block`), and to `Queued` otherwise: the same
1588    /// choice `block` itself would have recorded, reconstructed from what
1589    /// survived.
1590    ///
1591    /// A no-op, on purpose, for a task that is not [`TaskStatus::Blocked`]:
1592    /// `crate::daemon`'s deterministic resolver runs over every task on every
1593    /// poll, and a task that moved on for some other reason must not be
1594    /// dragged back by a stale id it still happens to carry.
1595    pub fn unblock(&mut self, resolved_id: &str) {
1596        if self.status != TaskStatus::Blocked {
1597            return;
1598        }
1599        self.blocked_by.retain(|id| id != resolved_id);
1600        self.restore_if_unblocked();
1601    }
1602
1603    /// A dependency this task waited on was deleted on purpose
1604    /// ([`Queue::remove`]): drop just that id from [`Task::blocked_by`] and,
1605    /// once nothing is left, return to the status the block interrupted, the
1606    /// same way [`Task::unblock`] does. Returns whether anything changed.
1607    ///
1608    /// Never a hold: the operator chose the deletion, so the dependent is not
1609    /// in trouble. Touches nothing but a `Blocked` task that actually names
1610    /// `deleted_id`, so a `Held` task (or a manual hold) is left exactly as it
1611    /// was. A dependency that vanished *without* going through a deletion is
1612    /// not this method's case - that stays a machine hold.
1613    pub fn dependency_deleted(&mut self, deleted_id: &str) -> bool {
1614        if self.status != TaskStatus::Blocked || !self.blocked_by.iter().any(|b| b == deleted_id) {
1615            return false;
1616        }
1617        self.blocked_by.retain(|id| id != deleted_id);
1618        self.restore_if_unblocked();
1619        true
1620    }
1621
1622    /// The shared tail of [`Task::unblock`] and [`Task::dependency_deleted`]:
1623    /// with nothing left in `blocked_by`, restore the interrupted status and
1624    /// clear the now-stale block bookkeeping.
1625    fn restore_if_unblocked(&mut self) {
1626        if self.blocked_by.is_empty() {
1627            self.status = match self.blocked_from {
1628                Some(TaskStatus::Running) => TaskStatus::Queued,
1629                Some(other) => other,
1630                None if self.hold_reason.is_some() || self.hold_source.is_some() => {
1631                    TaskStatus::Held
1632                }
1633                None => TaskStatus::Queued,
1634            };
1635            self.block_reason = None;
1636            self.blocked_from = None;
1637        }
1638    }
1639
1640    /// Record that a question `crate::conduct` asked about this task has been
1641    /// answered, so the answer's content — not just the fact that the
1642    /// question is gone — reaches the next conductor prompt and the next
1643    /// run's instruction. See [`Task::answers`].
1644    pub fn record_answer(&mut self, question: String, answer: String) {
1645        self.answers.push(AnsweredQuestion { question, answer });
1646    }
1647
1648    /// Requeue this task to reopen its last run as a review-only pass against
1649    /// `branch` (`crate::graph::Runner::review`) rather than competing from
1650    /// scratch. See [`Task::review_branch`].
1651    pub fn request_review(&mut self, branch: String) {
1652        self.release();
1653        self.review_branch = Some(branch);
1654        self.review_instruction = Some(self.instruction.clone());
1655    }
1656
1657    /// Requeue after a conductor chose a new competition. Unlike an ordinary
1658    /// operator release, this deliberately does not resume the old run.
1659    pub fn requeue(&mut self) {
1660        self.release();
1661        // A fresh competition is the opposite of a review of the old branch.
1662        self.review_branch = None;
1663        self.review_instruction = None;
1664        self.fresh_start = true;
1665    }
1666
1667    /// Hold this task because a takeover of `branch` was refused, keeping
1668    /// [`Task::review_branch`] so that once the operator has cleaned up and
1669    /// released it, the retry is a review of the same branch rather than a
1670    /// resume or a fresh competition. Spends no attempt.
1671    ///
1672    /// This is the only way a machine hold keeps a `review_branch`: the
1673    /// daemon takes it at the start of every attempt, so [`Task::release`]
1674    /// relies on that to tell this hold from every other.
1675    pub fn hold_for_handover(&mut self, branch: Option<String>, reason: String) {
1676        if branch.is_some() {
1677            self.review_branch = branch;
1678            self.review_instruction = Some(self.instruction.clone());
1679        }
1680        self.hold_machine(Some(reason));
1681    }
1682
1683    /// Change the urgent lane setting without releasing a held task.
1684    pub fn set_urgent(&mut self, urgent: bool) -> Result<()> {
1685        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1686            bail!(
1687                "task {} is {}; urgent can only change while queued or held; \
1688                 wait for the active attempt to finish and requeue or hold the task first",
1689                self.short(),
1690                self.status.as_str()
1691            );
1692        }
1693        self.urgent = urgent;
1694        Ok(())
1695    }
1696
1697    /// Change how urgently this task should run next.
1698    ///
1699    /// Refused once the task is `running`: priority only feeds the sort
1700    /// [`Queue::next_runnable`] does over tasks waiting to be claimed, and a
1701    /// running task has already left that pool. Accepting the write anyway
1702    /// would look like it worked while changing nothing until - and unless -
1703    /// this attempt fails and the task becomes runnable again, which is a
1704    /// surprise the phone should not hand back as a success.
1705    pub fn set_priority(&mut self, priority: i32) -> Result<()> {
1706        if self.status == TaskStatus::Running {
1707            bail!(
1708                "task {} is running; its priority cannot be changed until \
1709                 this attempt finishes",
1710                self.short()
1711            );
1712        }
1713        self.priority = priority;
1714        Ok(())
1715    }
1716
1717    /// Mark (or unmark) this task to interrupt whatever `magi serve` already
1718    /// has in flight, once `[daemon] pause_for_interrupts` is on. See
1719    /// [`Task::interrupt`].
1720    ///
1721    /// Setting it is restricted to a task the loop could pick up on its own
1722    /// right now - [`TaskStatus::runnable`] - for the same reason as
1723    /// [`Task::set_priority`]: a task already `running` has been claimed, and
1724    /// a task that is `done`, `held`, or `blocked` is not going to compete
1725    /// for the daemon's attention regardless of this flag. Unlike priority,
1726    /// this is never silently inert while `running` - it is refused outright,
1727    /// because the entire feature this flag drives (`crate::daemon`'s
1728    /// interrupt scheduler) is scoped to tasks still waiting to be claimed.
1729    /// Clearing it back to `false` carries no such risk and is always
1730    /// allowed, including on a task that moved on since it was set.
1731    pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
1732        if interrupt && !self.status.runnable() {
1733            bail!(
1734                "task {} is {}; only a queued or failed task can be marked \
1735                 to interrupt",
1736                self.short(),
1737                self.status.as_str()
1738            );
1739        }
1740        self.interrupt = interrupt;
1741        Ok(())
1742    }
1743
1744    /// Resolve the task override against the running daemon's limit.
1745    pub fn effective_attempt_limit(&self, daemon_limit: usize) -> usize {
1746        self.attempt_limit.map_or(daemon_limit, NonZeroUsize::get)
1747    }
1748
1749    /// Change the budget without resetting attempts or releasing a hold.
1750    /// Callers must hold the queue claim and reload the task before calling.
1751    pub fn change_attempt_limit(&mut self, limit: Option<NonZeroUsize>) -> Result<()> {
1752        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1753            bail!("only queued or held tasks can change the attempt limit");
1754        }
1755        self.stamp_schema();
1756        self.attempt_limit = limit;
1757        Ok(())
1758    }
1759
1760    /// Explicit implementer choice, with Solo taking precedence for old records.
1761    pub fn implementers(&self) -> Option<usize> {
1762        if self.solo {
1763            Some(1)
1764        } else {
1765            self.overrides.as_ref().and_then(|o| o.candidates)
1766        }
1767    }
1768
1769    /// Validate against this task's configuration and change only its seat count.
1770    /// Callers must hold the queue claim and reload the task before calling.
1771    pub fn change_implementers(&mut self, count: u8) -> Result<()> {
1772        self.check_implementers_editable()?;
1773        let explicit = self.overrides.as_ref().and_then(|o| o.config.as_deref());
1774        let (mut config, _) = crate::config::Config::discover(&self.repo, explicit)?;
1775        if let Some(o) = &self.overrides {
1776            o.apply(&mut config);
1777        }
1778        self.set_implementers(count, &config)
1779    }
1780
1781    fn check_implementers_editable(&self) -> Result<()> {
1782        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1783            bail!(
1784                "task {} is {}; only queued or held tasks can change implementers",
1785                self.short(),
1786                self.status.as_str()
1787            );
1788        }
1789        Ok(())
1790    }
1791
1792    /// Normalize Solo and candidate overrides without altering task history.
1793    pub fn set_implementers(&mut self, count: u8, config: &crate::config::Config) -> Result<()> {
1794        self.check_implementers_editable()?;
1795        crate::talk::check_implementers(count, config).map_err(anyhow::Error::msg)?;
1796        self.stamp_schema();
1797        self.solo = count == 1;
1798        let mut overrides = self.overrides.clone().unwrap_or_else(|| RunOverrides {
1799            inherit_daemon_options: true,
1800            ..Default::default()
1801        });
1802        overrides.candidates = (count > 1).then_some(usize::from(count));
1803        let mut settings = overrides.clone();
1804        settings.inherit_daemon_options = false;
1805        // Some(empty) is a direct-run setting too: retain its isolation on Solo.
1806        self.overrides = if overrides.inherit_daemon_options && settings == RunOverrides::default()
1807        {
1808            None
1809        } else {
1810            Some(overrides)
1811        };
1812        Ok(())
1813    }
1814
1815    /// Replace this task's title and instruction wholesale.
1816    ///
1817    /// Restricted to `queued` and `held`. A `running` task's instruction has
1818    /// already been handed to the graph, so a run in flight and the file on
1819    /// disk must not be allowed to disagree about what was asked; a `done` or
1820    /// `failed` task is a record of what actually happened and editing it
1821    /// after the fact would falsify that record. `id`, `created_at`,
1822    /// `source`, and `runs` are left untouched on purpose - an edit stands in
1823    /// for "delete and refile", and keeping the id, the timestamp, the
1824    /// attribution, and the run history is the entire reason it exists
1825    /// instead.
1826    pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
1827        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1828            bail!(
1829                "task {} is {}; only a queued or held task's instruction can \
1830                 be edited",
1831                self.short(),
1832                self.status.as_str()
1833            );
1834        }
1835        // Older retained reviews have no criteria snapshot yet. Capture the
1836        // old instruction before this supported edit can change the target.
1837        if self.review_branch.is_some() && self.review_instruction.is_none() {
1838            self.review_instruction = Some(self.instruction.clone());
1839        }
1840        if self.instruction != instruction {
1841            // A run selected for earlier criteria is no longer authorized by
1842            // this revised instruction. Title-only edits preserve the pin.
1843            self.resume_override = None;
1844        }
1845        self.title = title;
1846        self.instruction = instruction;
1847        Ok(())
1848    }
1849
1850    /// Record a run that produced a pull request without merging it.
1851    ///
1852    /// The task is held rather than retried, and it costs no further attempt
1853    /// either way. The work the task asked for exists: it is sitting on a
1854    /// branch, in a pull request, waiting for CI or for a person. Retrying
1855    /// would spend the whole competition budget a second time and then race a
1856    /// second branch against the pull request the first one opened - which is
1857    /// exactly what happened to run 01c2, whose finished and green pull request
1858    /// was re-competed from scratch four seconds after it opened.
1859    ///
1860    /// A pull request nobody merged is a request for a person, not a failure.
1861    ///
1862    /// Records `why` into [`Task::hold_reason`] as well as
1863    /// [`Task::last_error`], so the notification centre and `magi task show`
1864    /// say why the task is held rather than "no reason recorded". This is
1865    /// also the path a verified no-op with an outstanding `magi ask` question
1866    /// settles through - see `daemon::settle` and `daemon::settle_and_diagnose`,
1867    /// which append the question id to `hold_reason` when one is still open
1868    /// for the run.
1869    pub fn handed_off(&mut self, why: impl Into<String>) {
1870        let why = why.into();
1871        self.diagnostic = None;
1872        self.note_held();
1873        self.status = TaskStatus::Held;
1874        self.hold_source = Some(HoldSource::Machine);
1875        self.hold_reason = Some(why.clone());
1876        self.last_error = Some(why);
1877    }
1878
1879    /// Release on explicit operator authority, rather than automatic recovery.
1880    pub fn release_operator(&mut self, source: &str, question_id: Option<&str>) {
1881        self.recovery_budget = None;
1882        self.recovery_generations.clear();
1883        self.recovery_admission = None;
1884        if self
1885            .resumption
1886            .as_ref()
1887            .is_some_and(|r| r.instruction != self.instruction)
1888        {
1889            // Also cover persisted revisions whose earlier approval still
1890            // carries a transient pin when this release is applied.
1891            self.resume_override = None;
1892        }
1893        let approval = Resumption {
1894            generation: self.resumption.as_ref().map_or(1, |r| r.generation + 1),
1895            source: source.to_owned(),
1896            question_id: question_id.map(str::to_owned),
1897            at: Timestamp::now(),
1898            instruction: self.instruction.clone(),
1899            answers_through: self.answers.len(),
1900            runs_through: self.runs.len(),
1901            blocking_evidence: Vec::new(),
1902            cleared_reason: self
1903                .hold_reason
1904                .clone()
1905                .or_else(|| self.block_reason.clone()),
1906        };
1907        self.release();
1908        self.operator_decided_at = Some(approval.at);
1909        if let Some(previous) = self.resumption.replace(approval) {
1910            self.resumption_history.push(previous);
1911        }
1912    }
1913
1914    /// Record a failed graph pass without allowing automatic requeues to refund it.
1915    pub(crate) fn recovery_failed(&mut self, state: &crate::run::RunState) {
1916        let (base, inputs) = self.recovery_admission.as_ref().map_or_else(
1917            || (state.base_commit.clone(), recovery_inputs(&state.sources)),
1918            |a| (a.base.clone(), a.inputs.clone()),
1919        );
1920        let progress = recovery_progress(state);
1921        let stage = if state.prep_failure.is_some() {
1922            "preparation"
1923        } else {
1924            "execution"
1925        };
1926        let matches = |b: &RecoveryBudget| {
1927            b.instruction == self.instruction
1928                && b.base == base
1929                && b.inputs == inputs
1930                && (progress.is_none() || b.progress == progress)
1931        };
1932        if !self.recovery_budget.as_ref().is_some_and(matches) {
1933            let previous = self
1934                .recovery_generations
1935                .iter()
1936                .position(matches)
1937                .map(|index| self.recovery_generations.remove(index));
1938            if let Some(old) = self.recovery_budget.take() {
1939                self.recovery_generations.push(old);
1940            }
1941            self.recovery_budget = previous;
1942        }
1943        if let Some(budget) = &mut self.recovery_budget {
1944            budget.failures = budget.failures.saturating_add(1);
1945            budget.stage = stage.to_owned();
1946        } else {
1947            self.recovery_budget = Some(RecoveryBudget {
1948                instruction: self.instruction.clone(),
1949                base,
1950                inputs,
1951                stage: stage.to_owned(),
1952                failures: 1,
1953                progress,
1954            });
1955        }
1956    }
1957
1958    pub(crate) fn recovery_exhausted(
1959        &self,
1960        base: &str,
1961        inputs: &str,
1962        limit: usize,
1963    ) -> Option<String> {
1964        let same_input = |b: &&RecoveryBudget| {
1965            b.instruction == self.instruction && b.base == base && b.inputs == inputs
1966        };
1967        let budget = self
1968            .recovery_budget
1969            .as_ref()
1970            .filter(same_input)
1971            .or_else(|| self.recovery_generations.iter().rev().find(same_input))?;
1972        (budget.instruction == self.instruction && budget.base == base && budget.inputs == inputs
1973            && budget.failures >= limit.max(1)).then(|| format!(
1974                "[recovery-budget] {} failed passes against unchanged inputs ({}). Inspect the retained recovery sources and correct the cause, change the inputs, or explicitly release the task to approve another generation. Automatic requeue does not reset this budget.",
1975                budget.failures, budget.stage))
1976    }
1977
1978    /// Put a held or finished task back in line, with its attempt count reset
1979    /// so a release is a real second chance rather than an instant re-hold.
1980    /// The run history is kept: attempts reset, evidence does not.
1981    pub fn release(&mut self) {
1982        // Freeze the unresolved cause before a recovery can mint a run without
1983        // doing work. Approval and timestamps alone are not new outcomes.
1984        self.import_chat_notice();
1985        if self.hold_notice_cause.is_none()
1986            && matches!(self.status, TaskStatus::Held | TaskStatus::Blocked)
1987        {
1988            self.hold_notice_cause = Some(self.unresolved_hold_cause());
1989        }
1990        let refused_handover = self.status == TaskStatus::Held
1991            && self.hold_source == Some(HoldSource::Machine)
1992            && self.review_branch.is_some()
1993            && self
1994                .review_instruction
1995                .as_ref()
1996                .is_none_or(|i| *i == self.instruction);
1997        self.status = TaskStatus::Queued;
1998        self.held_at = None;
1999        self.attempts = 0;
2000        self.last_error = None;
2001        self.park_reason = None;
2002        // Otherwise the next person who holds this task reads a reason that
2003        // belonged to whatever it was waiting on last time.
2004        self.hold_reason = None;
2005        self.hold_source = None;
2006        self.diagnostic = None;
2007        // A release also un-blocks: the dependency or question `blocked_by`
2008        // named may still be unresolved, but a human (or `crate::conduct`)
2009        // choosing to release the task overrides that wait outright, the same
2010        // as it overrides an ordinary hold.
2011        self.blocked_by.clear();
2012        self.block_reason = None;
2013        self.blocked_from = None;
2014        // A machine hold that still names a review branch is a refused
2015        // handover (see [`Task::hold_for_handover`]): the retry must review
2016        // that same branch. Any other release drops it.
2017        if !refused_handover {
2018            self.review_branch = None;
2019            self.review_instruction = None;
2020        }
2021        self.fresh_start = false;
2022    }
2023}
2024
2025/// Copy `src` into `dir` as `name`, or `stem-2.ext`, `stem-3.ext`, ... when
2026/// that is taken. `create_new` makes the collision check atomic and also
2027/// catches a case-insensitive filesystem. Returns the stored name and path.
2028fn copy_new(dir: &Path, src: &Path, name: &str) -> Result<(String, PathBuf)> {
2029    use std::io::ErrorKind;
2030    let (stem, ext) = match name.rfind('.') {
2031        Some(i) if i > 0 => (&name[..i], &name[i..]),
2032        _ => (name, ""),
2033    };
2034    for n in 1u32.. {
2035        let candidate = if n == 1 {
2036            name.to_owned()
2037        } else {
2038            let suffix = format!("-{n}");
2039            let room = 64usize.saturating_sub(suffix.len() + ext.len());
2040            let stem: String = stem.chars().take(room).collect();
2041            format!("{stem}{suffix}{ext}")
2042        };
2043        if !crate::ask::valid_asset_name(&candidate) {
2044            bail!("no valid attachment name is left for `{name}`");
2045        }
2046        let path = dir.join(&candidate);
2047        match std::fs::OpenOptions::new()
2048            .write(true)
2049            .create_new(true)
2050            .open(&path)
2051        {
2052            Ok(mut out) => {
2053                let copied = std::fs::File::open(src)
2054                    .and_then(|mut input| std::io::copy(&mut input, &mut out));
2055                if let Err(e) = copied {
2056                    drop(out);
2057                    let _ = std::fs::remove_file(&path);
2058                    return Err(e).with_context(|| format!("copy {}", src.display()));
2059                }
2060                return Ok((candidate, path));
2061            }
2062            Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
2063            Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
2064        }
2065    }
2066    unreachable!("the counter never runs out")
2067}
2068
2069/// Minimum age before a dead writer's lock may be recovered, and maximum wait
2070/// for a contending writer. Age alone never revokes a live writer's ownership.
2071const TASK_LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
2072
2073/// Guard for [`Queue::lock_task`]; removes the lock file on drop, but only
2074/// while the file still carries this guard's owner token.
2075pub(crate) struct TaskLock {
2076    path: PathBuf,
2077    token: String,
2078}
2079
2080/// A token no other holder shares: pid, then a mix of entropy and a process
2081/// counter so threads born in the same nanosecond still differ.
2082fn owner_token() -> String {
2083    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2084    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2085    let mut r = crate::rng::SplitMix64::new(crate::rng::entropy() ^ n.rotate_left(32));
2086    format!("{}-{:016x}", std::process::id(), r.next_u64())
2087}
2088
2089/// The marker that serializes everyone removing the lock that carries `token`.
2090/// Hashed (FNV-1a) so an empty or foreign token is still a valid file name.
2091fn break_marker(path: &Path, token: &str) -> PathBuf {
2092    let mut h: u64 = 0xcbf29ce484222325;
2093    for b in token.bytes() {
2094        h ^= u64::from(b);
2095        h = h.wrapping_mul(0x100000001b3);
2096    }
2097    let mut name = path.as_os_str().to_owned();
2098    name.push(format!(".break-{h:016x}"));
2099    PathBuf::from(name)
2100}
2101
2102fn older_than_stale(path: &Path) -> bool {
2103    std::fs::metadata(path)
2104        .and_then(|m| m.modified())
2105        .ok()
2106        .and_then(|t| t.elapsed().ok())
2107        .is_some_and(|age| age > TASK_LOCK_STALE)
2108}
2109
2110fn writer_may_be_gone(token: &str) -> bool {
2111    let Some(pid) = token
2112        .split_once('-')
2113        .and_then(|(pid, _)| pid.parse::<u32>().ok())
2114    else {
2115        // Legacy/incomplete tokens have no valid writer identity. They remain
2116        // subject to the age and compare/remove checks.
2117        return true;
2118    };
2119    if pid == std::process::id() {
2120        return false;
2121    }
2122    // Unknown process-table access must not steal a long-running retirement.
2123    crate::proc::pid_status(pid) == Some(false)
2124}
2125
2126/// How many levels of "a stale marker guarding a stale marker" are recovered
2127/// before giving up. Each level needs its own crashed remover, so the cap is
2128/// never reached without several independent crashes.
2129const MAX_MARKER_DEPTH: u8 = 3;
2130
2131/// Take the break marker for `token`, or `None` if another remover holds it.
2132/// The marker carries its own owner token. One older than [`TASK_LOCK_STALE`]
2133/// was left by a remover that died; it is cleared by the same compare-and-
2134/// remove discipline as a lock, guarded by a marker of its own, so a recoverer
2135/// that judged it stale cannot delete a fresh marker that replaced it.
2136fn take_marker(
2137    path: &Path,
2138    token: &str,
2139    depth: u8,
2140    retry: crate::lock_file::Retry,
2141) -> Option<(PathBuf, String)> {
2142    use std::io::Write;
2143    let marker = break_marker(path, token);
2144    for _ in 0..2 {
2145        match retry.create(&marker) {
2146            Ok(mut f) => {
2147                let mine = owner_token();
2148                if f.write_all(mine.as_bytes()).is_err() {
2149                    drop(f);
2150                    let _ = std::fs::remove_file(&marker);
2151                    return None;
2152                }
2153                return Some((marker, mine));
2154            }
2155            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2156                let judged = std::fs::read_to_string(&marker).ok();
2157                match judged.filter(|_| older_than_stale(&marker)) {
2158                    Some(judged) if depth < MAX_MARKER_DEPTH => {
2159                        remove_lock_if(&marker, &judged, true, depth + 1, retry)?;
2160                    }
2161                    _ => return None,
2162                }
2163            }
2164            Err(_) => return None,
2165        }
2166    }
2167    None
2168}
2169
2170/// Remove the lock at `path` if it still carries `token` (and, when
2171/// `require_stale`, is still stale). Removal is serialized per token by the
2172/// break marker, so a remover that judged an old lock cannot delete the fresh
2173/// one that replaced it. `None` means the marker was busy: try again.
2174fn remove_lock_if(
2175    path: &Path,
2176    token: &str,
2177    require_stale: bool,
2178    depth: u8,
2179    retry: crate::lock_file::Retry,
2180) -> Option<bool> {
2181    let (marker, mine) = take_marker(path, token, depth, retry)?;
2182    let still = std::fs::read_to_string(path).is_ok_and(|c| c == token)
2183        && (!require_stale || older_than_stale(path) && writer_may_be_gone(token));
2184    if still {
2185        let _ = std::fs::remove_file(path);
2186    }
2187    // Release the marker only if it is still ours.
2188    if depth >= MAX_MARKER_DEPTH {
2189        if std::fs::read_to_string(&marker).is_ok_and(|c| c == mine) {
2190            let _ = std::fs::remove_file(&marker);
2191        }
2192    } else {
2193        let _ = remove_lock_if(&marker, &mine, false, depth + 1, retry);
2194    }
2195    Some(still)
2196}
2197
2198/// Break the lock judged stale while it held `judged`. Returns whether it was
2199/// removed; a lock that has since been replaced is left alone.
2200#[cfg(test)]
2201fn break_stale(path: &Path, judged: &str) -> bool {
2202    break_stale_with(path, judged, crate::lock_file::Retry::new(None))
2203}
2204
2205fn break_stale_with(path: &Path, judged: &str, retry: crate::lock_file::Retry) -> bool {
2206    remove_lock_if(path, judged, true, 0, retry).unwrap_or(false)
2207}
2208
2209impl Drop for TaskLock {
2210    fn drop(&mut self) {
2211        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2212        let retry = crate::lock_file::Retry::new(Some(deadline));
2213        loop {
2214            match remove_lock_if(&self.path, &self.token, false, 0, retry) {
2215                Some(_) => return,
2216                None if std::time::Instant::now() > deadline => return,
2217                None => std::thread::sleep(std::time::Duration::from_millis(5)),
2218            }
2219        }
2220    }
2221}
2222
2223/// A queue on disk.
2224#[derive(Debug, Clone)]
2225pub struct Queue {
2226    root: PathBuf,
2227    ids: IdSource,
2228}
2229
2230fn validate_attachments(sources: &[PathBuf]) -> Result<Vec<(&PathBuf, &str)>> {
2231    let mut wanted = Vec::new();
2232    for src in sources {
2233        let name = src
2234            .file_name()
2235            .and_then(|n| n.to_str())
2236            .with_context(|| format!("`{}` has no usable file name", src.display()))?;
2237        if !crate::ask::valid_asset_name(name) {
2238            bail!(
2239                "attachment name `{name}` must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ \
2240                 with no `..`; rename the file and try again"
2241            );
2242        }
2243        if !src.is_file() {
2244            bail!("attachment `{}` is not a file", src.display());
2245        }
2246        wanted.push((src, name));
2247    }
2248    Ok(wanted)
2249}
2250
2251impl Queue {
2252    /// The operator's queue, `<home>/queue`.
2253    pub fn open() -> Self {
2254        Self::at(crate::run::home().join("queue"))
2255    }
2256
2257    /// A queue at an explicit root. Tests use this; so could an operator who
2258    /// wants a queue per project.
2259    pub fn at(root: PathBuf) -> Self {
2260        Self {
2261            root,
2262            ids: IdSource::new(new_id),
2263        }
2264    }
2265
2266    /// Replace the source of candidate ids for new tasks. For tests that need
2267    /// a deterministic collision.
2268    #[doc(hidden)]
2269    pub fn with_id_source(mut self, f: impl Fn() -> String + Send + Sync + 'static) -> Self {
2270        self.ids = IdSource::custom(f);
2271        self
2272    }
2273
2274    /// File a new task: reserve its id atomically and write it without ever
2275    /// reading, merging into or replacing an existing record.
2276    ///
2277    /// The candidate is `task.id`. Under that id's write lock, a record that
2278    /// already exists makes the candidate taken and another id is minted; a
2279    /// failure to find out is an error, never "free". `hook` runs under the
2280    /// lock once the id looks free and may veto it with `Ok(false)` (an
2281    /// orphaned claim or attachment directory also marks an id as used). The
2282    /// first `Err` ends the attempt. `undo` runs if the record then fails to
2283    /// write. `task.id` is the reserved id on success.
2284    fn file_with(
2285        &self,
2286        task: &mut Task,
2287        hook: impl FnMut(&mut Task) -> Result<bool>,
2288        undo: impl FnMut(&mut Task),
2289    ) -> Result<()> {
2290        self.reserve_id(task, hook, undo, true)
2291    }
2292
2293    /// The reservation loop behind [`Queue::file_with`]. With `store` false
2294    /// the id is only reserved through whatever `hook` leaves on disk (an
2295    /// attachment directory) and no record is written; the task stays unfiled.
2296    ///
2297    /// An injected id source supplies the first candidate too, unless the
2298    /// task already holds an id through an earlier reservation (attachments);
2299    /// production keeps the id [`Task::new`] minted.
2300    fn reserve_id(
2301        &self,
2302        task: &mut Task,
2303        mut hook: impl FnMut(&mut Task) -> Result<bool>,
2304        mut undo: impl FnMut(&mut Task),
2305        store: bool,
2306    ) -> Result<()> {
2307        let redraw_first = self.ids.is_custom() && task.attachments.is_empty();
2308        let initial = task.id.clone();
2309        self.ids
2310            .reserve((!redraw_first).then_some(initial.as_str()), "task", |id| {
2311                task.id = id.to_owned();
2312                let _lock = self.lock_task(&task.id)?;
2313                if path_exists(&self.path_of(&task.id))? || !hook(task)? {
2314                    return Ok(false);
2315                }
2316                if store {
2317                    if let Err(e) = self.write_record(task) {
2318                        undo(task);
2319                        return Err(e);
2320                    }
2321                    task.unfiled = false;
2322                }
2323                Ok(true)
2324            })?;
2325        Ok(())
2326    }
2327
2328    /// Is `id` used by anything but a stored record: a claim file, or an
2329    /// attachment directory (`check_attachments`) left by an earlier
2330    /// reservation or an interrupted filing? Only a definite "not found" is
2331    /// free; any other answer is an error, never "free".
2332    fn id_in_use(&self, id: &str, check_attachments: bool) -> Result<bool> {
2333        if path_exists(&self.lock_path(id))? {
2334            return Ok(true);
2335        }
2336        Ok(check_attachments && path_exists(&self.attachments_dir(id))?)
2337    }
2338
2339    /// The `hook` of a plain [`Queue::put`] of a new task. Attachments
2340    /// already copied by [`Queue::attach`] sit in this id's own directory,
2341    /// which is then not a sign of someone else's use.
2342    fn plain_filing_hook(&self, task: &Task) -> Result<bool> {
2343        Ok(!self.id_in_use(&task.id, task.attachments.is_empty())?)
2344    }
2345
2346    /// File a new task and take its claim in one step, on the id that ends up
2347    /// reserved. An id whose claim file already exists is taken.
2348    pub fn file_claimed(&self, task: &mut Task) -> Result<Claim> {
2349        let mut claim = None;
2350        self.file_with(
2351            task,
2352            |t| {
2353                if self.id_in_use(&t.id, true)? {
2354                    return Ok(false);
2355                }
2356                claim = Some(self.claim(&t.id)?);
2357                Ok(true)
2358            },
2359            |_| {},
2360        )?;
2361        claim.context("claim missing after filing")
2362    }
2363
2364    /// Directory holding the task files.
2365    pub fn root(&self) -> &Path {
2366        &self.root
2367    }
2368
2369    /// Path for one task id.
2370    pub fn path_of(&self, id: &str) -> PathBuf {
2371        self.root.join(format!("{id}.json"))
2372    }
2373
2374    /// Directory holding a task's attachments. A sibling of the task file,
2375    /// not a `*.json`, so [`Queue::list`] and [`Queue::revision`] never see it.
2376    pub fn attachments_dir(&self, id: &str) -> PathBuf {
2377        self.root.join(format!("{id}.attachments"))
2378    }
2379
2380    /// Copy each of `sources` into `task`'s attachment directory and record
2381    /// the stored names on `task` (persist with [`Queue::put`]). Returns the
2382    /// names, in order.
2383    ///
2384    /// Every source is checked (a readable file, a name passing
2385    /// [`crate::ask::valid_asset_name`]) before anything is copied, and a
2386    /// copy that fails part-way removes only what this call created. A name
2387    /// already taken is never overwritten: it becomes `stem-2.ext`,
2388    /// `stem-3.ext`, ... A name that does not validate is refused rather than
2389    /// sanitised - the operator renames the file.
2390    pub fn attach(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2391        let wanted = validate_attachments(sources)?;
2392        if task.unfiled && task.attachments.is_empty() {
2393            // Not stored yet: the directory is created under the id's
2394            // reservation and keeps the id taken until the record lands.
2395            let names = std::cell::RefCell::new(Vec::new());
2396            self.reserve_id(
2397                task,
2398                |t| {
2399                    if self.id_in_use(&t.id, true)? {
2400                        return Ok(false);
2401                    }
2402                    *names.borrow_mut() = self.copy_attachments(t, &wanted)?;
2403                    Ok(true)
2404                },
2405                |_| {},
2406                false,
2407            )?;
2408            return Ok(names.into_inner());
2409        }
2410        self.copy_attachments(task, &wanted)
2411    }
2412
2413    /// Copy `wanted` into `task`'s attachment directory (see [`Queue::attach`]).
2414    fn copy_attachments(
2415        &self,
2416        task: &mut Task,
2417        wanted: &[(&PathBuf, &str)],
2418    ) -> Result<Vec<String>> {
2419        let dir = self.attachments_dir(&task.id);
2420        let existed = dir.is_dir();
2421        std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
2422        let mut created: Vec<PathBuf> = Vec::new();
2423        let mut names = Vec::new();
2424        let mut copy_all = || -> Result<()> {
2425            for (src, name) in wanted {
2426                let (stored, path) = copy_new(&dir, src, name)?;
2427                created.push(path);
2428                names.push(stored);
2429            }
2430            Ok(())
2431        };
2432        if let Err(e) = copy_all() {
2433            for path in &created {
2434                let _ = std::fs::remove_file(path);
2435            }
2436            if !existed {
2437                let _ = std::fs::remove_dir(&dir);
2438            }
2439            return Err(e);
2440        }
2441        task.attachments.extend(names.iter().cloned());
2442        Ok(names)
2443    }
2444
2445    /// [`Queue::attach`] then [`Queue::put`], undoing the copies when the
2446    /// record cannot be written. Without the undo the files sit in
2447    /// `<id>.attachments/` with no task pointing at them.
2448    ///
2449    /// Only what this call copied is removed (the names `attach` returned,
2450    /// and the directory itself only when it did not exist before and is
2451    /// empty), never an attachment an earlier save recorded. `task` is rolled
2452    /// back too, and the error the caller sees is `put`'s own. This relies on
2453    /// a failed `put` leaving the stored record untouched, which its
2454    /// write-then-rename guarantees.
2455    pub fn attach_and_put(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2456        if task.unfiled && task.attachments.is_empty() {
2457            return self.attach_and_file(task, sources);
2458        }
2459        let dir = self.attachments_dir(&task.id);
2460        let existed = dir.is_dir();
2461        let before = task.attachments.len();
2462        let names = self.attach(task, sources)?;
2463        if let Err(e) = self.put(task) {
2464            for name in &names {
2465                let _ = std::fs::remove_file(dir.join(name));
2466            }
2467            if !existed {
2468                let _ = std::fs::remove_dir(&dir);
2469            }
2470            task.attachments.truncate(before);
2471            return Err(e);
2472        }
2473        Ok(names)
2474    }
2475
2476    /// [`Queue::attach_and_put`] for a task not yet stored: the attachment
2477    /// directory is created under the id's reservation, and a candidate whose
2478    /// directory already exists is taken.
2479    fn attach_and_file(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2480        let wanted = validate_attachments(sources)?;
2481        let before = task.attachments.len();
2482        let names = std::cell::RefCell::new(Vec::new());
2483        let result = self.file_with(
2484            task,
2485            |t| {
2486                if self.id_in_use(&t.id, true)? {
2487                    return Ok(false);
2488                }
2489                *names.borrow_mut() = self.copy_attachments(t, &wanted)?;
2490                Ok(true)
2491            },
2492            |t| {
2493                let dir = self.attachments_dir(&t.id);
2494                for name in names.borrow().iter() {
2495                    let _ = std::fs::remove_file(dir.join(name));
2496                }
2497                // The directory did not exist when the id was reserved.
2498                let _ = std::fs::remove_dir(&dir);
2499                t.attachments.truncate(before);
2500            },
2501        );
2502        result.map(|()| names.into_inner())
2503    }
2504
2505    /// Absolute paths of `task`'s attachments, whatever shape this queue's
2506    /// root has. Absolute because the prompt hands them to an agent whose
2507    /// working directory is somewhere else entirely.
2508    pub fn attachment_paths(&self, task: &Task) -> Vec<PathBuf> {
2509        let dir = self.attachments_dir(&task.id);
2510        task.attachments
2511            .iter()
2512            .map(|n| {
2513                let p = dir.join(n);
2514                std::path::absolute(&p).unwrap_or(p)
2515            })
2516            .collect()
2517    }
2518
2519    /// Write a task, atomically, so a daemon killed mid-write leaves the
2520    /// previous state readable rather than a truncated file.
2521    ///
2522    /// Runs already recorded on disk that `task` does not carry are kept, not
2523    /// dropped: `magi run --task` links a run into a task from another
2524    /// process ([`Queue::link_run`]) while a daemon may hold a snapshot taken
2525    /// before it, and writing that snapshot back whole would erase the link.
2526    /// Nothing ever removes a run from a task, so the union loses nothing.
2527    pub fn put(&self, task: &mut Task) -> Result<()> {
2528        if task.unfiled {
2529            // A new task never merges into a record that has its id.
2530            return self.file_with(task, |t| self.plain_filing_hook(t), |_| {});
2531        }
2532        let _lock = self.lock_task(&task.id)?;
2533        self.put_unlocked(task)
2534    }
2535
2536    /// Write `task` only if no task with its id exists yet; `Ok(false)` when
2537    /// one does. Unlike [`Queue::put`] it never replaces a record, so a
2538    /// producer with a deterministic id cannot rewind a task that has since
2539    /// started running or finished.
2540    pub fn create_new(&self, task: &mut Task) -> Result<bool> {
2541        let _lock = self.lock_task(&task.id)?;
2542        if path_exists(&self.path_of(&task.id))? {
2543            return Ok(false);
2544        }
2545        self.write_record(task)?;
2546        task.unfiled = false;
2547        Ok(true)
2548    }
2549
2550    /// [`Queue::put`] for a caller already holding [`Queue::lock_task`].
2551    fn put_unlocked(&self, task: &mut Task) -> Result<()> {
2552        self.merge_stored(task);
2553        self.write_record(task)
2554    }
2555
2556    /// Fold the stored record of `task`'s id into `task` (updates only).
2557    fn merge_stored(&self, task: &mut Task) {
2558        if let Ok(mut stored) = read_path(&self.path_of(&task.id)) {
2559            stored.import_chat_notice();
2560            // The task's message chat is recorded once and never rewound or
2561            // dropped by a writer holding an older snapshot.
2562            if task.chat.is_none() {
2563                task.chat = stored.chat.clone();
2564            }
2565            for key in stored.chat_notices {
2566                if !task.chat_notices.contains(&key) {
2567                    task.chat_notices.push(key);
2568                }
2569            }
2570            for run in stored.runs {
2571                if !task.runs.contains(&run) {
2572                    task.runs.push(run);
2573                }
2574            }
2575            for p in stored.passes {
2576                if !task
2577                    .passes
2578                    .iter()
2579                    .any(|q| q.run == p.run && q.pass_index == p.pass_index)
2580                {
2581                    task.passes.push(p);
2582                }
2583            }
2584            // A completion notice already recorded is never rewound by an
2585            // older snapshot: that would report the same state twice.
2586            if let Some(n) = stored.chat_notice
2587                && task.chat_notice.as_ref().is_none_or(|mine| mine.at < n.at)
2588            {
2589                task.chat_notice = Some(n);
2590            }
2591        }
2592    }
2593
2594    /// Stamp and write `task` atomically; the caller holds the id's lock.
2595    fn write_record(&self, task: &mut Task) -> Result<()> {
2596        task.import_chat_notice();
2597        task.updated_at = Timestamp::now();
2598        std::fs::create_dir_all(&self.root)
2599            .with_context(|| format!("create {}", self.root.display()))?;
2600        let body = serde_json::to_string_pretty(task).context("serialize task")?;
2601        let path = self.path_of(&task.id);
2602        let tmp = path.with_extension("json.tmp");
2603        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
2604        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
2605        // A machine hold is news for the notification centre, filed beside
2606        // this queue (`<home>/queue` -> `<home>/notifications`) rather than
2607        // through a process-global, so a queue in a temp directory notifies
2608        // into that directory.
2609        if let (Some(notice), Some(home)) = (
2610            crate::notices::task_held(task),
2611            self.root.parent().filter(|p| !p.as_os_str().is_empty()),
2612        ) {
2613            crate::notices::raise_in(home, notice);
2614        }
2615        Ok(())
2616    }
2617
2618    /// Change the task `id` in place: read it inside its write lock, let `f`
2619    /// edit it, and write it back only when `f` returns `true`. The check a
2620    /// caller makes in `f` therefore sees the record as stored now, not a
2621    /// snapshot taken before another writer got in. Returns whether it wrote.
2622    pub fn modify(&self, id: &str, f: impl FnOnce(&mut Task) -> bool) -> Result<bool> {
2623        let id = self.resolve_id(id)?;
2624        let _lock = self.lock_task(&id)?;
2625        let mut task = self.get(&id)?;
2626        if !f(&mut task) {
2627            return Ok(false);
2628        }
2629        self.put_unlocked(&mut task)?;
2630        Ok(true)
2631    }
2632
2633    /// Append `run` to the runs of the task `id` (or an unambiguous prefix),
2634    /// leaving everything else about it alone — see [`Task::link_run`]. Read
2635    /// and written back in one breath, because [`Queue::put`] replaces the
2636    /// whole record. Returns the task as stored.
2637    pub fn link_run(&self, id: &str, run: &str) -> Result<Task> {
2638        let id = self.resolve_id(id)?;
2639        // Read inside the lock, so the record written back is the current one
2640        // and a daemon's write cannot slip between the read and the write.
2641        let _lock = self.lock_task(&id)?;
2642        let mut task = self.get(&id)?;
2643        if task.link_run(run) {
2644            self.put_unlocked(&mut task)?;
2645        }
2646        Ok(task)
2647    }
2648
2649    /// Exclusive right to rewrite task `id`'s record, held until the guard
2650    /// drops. Every writer ([`Queue::put`], [`Queue::link_run`]) takes it, so
2651    /// a process linking a run and the daemon saving a transition are
2652    /// serialized instead of last-writer-wins. An old lock may be broken only
2653    /// after its writer is confirmed dead (legacy tokens without a PID retain
2654    /// age-based recovery). Not the claim lock (`<id>.lock`), held for a
2655    /// whole run.
2656    pub(crate) fn lock_task(&self, id: &str) -> Result<TaskLock> {
2657        std::fs::create_dir_all(&self.root)
2658            .with_context(|| format!("create {}", self.root.display()))?;
2659        let path = self.root.join(format!("{id}.write-lock"));
2660        let started = std::time::Instant::now();
2661        let retry = crate::lock_file::Retry::task_write(started + TASK_LOCK_STALE);
2662        let marker_retry = crate::lock_file::Retry::new(Some(started + TASK_LOCK_STALE));
2663        loop {
2664            match retry.create(&path) {
2665                Ok(mut file) => {
2666                    use std::io::Write;
2667                    let token = owner_token();
2668                    if let Err(e) = file.write_all(token.as_bytes()) {
2669                        drop(file);
2670                        let _ = std::fs::remove_file(&path);
2671                        return Err(e).with_context(|| format!("lock {}", path.display()));
2672                    }
2673                    return Ok(TaskLock { path, token });
2674                }
2675                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2676                    // Content first, then age: a lock replaced in between
2677                    // reads fresh, never stale.
2678                    let judged = std::fs::read_to_string(&path).ok();
2679                    let broken = judged
2680                        .filter(|_| older_than_stale(&path))
2681                        .is_some_and(|judged| break_stale_with(&path, &judged, marker_retry));
2682                    // A break that does not happen (marker busy, recovery
2683                    // exhausted) is a wait like any other, so it meets the
2684                    // same deadline instead of spinning forever.
2685                    if !broken {
2686                        if started.elapsed() > TASK_LOCK_STALE {
2687                            bail!("could not lock task {id}");
2688                        }
2689                        std::thread::sleep(std::time::Duration::from_millis(15));
2690                    }
2691                }
2692                Err(e) => return Err(e).with_context(|| format!("lock {}", path.display())),
2693            }
2694        }
2695    }
2696
2697    /// Load a task by id or unambiguous id prefix.
2698    pub fn get(&self, id: &str) -> Result<Task> {
2699        let resolved = self.resolve_id(id)?;
2700        read_path(&self.path_of(&resolved))
2701    }
2702
2703    /// Remove a task, and the claim lock that belongs to it.
2704    ///
2705    /// `in_flight` comes from the caller — a live daemon's heartbeat naming
2706    /// this task — because the task's own `running` status cannot answer the
2707    /// question. A daemon killed mid-competition leaves the status at
2708    /// `running` and an orphaned `.lock` behind, and a guard that trusted
2709    /// either would make the task undeletable for good: the phone showed
2710    /// exactly that, refusing a task whose daemon had been gone for an hour.
2711    ///
2712    /// So the lock is removed with the task rather than respected. Any lock
2713    /// still there once no live daemon claims the task is by definition stale,
2714    /// and leaving it would make a deleted task look claimed to
2715    /// [`Queue::claim`] and to whoever reads the directory.
2716    ///
2717    /// A deletion is deliberate, so whatever was `blocked` on the id is
2718    /// resolved, not held: a `<id>.removed` tombstone is written first (it
2719    /// is what tells [`missing_blockers`] and the daemon's resolver that this
2720    /// id was deleted on purpose, so a pass that races this call cannot
2721    /// mistake it for a vanished dependency), the record is deleted (the
2722    /// tombstone is taken back if that fails), and then each dependent is
2723    /// rewritten under its claim - see [`Queue::release_dependents_of`].
2724    /// Best-effort for the dependents: one claimed by something else right
2725    /// now, or whose write fails, is released silently by
2726    /// `crate::daemon::resolve_blockers` (or `crate::triage::run_once`) on
2727    /// its next pass, because the tombstone is still there. It does not fail
2728    /// this removal.
2729    ///
2730    /// `questions` is the store [`missing_blockers`] checks a `blocked_by` id
2731    /// against - the same store the caller already resolves `id`'s own home
2732    /// from, passed in rather than reopened here so a test queue at an
2733    /// explicit root never reads the operator's real questions directory.
2734    pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
2735        let resolved = self.resolve_id(id)?;
2736        if in_flight {
2737            bail!("task {resolved} is being run by a live daemon right now");
2738        }
2739        self.write_tombstone(&resolved)?;
2740        if let Err(e) = self.remove_record_with_attachments(&resolved, |p| std::fs::remove_file(p))
2741        {
2742            // A record that is still there was not deleted: take the marker
2743            // back. One already gone keeps it, so the deletion still reads
2744            // as deliberate.
2745            if self.path_of(&resolved).exists() {
2746                let _ = std::fs::remove_file(self.tombstone_path(&resolved));
2747            }
2748            return Err(e);
2749        }
2750        let (released, still_blocked) = self.release_dependents_of(&resolved, questions);
2751        Ok(Removal {
2752            id: resolved,
2753            released,
2754            still_blocked,
2755        })
2756    }
2757
2758    /// Path of the marker saying task `id` was deleted on purpose. Not a
2759    /// `*.json`, so [`Queue::list`] and [`Queue::revision`] never see it.
2760    fn tombstone_path(&self, id: &str) -> PathBuf {
2761        self.root.join(format!("{id}.removed"))
2762    }
2763
2764    fn write_tombstone(&self, id: &str) -> Result<()> {
2765        let path = self.tombstone_path(id);
2766        let tmp = self.root.join(format!("{id}.removed.tmp"));
2767        std::fs::write(&tmp, b"")
2768            .and_then(|()| std::fs::rename(&tmp, &path))
2769            .with_context(|| format!("write {}", path.display()))
2770    }
2771
2772    /// Was `id` deleted through [`Queue::remove`] (and is its record gone)?
2773    /// A live record always wins: a tombstone never makes an existing task
2774    /// look deleted.
2775    fn was_deleted(&self, id: &str) -> bool {
2776        self.tombstone_path(id).is_file() && !self.path_of(id).exists()
2777    }
2778
2779    /// Apply [`Task::dependency_deleted`] to `task` for every `blocked_by` id
2780    /// that [`Queue::remove`] deleted. Returns those ids. The caller holds
2781    /// the task's claim and saves it.
2782    pub fn apply_deleted_blockers(&self, task: &mut Task, questions: &Questions) -> Vec<String> {
2783        let deleted = deleted_blockers(self, questions, &task.blocked_by);
2784        deleted
2785            .into_iter()
2786            .filter(|id| task.dependency_deleted(id))
2787            .collect()
2788    }
2789
2790    /// Record, as a fixed-wording info notice, that `dependent` stopped
2791    /// waiting on the deleted task `deleted`. Best-effort and beside the
2792    /// queue, like the hold notice [`Queue::put`] files.
2793    pub fn note_dependency_deleted(&self, dependent: &Task, deleted: &str) {
2794        let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty()) else {
2795            return;
2796        };
2797        let ja = crate::lang::is_japanese(&crate::lang::of_repo(&dependent.repo));
2798        let waiting = dependent.status == TaskStatus::Blocked;
2799        let message = match (ja, waiting) {
2800            (true, false) => format!(
2801                "タスク {} は、待っていた {} が削除されたため待機を解除し、元の状態に戻しました",
2802                dependent.short(),
2803                short(deleted)
2804            ),
2805            (true, true) => format!(
2806                "タスク {} は、待っていた {} が削除されたため、残りの依存を待っています",
2807                dependent.short(),
2808                short(deleted)
2809            ),
2810            (false, false) => format!(
2811                "Task {} stopped waiting on {} because it was deleted, and returned to its previous state",
2812                dependent.short(),
2813                short(deleted)
2814            ),
2815            (false, true) => format!(
2816                "Task {} stopped waiting on {} because it was deleted, and is still waiting on its other dependencies",
2817                dependent.short(),
2818                short(deleted)
2819            ),
2820        };
2821        crate::notices::raise_in(
2822            home,
2823            crate::notices::Notice::info(
2824                &format!("unblocked:{}:{}", dependent.id, deleted),
2825                message,
2826            )
2827            .link(crate::notices::Link::Task {
2828                id: dependent.id.clone(),
2829            }),
2830        );
2831    }
2832
2833    /// Resolve every `blocked` task naming `dependency` (just deleted) with
2834    /// [`Task::dependency_deleted`]. Returns the ids released to another
2835    /// status and those that remain `blocked` on something else. Each
2836    /// dependent is re-read inside its claim, never written from a stale copy.
2837    fn release_dependents_of(
2838        &self,
2839        dependency: &str,
2840        questions: &Questions,
2841    ) -> (Vec<String>, Vec<String>) {
2842        let (mut released, mut still_blocked) = (Vec::new(), Vec::new());
2843        for listed in self.list() {
2844            if listed.status != TaskStatus::Blocked || !listed.blocked_by.iter().any(|b| {
2845                matches!(crate::queue::dependency(self, questions, b), Dependency::Deleted(id) if id == dependency)
2846            }) {
2847                continue;
2848            }
2849            let Ok(_claim) = self.claim(&listed.id) else {
2850                continue;
2851            };
2852            let Ok(mut task) = self.get(&listed.id) else {
2853                continue;
2854            };
2855            let references: Vec<String> = task.blocked_by.iter().filter(|id| {
2856                matches!(crate::queue::dependency(self, questions, id), Dependency::Deleted(full) if full == dependency)
2857            }).cloned().collect();
2858            if references.is_empty() {
2859                continue;
2860            }
2861            for reference in references {
2862                task.dependency_deleted(&reference);
2863            }
2864            if self.put(&mut task).is_err() {
2865                continue;
2866            }
2867            self.note_dependency_deleted(&task, dependency);
2868            if task.status == TaskStatus::Blocked {
2869                still_blocked.push(task.id.clone());
2870            } else {
2871                released.push(task.id.clone());
2872            }
2873        }
2874        (released, still_blocked)
2875    }
2876
2877    /// The attachment-and-record half of [`Queue::remove`]. `remove_record` is
2878    /// how the record file is deleted; production passes `remove_file`, and a
2879    /// test passes a failing one to prove the attachments come back.
2880    fn remove_record_with_attachments(
2881        &self,
2882        resolved: &str,
2883        remove_record: impl FnOnce(&Path) -> std::io::Result<()>,
2884    ) -> Result<()> {
2885        // Neither step may strand the other. The attachments are set aside by a
2886        // rename (cheap, and undone if the record cannot be removed), then the
2887        // record goes, and only then is the set-aside directory deleted for
2888        // real. A failure before the record is gone loses nothing; a failure
2889        // after it leaves at most an orphan `.removing` directory, which the
2890        // next removal sweeps.
2891        self.sweep_removed_attachments();
2892        let attachments = self.attachments_dir(resolved);
2893        let aside = self.root.join(format!("{resolved}.attachments.removing"));
2894        let moved = match std::fs::rename(&attachments, &aside) {
2895            Ok(()) => true,
2896            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
2897            Err(e) => {
2898                return Err(e).with_context(|| format!("remove {}", attachments.display()));
2899            }
2900        };
2901        let path = self.path_of(resolved);
2902        if let Err(e) = remove_record(&path) {
2903            if moved {
2904                let _ = std::fs::rename(&aside, &attachments);
2905            }
2906            return Err(e).with_context(|| format!("remove {}", path.display()));
2907        }
2908        if moved {
2909            if let Err(e) = std::fs::remove_dir_all(&aside) {
2910                tracing::warn!("leftover attachments {}: {e}", aside.display());
2911            }
2912        }
2913        let lock = self.lock_path(resolved);
2914        if let Err(e) = std::fs::remove_file(&lock) {
2915            if e.kind() != std::io::ErrorKind::NotFound {
2916                return Err(e).with_context(|| format!("remove {}", lock.display()));
2917            }
2918        }
2919        Ok(())
2920    }
2921
2922    /// Delete `*.attachments.removing` directories a previous [`Queue::remove`]
2923    /// could not finish deleting. The task record is already gone by then, so
2924    /// the id no longer resolves and the removal cannot be retried by name;
2925    /// every later removal sweeps them instead. A directory whose record still
2926    /// exists belongs to a removal in progress and is left alone. Best-effort.
2927    fn sweep_removed_attachments(&self) {
2928        let Ok(entries) = std::fs::read_dir(&self.root) else {
2929            return;
2930        };
2931        for entry in entries.flatten() {
2932            let name = entry.file_name();
2933            let name = name.to_string_lossy();
2934            let Some(id) = name.strip_suffix(".attachments.removing") else {
2935                continue;
2936            };
2937            // A record still on disk means a removal is mid-flight (or about
2938            // to roll back): the directory is its to delete or restore.
2939            if !self.path_of(id).exists() {
2940                if let Err(e) = std::fs::remove_dir_all(entry.path()) {
2941                    tracing::warn!("leftover attachments {}: {e}", entry.path().display());
2942                }
2943            }
2944        }
2945        self.sweep_tombstones();
2946    }
2947
2948    /// Delete `*.removed` markers that are no longer needed: the record is
2949    /// gone, no task's `blocked_by` names the id, and the marker is older than
2950    /// [`TOMBSTONE_GRACE`]. The age is what makes this safe against a
2951    /// concurrent writer: the task list read here can be stale, so a dependent
2952    /// that was just blocked on an id (a conductor applying a decision while a
2953    /// removal is under way) would otherwise lose the marker and be held as a
2954    /// vanished dependency. A marker whose record still exists belongs to a
2955    /// removal in progress and is never touched.
2956    fn sweep_tombstones(&self) {
2957        let Ok(entries) = std::fs::read_dir(&self.root) else {
2958            return;
2959        };
2960        let tasks = self.list();
2961        for entry in entries.flatten() {
2962            let name = entry.file_name();
2963            let name = name.to_string_lossy();
2964            let Some(id) = name.strip_suffix(".removed") else {
2965                continue;
2966            };
2967            if self.path_of(id).exists()
2968                || tasks.iter().any(|t| {
2969                    t.blocked_by
2970                        .iter()
2971                        .any(|b| id.starts_with(b) || id.ends_with(b))
2972                })
2973            {
2974                continue;
2975            }
2976            let old = entry
2977                .metadata()
2978                .and_then(|m| m.modified())
2979                .ok()
2980                .and_then(|m| m.elapsed().ok())
2981                .is_some_and(|age| age >= TOMBSTONE_GRACE);
2982            if old {
2983                let _ = std::fs::remove_file(entry.path());
2984            }
2985        }
2986    }
2987
2988    /// Path of the claim lock for a task. One definition, so `claim` and
2989    /// `remove` cannot end up naming different files.
2990    fn lock_path(&self, id: &str) -> PathBuf {
2991        self.root.join(format!("{id}.lock"))
2992    }
2993
2994    /// Every task on disk, highest priority first and newest first within a
2995    /// priority. This is what `magi task list` and `GET /api/queue` print, so
2996    /// a raised priority has to move a task here the moment it is saved, not
2997    /// only in [`Queue::next_runnable`]'s own ordering - the operator reading
2998    /// the backlog and the loop about to drain it must agree on what "first"
2999    /// means. Every existing task defaults to priority 0, so this is a no-op
3000    /// change from the old newest-first order for a queue nobody has
3001    /// reprioritised.
3002    ///
3003    /// Unreadable files are skipped rather than fatal: one corrupt task must
3004    /// not take the queue - or the web UI, or an unattended daemon - down
3005    /// with it.
3006    pub fn list(&self) -> Vec<Task> {
3007        let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
3008            .into_iter()
3009            .flatten()
3010            .flatten()
3011            .map(|e| e.path())
3012            .filter(|p| p.extension().is_some_and(|x| x == "json"))
3013            .filter_map(|p| read_path(&p).ok())
3014            .collect();
3015        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
3016        tasks
3017    }
3018
3019    /// Task files [`Queue::list`] skips because they cannot be parsed (a
3020    /// newer schema, a torn write). A guard that looks a task up by scanning
3021    /// the list must treat any of these as "the task might be there".
3022    pub fn unreadable(&self) -> Vec<PathBuf> {
3023        std::fs::read_dir(&self.root)
3024            .into_iter()
3025            .flatten()
3026            .flatten()
3027            .map(|e| e.path())
3028            .filter(|p| p.extension().is_some_and(|x| x == "json"))
3029            .filter(|p| read_path(p).is_err())
3030            .collect()
3031    }
3032
3033    /// Runs that a later attempt at the same task replaced, mapped to the id
3034    /// of the attempt that replaced them.
3035    ///
3036    /// A task keeps its attempts in order, and the deck showed them as two
3037    /// cards with the same title and no hint which was which: yukimemi asked
3038    /// why `stalled` and `blocked` appeared twice for one task, and the
3039    /// answer - "those are two tries, and the second one exists because of a
3040    /// bug since fixed" - was not on the screen anywhere.
3041    ///
3042    /// Read from the queue rather than stored on the run, because the
3043    /// ordering is the queue's fact: a `RunState` has no idea another attempt
3044    /// happened after it.
3045    pub fn superseded(&self) -> HashMap<String, String> {
3046        let mut by = HashMap::new();
3047        for task in self.list() {
3048            for earlier in &task.runs {
3049                if let Some(later) = task.successor_of(earlier) {
3050                    by.insert(earlier.clone(), later.clone());
3051                }
3052            }
3053        }
3054        by
3055    }
3056
3057    /// Whether `run` is an earlier attempt a later one replaced, and if so
3058    /// the id of that later attempt.
3059    ///
3060    /// Same walk as [`Queue::superseded`], narrowed to one run: a run detail
3061    /// page asks about exactly one run at a time, and this keeps that call
3062    /// site from building (and discarding) the whole map's `HashMap` just to
3063    /// read one entry out of it.
3064    pub fn superseded_by(&self, run: &str) -> Option<String> {
3065        for task in self.list() {
3066            if task.runs.iter().any(|r| r == run) {
3067                return task.successor_of(run).cloned();
3068            }
3069        }
3070        None
3071    }
3072
3073    /// The task's own most recent attempt, when `run` belongs to that task
3074    /// but is not already that attempt.
3075    ///
3076    /// Distinct from [`Queue::superseded_by`], which names only the very
3077    /// next attempt: a chain of retries (A superseded by B superseded by C)
3078    /// leaves an older run pointing at an intermediate one that may itself
3079    /// be unresolved, and a run's own detail page needs to know where the
3080    /// task's story currently stands - the chain's current head, C - not an
3081    /// attempt in the middle of it that a client would otherwise have to
3082    /// walk to by hand.
3083    pub fn latest_attempt(&self, run: &str) -> Option<String> {
3084        for task in self.list() {
3085            if task.runs.iter().any(|r| r == run) {
3086                return task.runs.last().filter(|last| **last != run).cloned();
3087            }
3088        }
3089        None
3090    }
3091
3092    /// The task a daemon should run next, or `None` when the queue is idle.
3093    ///
3094    /// Upgrade resumes first, then highest priority and oldest id first.
3095    /// See [`dispatch_order`] for the durable ordering rule.
3096    pub fn next_runnable(&self) -> Option<Task> {
3097        let mut runnable: Vec<Task> = self
3098            .list()
3099            .into_iter()
3100            .filter(|t| t.status.runnable())
3101            .collect();
3102        runnable.sort_unstable_by(dispatch_order);
3103        runnable.into_iter().next()
3104    }
3105
3106    /// Take exclusive ownership of a task.
3107    ///
3108    /// The lock is a `create_new` file next to the task, which is atomic on
3109    /// every platform magi targets. It exists so two daemons - or a daemon and
3110    /// a human running `magi run` - cannot drive one task into two competing
3111    /// runs. The returned guard releases on drop, including on panic.
3112    pub fn claim(&self, id: &str) -> Result<Claim> {
3113        std::fs::create_dir_all(&self.root)
3114            .with_context(|| format!("create {}", self.root.display()))?;
3115        let path = self.lock_path(id);
3116        let retry = crate::lock_file::Retry::new(None);
3117        match retry.create(&path) {
3118            Ok(mut f) => {
3119                use std::io::Write as _;
3120                // Best effort: the pid is for the human looking at a stale lock.
3121                let _ = writeln!(f, "{}", std::process::id());
3122                Ok(Claim { path })
3123            }
3124            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
3125                bail!("task {id} is already claimed ({} exists)", path.display())
3126            }
3127            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
3128        }
3129    }
3130
3131    /// Expand an id prefix to exactly one task id.
3132    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
3133        if !prefix.is_empty()
3134            && prefix != "."
3135            && prefix != ".."
3136            && !prefix.contains(['/', '\\'])
3137            && self.path_of(prefix).is_file()
3138        {
3139            return Ok(prefix.to_owned());
3140        }
3141        let ids = record_ids(&self.root, "json")?;
3142        match resolve_reference(prefix, ids.iter().map(String::as_str)) {
3143            Reference::Found(id) => Ok(id),
3144            Reference::Missing => bail!("no task matches `{prefix}`"),
3145            Reference::Ambiguous => bail!("`{prefix}` matches multiple tasks"),
3146        }
3147    }
3148
3149    /// Change detection token for the queue.
3150    ///
3151    /// Combines file names and modification times of all task files in the
3152    /// queue, so adding, modifying, or deleting any task — even an older one —
3153    /// moves the revision and notifies connected clients via the change stream.
3154    /// Returns 0 when the queue is completely empty.
3155    pub fn revision(&self) -> u64 {
3156        self.revision_excluding(&std::collections::BTreeSet::new())
3157    }
3158
3159    /// [`Queue::revision`] ignoring the files of the named task ids, for a
3160    /// caller that tracks those tasks' content some other way.
3161    pub fn revision_excluding(&self, skip: &std::collections::BTreeSet<String>) -> u64 {
3162        use std::hash::{Hash as _, Hasher as _};
3163
3164        let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
3165            .into_iter()
3166            .flatten()
3167            .flatten()
3168            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
3169            .filter(|e| {
3170                let path = e.path();
3171                !path
3172                    .file_stem()
3173                    .is_some_and(|stem| skip.contains(stem.to_string_lossy().as_ref()))
3174            })
3175            .filter_map(|e| {
3176                let name = e.file_name().to_string_lossy().into_owned();
3177                let mtime = e
3178                    .metadata()
3179                    .ok()?
3180                    .modified()
3181                    .ok()?
3182                    .duration_since(std::time::UNIX_EPOCH)
3183                    .ok()?
3184                    .as_millis() as u64;
3185                Some((name, mtime))
3186            })
3187            .collect();
3188
3189        if entries.is_empty() {
3190            return 0;
3191        }
3192
3193        entries.sort_unstable();
3194        let mut hasher = std::hash::DefaultHasher::new();
3195        for (name, mtime) in &entries {
3196            name.hash(&mut hasher);
3197            mtime.hash(&mut hasher);
3198        }
3199        let h = hasher.finish();
3200        if h == 0 { 1 } else { h }
3201    }
3202}
3203
3204/// How long a deletion marker is kept after its record is gone, even with
3205/// nothing referencing it. See [`Queue::sweep_tombstones`].
3206const TOMBSTONE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
3207
3208/// What [`Queue::remove`] did, beyond deleting the named task's own file.
3209#[derive(Debug, Clone)]
3210pub struct Removal {
3211    /// The id actually removed - `id` expanded from a prefix, if it was one.
3212    pub id: String,
3213    /// Every task that was `blocked` on [`Removal::id`] and left the block
3214    /// (back to the status it interrupted) because that was its only
3215    /// remaining dependency.
3216    pub released: Vec<String>,
3217    /// Every task that named [`Removal::id`] and is still `blocked` on
3218    /// another dependency.
3219    pub still_blocked: Vec<String>,
3220}
3221
3222/// Exclusive ownership of a task, released on drop.
3223#[derive(Debug)]
3224pub struct Claim {
3225    path: PathBuf,
3226}
3227
3228impl Drop for Claim {
3229    fn drop(&mut self) {
3230        let _ = std::fs::remove_file(&self.path);
3231    }
3232}
3233
3234/// The first line of a task, trimmed to a title. Used when the caller gives a
3235/// body but no title, which is the normal case for an agent piping a file in.
3236pub fn title_from(instruction: &str, max: usize) -> String {
3237    let Some(line) = first_line(instruction) else {
3238        return "(empty task)".to_owned();
3239    };
3240    if line.chars().count() <= max {
3241        return line.to_owned();
3242    }
3243    let head: String = line.chars().take(max.saturating_sub(1)).collect();
3244    format!("{head}…")
3245}
3246
3247/// The first non-blank line, whatever it is. A markdown heading is the
3248/// task's own summary - agents pipe in `# Rework the config loader` and mean
3249/// exactly that - so it is preferred over the prose beneath it rather than
3250/// skipped as decoration. Leading list and heading markers are stripped
3251/// because they are syntax, not words. `None` when nothing is left.
3252pub(crate) fn first_line(instruction: &str) -> Option<&str> {
3253    let line = instruction
3254        .lines()
3255        .map(str::trim)
3256        .find(|l| !l.is_empty())?
3257        .trim_start_matches(['#', '-', '*', '>', ' '])
3258        .trim();
3259    (!line.is_empty()).then_some(line)
3260}
3261
3262fn read_path(path: &Path) -> Result<Task> {
3263    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
3264    let task: Task =
3265        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3266    // Greater-than, not not-equal: every field added since schema 1 carries
3267    // `#[serde(default)]`, so an older task has nothing to say about it and
3268    // defaulting is exactly as good a reading as a value that build never had
3269    // a chance to write. Only a schema *ahead* of this build - a meaning it
3270    // cannot possibly know - is refused rather than guessed at.
3271    if task.schema > SCHEMA {
3272        bail!(
3273            "task {} was written by a different magi (schema {}, this build \
3274             speaks {SCHEMA})",
3275            task.id,
3276            task.schema
3277        );
3278    }
3279    Ok(task)
3280}
3281
3282/// Resolution shared by queue lookups and dependency inventories. Exact ids
3283/// win; otherwise both the CLI prefix and displayed suffix are supported.
3284#[derive(Debug, PartialEq, Eq)]
3285pub(crate) enum Reference {
3286    Found(String),
3287    Missing,
3288    Ambiguous,
3289}
3290
3291pub(crate) fn resolve_reference<'a>(id: &str, ids: impl Iterator<Item = &'a str>) -> Reference {
3292    let mut hits = Vec::new();
3293    for candidate in ids {
3294        if candidate == id {
3295            return Reference::Found(candidate.to_owned());
3296        }
3297        if candidate.starts_with(id) || candidate.ends_with(id) {
3298            hits.push(candidate);
3299        }
3300    }
3301    match hits.as_slice() {
3302        [] => Reference::Missing,
3303        [one] => Reference::Found((*one).to_owned()),
3304        _ => Reference::Ambiguous,
3305    }
3306}
3307
3308/// Read names, not deserialized records: list() intentionally skips corrupt
3309/// and future-schema records, which still exist and must keep their waiters.
3310pub(crate) fn record_ids(root: &Path, extension: &str) -> Result<Vec<String>> {
3311    let entries = match std::fs::read_dir(root) {
3312        Ok(entries) => entries,
3313        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
3314        Err(e) => return Err(e.into()),
3315    };
3316    let mut ids = Vec::new();
3317    for entry in entries {
3318        let path = entry?.path();
3319        if path.extension().is_some_and(|e| e == extension)
3320            && let Some(id) = path.file_stem().and_then(|s| s.to_str())
3321        {
3322            ids.push(id.to_owned());
3323        }
3324    }
3325    Ok(ids)
3326}
3327
3328/// Unknown and ambiguous dependencies stay waiting; neither is evidence that
3329/// the operator deleted a prerequisite or needs to supply its replacement.
3330#[derive(Debug, PartialEq, Eq)]
3331pub enum Dependency {
3332    /// Existing task record, with its canonical id; its contents may be unreadable.
3333    Task(String),
3334    /// Existing question record, with its canonical id.
3335    Question(String),
3336    /// Explicitly removed task, identified by its tombstone.
3337    Deleted(String),
3338    /// No matching record or tombstone after a successful inventory read.
3339    Missing,
3340    /// More than one record matches the reference.
3341    Ambiguous,
3342    /// The inventory cannot currently be read, so existence is unknown.
3343    Unknown,
3344}
3345
3346/// Resolve against authoritative task, question and deletion record names.
3347pub fn dependency(queue: &Queue, questions: &Questions, id: &str) -> Dependency {
3348    let (Ok(tasks), Ok(qs), Ok(removed)) = (
3349        record_ids(queue.root(), "json"),
3350        record_ids(questions.root(), "json"),
3351        record_ids(queue.root(), "removed"),
3352    ) else {
3353        return Dependency::Unknown;
3354    };
3355    // Include tombstones in ambiguity checks: a reused suffix must never
3356    // release a waiter on a different, still-live task.
3357    let mut ids = tasks
3358        .iter()
3359        .chain(qs.iter())
3360        .chain(removed.iter())
3361        .cloned()
3362        .collect::<Vec<_>>();
3363    ids.sort();
3364    ids.dedup();
3365    match resolve_reference(id, ids.iter().map(String::as_str)) {
3366        Reference::Found(id) if tasks.contains(&id) => Dependency::Task(id),
3367        Reference::Found(id) if qs.contains(&id) => Dependency::Question(id),
3368        Reference::Found(id) if queue.was_deleted(&id) => Dependency::Deleted(id),
3369        Reference::Found(_) => Dependency::Unknown,
3370        Reference::Missing => Dependency::Missing,
3371        Reference::Ambiguous => Dependency::Ambiguous,
3372    }
3373}
3374
3375/// Canonicalize only proven dependencies, preserving unknown legacy references.
3376pub fn normalize_blockers(queue: &Queue, questions: &Questions, ids: &mut [String]) {
3377    for id in ids {
3378        if let Dependency::Task(full) | Dependency::Question(full) | Dependency::Deleted(full) =
3379            dependency(queue, questions, id)
3380        {
3381            *id = full;
3382        }
3383    }
3384}
3385
3386/// Live queue facts for decision and deputy prompts. Historical hold reasons
3387/// and question text are claims, never an inventory of existing prerequisites.
3388pub fn dependency_facts(queue: &Queue, questions: &Questions) -> String {
3389    let inventory = crate::blockers::Inventory::load(queue, questions);
3390    let mut text = String::from(
3391        "Current authoritative queue (historical missing-task claims may be stale; unreadable or ambiguous references must keep waiting):\n",
3392    );
3393    match record_ids(queue.root(), "json") {
3394        Ok(ids) => {
3395            for id in ids {
3396                match queue.get(&id) {
3397                    Ok(task) => text.push_str(&format!(
3398                        "{}: {}; blocked_by={:?}; chain={:?}\n",
3399                        task.id,
3400                        task.status.as_str(),
3401                        task.blocked_by,
3402                        inventory.waits_on(&task)
3403                    )),
3404                    Err(_) => text.push_str(&format!("{id}: exists, state unreadable\n")),
3405                }
3406            }
3407        }
3408        Err(_) => text.push_str("Queue inventory unreadable: existence is unknown.\n"),
3409    }
3410    text
3411}
3412
3413/// Only a successful directory scan with no matching record proves absence.
3414pub fn missing_blockers(
3415    queue: &Queue,
3416    questions: &Questions,
3417    blocked_by: &[String],
3418) -> Vec<String> {
3419    blocked_by
3420        .iter()
3421        .filter(|id| dependency(queue, questions, id) == Dependency::Missing)
3422        .cloned()
3423        .collect()
3424}
3425
3426/// Return the original references, so legacy short ids are removed by the
3427/// same Task transition as full ids.
3428pub fn deleted_blockers(
3429    queue: &Queue,
3430    questions: &Questions,
3431    blocked_by: &[String],
3432) -> Vec<String> {
3433    blocked_by
3434        .iter()
3435        .filter(|id| matches!(dependency(queue, questions, id), Dependency::Deleted(_)))
3436        .cloned()
3437        .collect()
3438}
3439
3440/// The `hold_reason` text for a task quarantined because one or more of its
3441/// `blocked_by` ids no longer exist. Shared by `crate::daemon::resolve_blockers`,
3442/// `crate::triage::run_once`, and [`Queue::remove`]'s own dependent
3443/// quarantine, so the three call sites read as the same event to an operator
3444/// looking at `magi task show` rather than three different wordings for it.
3445///
3446/// Names the full original `blocked_by` list, not just `missing` - a task
3447/// quarantined here can also have named a dependency that was still
3448/// perfectly valid, and [`Task::hold_machine`] clears `blocked_by` on the way
3449/// in, so this text is the only place that information survives for an
3450/// operator deciding whether to release the task outright.
3451pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
3452    missing_blocker_hold_reason_in(blocked_by, missing, "en")
3453}
3454
3455/// [`missing_blocker_hold_reason`] in `language` (Japanese, else English).
3456pub fn missing_blocker_hold_reason_in(
3457    blocked_by: &[String],
3458    missing: &[String],
3459    language: &str,
3460) -> String {
3461    if crate::lang::is_japanese(language) {
3462        format!(
3463            "{} を待っていましたが、{} はディスク上に存在しません - `magi task triage` を参照",
3464            blocked_by.join(", "),
3465            missing.join(", "),
3466        )
3467    } else {
3468        format!(
3469            "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
3470            blocked_by.join(", "),
3471            missing.join(", "),
3472        )
3473    }
3474}
3475
3476/// The last dash-separated part of an id, the form people say aloud.
3477pub fn short(id: &str) -> &str {
3478    id.split('-').next_back().unwrap_or(id)
3479}
3480
3481fn new_id() -> String {
3482    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
3483    let seed = crate::rng::entropy();
3484    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
3485}
3486
3487#[cfg(test)]
3488mod tests {
3489    #[test]
3490    fn explicit_resumption_is_not_invented_for_legacy_or_automatic_release() {
3491        let t = Task::new(
3492            "retry".into(),
3493            "do it".into(),
3494            PathBuf::from("."),
3495            Source::Human,
3496        );
3497        let mut value = serde_json::to_value(&t).unwrap();
3498        for field in ["resumption", "resumption_history", "operator_decided_at"] {
3499            value.as_object_mut().unwrap().remove(field);
3500        }
3501        value["schema"] = serde_json::json!(15);
3502        let mut legacy: Task = serde_json::from_value(value).unwrap();
3503        legacy.hold_machine(Some("automatic retry".into()));
3504        legacy.release();
3505        assert!(legacy.resumption.is_none());
3506        assert!(legacy.resumption_history.is_empty());
3507        assert!(legacy.operator_decided_at.is_none());
3508        legacy.release_operator("cli", None);
3509        legacy.start("run".into());
3510        let reloaded: Task =
3511            serde_json::from_str(&serde_json::to_string(&legacy).unwrap()).unwrap();
3512        assert!(reloaded.resume_override.is_none());
3513        assert_eq!(reloaded.resumption.as_ref().unwrap().generation, 1);
3514    }
3515
3516    #[test]
3517    fn merge_over_lets_the_later_choice_win_and_keeps_the_rest() {
3518        let mut base = RunOverrides {
3519            merge: Some("pr".to_owned()),
3520            candidates: Some(3),
3521            ..RunOverrides::default()
3522        };
3523        base.merge_over(&RunOverrides {
3524            merge: Some("none".to_owned()),
3525            seed: Some(7),
3526            ..RunOverrides::default()
3527        });
3528        assert_eq!(base.merge.as_deref(), Some("none"));
3529        assert_eq!(base.candidates, Some(3));
3530        assert_eq!(base.seed, Some(7));
3531    }
3532
3533    #[test]
3534    fn an_already_landed_task_is_done_with_its_attempt_refunded() {
3535        let mut t = task("relanded");
3536        t.attempts = 1;
3537        t.status = TaskStatus::Running;
3538        t.already_landed("already in main as 0e368de");
3539        assert_eq!(t.status, TaskStatus::Done);
3540        assert_eq!(t.attempts, 0);
3541        assert!(t.hold_reason.is_none());
3542        assert_eq!(t.last_error.as_deref(), Some("already in main as 0e368de"));
3543    }
3544
3545    #[test]
3546    fn missing_blocker_reason_follows_the_language() {
3547        let b = vec!["a".to_owned()];
3548        let en = missing_blocker_hold_reason_in(&b, &b, "en");
3549        assert_eq!(en, missing_blocker_hold_reason(&b, &b));
3550        assert!(en.starts_with("blocked on a"));
3551        assert!(missing_blocker_hold_reason_in(&b, &b, "ja").contains("存在しません"));
3552        assert_eq!(missing_blocker_hold_reason_in(&b, &b, "de"), en);
3553    }
3554
3555    use super::*;
3556
3557    #[test]
3558    fn triage_applied_survives_release_and_old_records_read_as_empty() {
3559        let mut t = Task::new(
3560            "t".to_owned(),
3561            "i".to_owned(),
3562            PathBuf::from("r"),
3563            Source::Human,
3564        );
3565        t.mark_triage_applied("q1");
3566        t.mark_triage_applied("q1");
3567        t.hold_machine(Some("x".to_owned()));
3568        t.release();
3569        assert_eq!(t.triage_applied, ["q1"]);
3570        assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
3571
3572        let mut v = serde_json::to_value(&t).unwrap();
3573        v.as_object_mut().unwrap().remove("triage_applied");
3574        let old: Task = serde_json::from_value(v).unwrap();
3575        assert!(old.triage_applied.is_empty());
3576    }
3577
3578    #[test]
3579    fn task_counts_of_empty_is_all_zero() {
3580        assert_eq!(TaskCounts::of(&[]), TaskCounts::default());
3581    }
3582
3583    #[test]
3584    fn task_counts_of_tallies_every_status() {
3585        let mut queued = Task::new(
3586            "q".to_owned(),
3587            "i".to_owned(),
3588            PathBuf::from("."),
3589            Source::Human,
3590        );
3591        queued.status = TaskStatus::Queued;
3592        let mut running = queued.clone();
3593        running.status = TaskStatus::Running;
3594        let mut done = queued.clone();
3595        done.status = TaskStatus::Done;
3596        let mut failed = queued.clone();
3597        failed.status = TaskStatus::Failed;
3598        let mut held = queued.clone();
3599        held.status = TaskStatus::Held;
3600        let mut blocked = queued.clone();
3601        blocked.status = TaskStatus::Blocked;
3602
3603        let mut parked = queued.clone();
3604        parked.status = TaskStatus::Parked;
3605
3606        let counts = TaskCounts::of(&[
3607            queued,
3608            running,
3609            done.clone(),
3610            done,
3611            failed,
3612            held,
3613            blocked,
3614            parked,
3615        ]);
3616        assert_eq!(
3617            counts,
3618            TaskCounts {
3619                queued: 1,
3620                running: 1,
3621                done: 2,
3622                failed: 1,
3623                held: 1,
3624                blocked: 1,
3625                parked: 1,
3626            }
3627        );
3628    }
3629
3630    #[test]
3631    fn task_implementers_persist_normalized_without_losing_history() {
3632        let (_dir, q) = queue();
3633        let mut t = task("keep me");
3634        t.hold_manual(Some("waiting".into()));
3635        t.runs.push("earlier-run".into());
3636        t.attachments.push("reference.txt".into());
3637        let cfg: crate::config::Config =
3638            toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
3639                .unwrap();
3640        q.put(&mut t).unwrap();
3641        let original = serde_json::to_value(&t).unwrap();
3642        for n in [2, 1, 2] {
3643            t.set_implementers(n, &cfg).unwrap();
3644            q.put(&mut t).unwrap();
3645            t = q.get(&t.id).unwrap();
3646            assert_eq!(t.implementers(), Some(usize::from(n)));
3647            assert_eq!(t.solo, n == 1);
3648            if n == 1 {
3649                assert!(t.overrides.is_none());
3650            }
3651            let mut actual = serde_json::to_value(&t).unwrap();
3652            for field in ["solo", "overrides", "updated_at"] {
3653                actual[field] = original[field].clone();
3654            }
3655            assert_eq!(actual, original);
3656        }
3657        t.overrides.as_mut().unwrap().judges = Some(2);
3658        t.set_implementers(1, &cfg).unwrap();
3659        assert_eq!(t.overrides.as_ref().unwrap().judges, Some(2));
3660        assert_eq!(t.overrides.as_ref().unwrap().candidates, None);
3661    }
3662
3663    #[test]
3664    fn task_implementers_old_records_keep_inheritance_until_changed() {
3665        let mut value = serde_json::to_value(task("old task")).unwrap();
3666        value.as_object_mut().unwrap().remove("solo");
3667        value.as_object_mut().unwrap().remove("overrides");
3668        let mut old: Task = serde_json::from_value(value).unwrap();
3669        assert_eq!(old.implementers(), None);
3670        old.set_implementers(1, &crate::config::Config::default())
3671            .unwrap();
3672        assert!(old.solo);
3673        assert!(old.overrides.is_none());
3674    }
3675
3676    #[test]
3677    fn task_implementers_reject_invalid_counts_and_states() {
3678        let cfg = crate::config::Config::default();
3679        let mut t = task("unchanged");
3680        for n in [0, 4, 255] {
3681            assert!(t.set_implementers(n, &cfg).is_err());
3682        }
3683        for status in [
3684            TaskStatus::Running,
3685            TaskStatus::Done,
3686            TaskStatus::Failed,
3687            TaskStatus::Blocked,
3688            TaskStatus::Parked,
3689        ] {
3690            t.status = status;
3691            let before = serde_json::to_value(&t).unwrap();
3692            assert!(t.set_implementers(1, &cfg).is_err());
3693            assert_eq!(serde_json::to_value(&t).unwrap(), before);
3694        }
3695    }
3696
3697    /// A queue of its own, with no process-global state - which is the point of
3698    /// `Queue::at`, and why these can run in parallel.
3699    fn queue() -> (tempfile::TempDir, Queue) {
3700        let dir = tempfile::tempdir().unwrap();
3701        let q = Queue::at(dir.path().join("queue"));
3702        (dir, q)
3703    }
3704
3705    #[test]
3706    fn a_stale_write_does_not_drop_the_recorded_task_chat() {
3707        let (_dir, q) = queue();
3708        let mut stale = task("chat");
3709        q.put(&mut stale).unwrap();
3710        let mut fresh = q.get(&stale.id).unwrap();
3711        fresh.chat = Some("talk-1".to_owned());
3712        q.put(&mut fresh).unwrap();
3713        q.put(&mut stale).unwrap();
3714        assert_eq!(q.get(&stale.id).unwrap().chat.as_deref(), Some("talk-1"));
3715    }
3716
3717    #[test]
3718    fn putting_a_machine_held_task_files_a_notification_beside_the_queue() {
3719        let dir = tempfile::tempdir().unwrap();
3720        let q = Queue::at(dir.path().join("queue"));
3721        let mut t = task("held");
3722        q.put(&mut t).unwrap();
3723        assert_eq!(
3724            crate::notices::Notices::at(dir.path().join("notifications"))
3725                .list()
3726                .len(),
3727            0
3728        );
3729        t.hold_machine(Some("out of attempts".to_owned()));
3730        q.put(&mut t).unwrap();
3731        let listed = crate::notices::Notices::at(dir.path().join("notifications")).list();
3732        assert_eq!(listed.len(), 1);
3733        assert!(listed[0].message.contains("out of attempts"));
3734    }
3735
3736    fn task(title: &str) -> Task {
3737        Task::new(
3738            title.to_owned(),
3739            format!("do {title}"),
3740            PathBuf::from("."),
3741            Source::Human,
3742        )
3743    }
3744
3745    #[test]
3746    fn attempt_limit_inherits_old_records_and_controls_failure() {
3747        let mut t = task("budget");
3748        let mut old = serde_json::to_value(&t).unwrap();
3749        old.as_object_mut().unwrap().remove("attempt_limit");
3750        old["schema"] = serde_json::json!(15);
3751        let legacy: Task = serde_json::from_value(old).unwrap();
3752        assert_eq!(legacy.attempt_limit, None);
3753        assert_eq!(legacy.effective_attempt_limit(2), 2);
3754        t.attempts = 2;
3755        t.change_attempt_limit(NonZeroUsize::new(3)).unwrap();
3756        t.fail("retry", 1);
3757        assert_eq!(t.status, TaskStatus::Failed);
3758        t.attempts = 3;
3759        t.fail("stop", 10);
3760        assert_eq!(t.status, TaskStatus::Held);
3761        t.change_attempt_limit(None).unwrap();
3762        assert_eq!(t.status, TaskStatus::Held);
3763        assert_eq!(t.attempts, 3);
3764        t.fail("inherit", 4);
3765        assert_eq!(t.status, TaskStatus::Failed);
3766        for status in [
3767            TaskStatus::Running,
3768            TaskStatus::Done,
3769            TaskStatus::Failed,
3770            TaskStatus::Blocked,
3771            TaskStatus::Parked,
3772        ] {
3773            t.status = status;
3774            assert!(t.change_attempt_limit(NonZeroUsize::new(1)).is_err());
3775            assert_eq!(t.attempt_limit, None);
3776        }
3777    }
3778
3779    #[test]
3780    fn earlier_attempts_is_every_recorded_run_and_agrees_with_the_display() {
3781        let mut t = task("retried");
3782        assert!(
3783            t.earlier_attempts().is_empty(),
3784            "a first attempt takes nothing over"
3785        );
3786        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3787        assert_eq!(t.earlier_attempts(), ["aaaa", "bbbb"]);
3788        // What the web note shows and what a takeover acts on are one rule.
3789        assert_eq!(t.successor_of("aaaa"), Some(&"bbbb".to_owned()));
3790        assert_eq!(t.successor_of("bbbb"), None);
3791        assert_eq!(t.successor_of("zzzz"), None);
3792    }
3793
3794    #[test]
3795    fn resumed_runs_are_superseded_only_after_their_last_occurrence() {
3796        let (_dir, q) = queue();
3797        let mut t = task("resumed");
3798        for (runs, successor) in [
3799            (vec!["aaaa", "aaaa"], None),
3800            (vec!["aaaa", "bbbb", "aaaa"], None),
3801            (vec!["aaaa", "aaaa", "bbbb"], Some("bbbb")),
3802            (vec!["aaaa", "bbbb", "aaaa", "cccc"], Some("cccc")),
3803        ] {
3804            t.runs = runs.into_iter().map(str::to_owned).collect();
3805            q.put(&mut t).unwrap();
3806
3807            assert_eq!(t.successor_of("aaaa").map(String::as_str), successor);
3808            assert_eq!(q.superseded_by("aaaa").as_deref(), successor);
3809            assert_eq!(q.superseded().get("aaaa").map(String::as_str), successor);
3810        }
3811    }
3812
3813    #[test]
3814    fn superseded_by_names_the_next_attempt_and_none_for_the_last() {
3815        let (_dir, q) = queue();
3816        let mut t = task("retried");
3817        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3818        q.put(&mut t).unwrap();
3819
3820        assert_eq!(q.superseded_by("aaaa"), Some("bbbb".to_owned()));
3821        assert_eq!(q.superseded_by("bbbb"), Some("cccc".to_owned()));
3822        assert_eq!(
3823            q.superseded_by("cccc"),
3824            None,
3825            "the latest attempt replaces nothing"
3826        );
3827        assert_eq!(
3828            q.superseded_by("never-heard-of-it"),
3829            None,
3830            "a run belonging to no task on this queue is not superseded"
3831        );
3832
3833        let mut by = HashMap::new();
3834        by.insert("aaaa".to_owned(), "bbbb".to_owned());
3835        by.insert("bbbb".to_owned(), "cccc".to_owned());
3836        assert_eq!(
3837            q.superseded(),
3838            by,
3839            "the whole-map and single-run forms must agree"
3840        );
3841    }
3842
3843    #[test]
3844    fn superseded_attempts_is_empty_until_the_task_is_done() {
3845        let mut t = task("retried");
3846        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3847        t.status = TaskStatus::Failed;
3848        assert_eq!(
3849            t.superseded_attempts(true),
3850            &[] as &[String],
3851            "a task still retrying has no attempt yet that a later one made moot"
3852        );
3853
3854        t.status = TaskStatus::Running;
3855        assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3856    }
3857
3858    #[test]
3859    fn superseded_attempts_names_every_run_before_the_one_that_succeeded() {
3860        let mut t = task("retried");
3861        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3862        t.status = TaskStatus::Done;
3863        assert_eq!(
3864            t.superseded_attempts(true),
3865            &["aaaa".to_owned(), "bbbb".to_owned()],
3866            "cccc is the attempt whose success made the task done, and stays out"
3867        );
3868    }
3869
3870    #[test]
3871    fn superseded_attempts_is_empty_for_a_done_task_with_only_one_attempt() {
3872        let mut t = task("first try landed");
3873        t.runs = vec!["aaaa".to_owned()];
3874        t.status = TaskStatus::Done;
3875        assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3876    }
3877
3878    #[test]
3879    fn superseded_attempts_is_empty_when_the_last_run_never_actually_succeeded() {
3880        // `succeed` is reachable directly - `magi task done`, its web
3881        // equivalent, and the conductor's `Recovery::Done` - on a task in
3882        // any status, including one whose last recorded attempt is itself
3883        // `Blocked`/`Failed`/anything but `Merged`/`Ready`. `runs.last()`
3884        // alone cannot tell that apart from the loop's own settle path, so
3885        // the caller's own read of that run's status is what decides this.
3886        let mut t = task("closed by hand after a manual merge");
3887        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3888        t.status = TaskStatus::Done;
3889        assert_eq!(
3890            t.superseded_attempts(false),
3891            &[] as &[String],
3892            "nothing here is provably why the task is done, so nothing is superseded"
3893        );
3894    }
3895
3896    #[test]
3897    fn latest_attempt_names_the_chain_s_current_head_not_just_the_next_one() {
3898        let (_dir, q) = queue();
3899        let mut t = task("retried twice");
3900        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3901        q.put(&mut t).unwrap();
3902
3903        assert_eq!(
3904            q.latest_attempt("aaaa"),
3905            Some("cccc".to_owned()),
3906            "an old attempt points straight at the chain's current head, not the \
3907             next attempt in the middle of it"
3908        );
3909        assert_eq!(q.latest_attempt("bbbb"), Some("cccc".to_owned()));
3910        assert_eq!(
3911            q.latest_attempt("cccc"),
3912            None,
3913            "the latest attempt is not superseded by anything"
3914        );
3915        assert_eq!(
3916            q.latest_attempt("never-heard-of-it"),
3917            None,
3918            "a run belonging to no task on this queue is not superseded"
3919        );
3920    }
3921
3922    #[test]
3923    fn a_markdown_heading_is_the_title_not_decoration() {
3924        // A task file's heading is the summary its author already wrote, so it
3925        // beats the prose underneath. Getting this backwards was visible in the
3926        // first smoke test: a task titled "# Rework the config loader" listed
3927        // as "It re-reads the file on every lookup".
3928        assert_eq!(
3929            title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
3930            "Rework the config loader"
3931        );
3932        assert_eq!(title_from("- fix the thing", 40), "fix the thing");
3933        assert_eq!(title_from("> quoted task", 40), "quoted task");
3934        // Nothing usable at all still has to produce something printable.
3935        assert_eq!(title_from("   \n\n", 40), "(empty task)");
3936        assert_eq!(title_from("###\n", 40), "(empty task)");
3937    }
3938
3939    #[test]
3940    fn a_long_title_is_elided_by_characters_not_bytes() {
3941        // Byte truncation would split a multi-byte character and panic.
3942        let long = "課題".repeat(30);
3943        let title = title_from(&long, 10);
3944        assert_eq!(title.chars().count(), 10);
3945        assert!(title.ends_with('…'));
3946    }
3947
3948    #[test]
3949    fn priority_wins_and_ties_break_oldest_first() {
3950        let (_dir, q) = queue();
3951        let mut a = task("first");
3952        let mut b = task("second");
3953        let mut c = task("urgent");
3954        // Ids carry a timestamp, so force a known order.
3955        a.id = "20260101-000001-aaaa".to_owned();
3956        b.id = "20260101-000002-bbbb".to_owned();
3957        c.id = "20260101-000003-cccc".to_owned();
3958        c.priority = 5;
3959        for t in [&mut a, &mut b, &mut c] {
3960            q.put(t).unwrap();
3961        }
3962
3963        // Priority first...
3964        assert_eq!(q.next_runnable().unwrap().id, c.id);
3965        c.hold_machine(None);
3966        q.put(&mut c).unwrap();
3967        // ...then oldest, so a burst of new work cannot starve older work.
3968        assert_eq!(q.next_runnable().unwrap().id, a.id);
3969        assert_eq!(q.list().len(), 3, "b is still waiting its turn");
3970    }
3971
3972    #[test]
3973    fn a_blocked_task_never_starves_another_runnable_one() {
3974        let (_dir, q) = queue();
3975        let mut blocked = task("blocked");
3976        blocked.block(vec!["something".to_owned()], None);
3977        q.put(&mut blocked).unwrap();
3978
3979        let mut runnable = task("free to go");
3980        q.put(&mut runnable).unwrap();
3981
3982        let next = q.next_runnable().expect("a runnable task is still offered");
3983        assert_eq!(next.id, runnable.id);
3984    }
3985
3986    #[test]
3987    fn a_held_task_is_never_offered_to_the_loop() {
3988        let (_dir, q) = queue();
3989        let mut t = task("held");
3990        q.put(&mut t).unwrap();
3991        assert!(q.next_runnable().is_some());
3992
3993        t.hold_machine(None);
3994        q.put(&mut t).unwrap();
3995        assert!(
3996            q.next_runnable().is_none(),
3997            "a held task must wait for a human"
3998        );
3999
4000        // A failed task, by contrast, is exactly what the loop should retry.
4001        t.status = TaskStatus::Failed;
4002        q.put(&mut t).unwrap();
4003        assert!(q.next_runnable().is_some());
4004    }
4005
4006    #[test]
4007    fn attempts_are_capped_and_then_the_task_is_held() {
4008        let mut t = task("doomed");
4009
4010        t.start("run-1".to_owned());
4011        t.fail("gate red", 2);
4012        assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
4013
4014        t.start("run-2".to_owned());
4015        t.fail("gate red", 2);
4016        assert_eq!(
4017            t.status,
4018            TaskStatus::Held,
4019            "out of attempts: stop spending money on it"
4020        );
4021        assert_eq!(t.runs, ["run-1", "run-2"]);
4022        assert_eq!(t.last_error.as_deref(), Some("gate red"));
4023        assert_eq!(
4024            t.hold_reason.as_deref(),
4025            Some("gate red"),
4026            "the hold must say why, not leave hold_reason null next to a \
4027             populated last_error"
4028        );
4029    }
4030
4031    #[test]
4032    fn handing_off_a_task_records_a_hold_reason_too() {
4033        let mut t = task("left a pull request");
4034        t.start("run-1".to_owned());
4035        t.handed_off("run ended with a pull request open [run run-1]");
4036        assert_eq!(t.status, TaskStatus::Held);
4037        assert_eq!(t.hold_source, Some(HoldSource::Machine));
4038        assert_eq!(
4039            t.hold_reason.as_deref(),
4040            Some("run ended with a pull request open [run run-1]")
4041        );
4042        assert_eq!(t.hold_reason, t.last_error);
4043    }
4044
4045    #[test]
4046    fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
4047        let mut t = task("stalled by quota");
4048
4049        t.start("run-1".to_owned());
4050        assert_eq!(t.attempts, 1);
4051        t.stall("judge-1, judge-2 out of quota");
4052        assert_eq!(
4053            t.attempts, 0,
4054            "a closed quota window must not spend the task's retry budget"
4055        );
4056        assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
4057        assert_eq!(
4058            t.last_error.as_deref(),
4059            Some("judge-1, judge-2 out of quota")
4060        );
4061
4062        // A task can therefore stall all night and still get its real attempts
4063        // once the quota resets - which is the whole point.
4064        for _ in 0..20 {
4065            t.start("run-n".to_owned());
4066            t.stall("still out of quota");
4067        }
4068        t.start("run-real".to_owned());
4069        t.fail("gate red", 2);
4070        assert_eq!(
4071            t.status,
4072            TaskStatus::Failed,
4073            "the first attempt that was really judged is attempt one"
4074        );
4075    }
4076
4077    #[test]
4078    fn releasing_a_held_task_gives_it_a_real_second_chance() {
4079        let mut t = task("retry me");
4080        t.start("run-1".to_owned());
4081        t.fail("gate red", 1);
4082        assert_eq!(t.status, TaskStatus::Held);
4083
4084        t.release();
4085        assert_eq!(t.status, TaskStatus::Queued);
4086        // Without resetting attempts the next failure would re-hold at once,
4087        // and a release would be a no-op the operator cannot see.
4088        assert_eq!(t.attempts, 0);
4089        assert!(t.last_error.is_none());
4090        assert_eq!(
4091            t.runs.len(),
4092            1,
4093            "history is kept: attempts reset, evidence does not"
4094        );
4095    }
4096
4097    #[test]
4098    fn a_hold_reason_survives_and_a_release_clears_it() {
4099        let mut t = task("waiting on something else");
4100        t.hold_manual(Some(
4101            "waiting for 20260101-000000-aaaa to land first".to_owned(),
4102        ));
4103        assert_eq!(t.status, TaskStatus::Held);
4104        assert_eq!(
4105            t.hold_reason.as_deref(),
4106            Some("waiting for 20260101-000000-aaaa to land first")
4107        );
4108
4109        // Holding again with no reason must not erase the one already there.
4110        t.hold_manual(None);
4111        assert_eq!(
4112            t.hold_reason.as_deref(),
4113            Some("waiting for 20260101-000000-aaaa to land first"),
4114            "a bare re-hold keeps whatever a human already wrote down"
4115        );
4116
4117        // A hold with no reason at all is still an ordinary, allowed hold.
4118        let mut plain = task("no reason given");
4119        plain.hold_manual(None);
4120        assert_eq!(plain.status, TaskStatus::Held);
4121        assert!(plain.hold_reason.is_none());
4122
4123        t.release();
4124        assert_eq!(t.status, TaskStatus::Queued);
4125        assert!(
4126            t.hold_reason.is_none(),
4127            "a stale reason must not greet the next person who holds this task"
4128        );
4129    }
4130
4131    #[test]
4132    fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
4133        // `done` can close a held task directly - neither `magi task done`
4134        // nor `POST /api/queue/{id}/done` requires a release first - so a
4135        // task held for "waiting on 3ed9" and then closed without ever being
4136        // released must not still read as waiting on it afterwards.
4137        let mut t = task("landed by hand while held");
4138        t.hold_manual(Some("waiting on 3ed9".to_owned()));
4139        assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
4140
4141        t.succeed();
4142        assert_eq!(t.status, TaskStatus::Done);
4143        assert!(
4144            t.hold_reason.is_none(),
4145            "a done task cannot still be waiting on something"
4146        );
4147    }
4148
4149    #[test]
4150    fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
4151        // The web UI's "Hold" and "Mark done" buttons are both reachable on a
4152        // `blocked` task, not just on `queued`/`held` ones - neither requires
4153        // a release first. A task moved off `Blocked` that way must not still
4154        // carry the dependency it was waiting on: a dependency graph built
4155        // from `blocked_by` would otherwise keep drawing an edge for a task
4156        // that is not blocked on anything any more.
4157        let mut held = task("held straight out of blocked");
4158        held.block(
4159            vec!["20260101-000000-dead".to_owned()],
4160            Some("waiting on the migration script".to_owned()),
4161        );
4162        assert_eq!(held.status, TaskStatus::Blocked);
4163
4164        held.hold_manual(None);
4165        assert_eq!(held.status, TaskStatus::Held);
4166        assert!(
4167            held.blocked_by.is_empty(),
4168            "hold overrides the wait, same as release"
4169        );
4170        assert!(held.block_reason.is_none());
4171
4172        let mut done = task("closed straight out of blocked");
4173        done.block(
4174            vec!["20260101-000000-dead".to_owned()],
4175            Some("waiting on the migration script".to_owned()),
4176        );
4177        done.succeed();
4178        assert_eq!(done.status, TaskStatus::Done);
4179        assert!(
4180            done.blocked_by.is_empty(),
4181            "a done task cannot still be waiting on a dependency"
4182        );
4183        assert!(done.block_reason.is_none());
4184    }
4185
4186    #[test]
4187    fn a_blocked_task_is_never_offered_to_the_loop() {
4188        let mut t = task("blocked");
4189        assert!(t.status.runnable());
4190        t.block(
4191            vec!["dep-id".to_owned()],
4192            Some("waits on dep-id".to_owned()),
4193        );
4194        assert_eq!(t.status, TaskStatus::Blocked);
4195        assert!(!t.status.runnable());
4196        assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
4197    }
4198
4199    #[test]
4200    fn unblocking_the_last_dependency_returns_the_task_to_queued() {
4201        let mut t = task("blocked on two");
4202        t.block(
4203            vec!["a".to_owned(), "b".to_owned()],
4204            Some("waits on a and b".to_owned()),
4205        );
4206
4207        t.unblock("a");
4208        assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
4209        assert_eq!(t.blocked_by, ["b"]);
4210
4211        t.unblock("b");
4212        assert_eq!(t.status, TaskStatus::Queued);
4213        assert!(t.blocked_by.is_empty());
4214        assert!(t.block_reason.is_none());
4215    }
4216
4217    #[test]
4218    fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
4219        let mut t = task("never blocked");
4220        t.unblock("whatever");
4221        assert_eq!(t.status, TaskStatus::Queued);
4222    }
4223
4224    #[test]
4225    fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
4226        // The bug this guards: a task an operator (or `crate::triage`) has
4227        // deliberately held, once `crate::conduct` blocks it on a follow-up
4228        // question, must not silently re-enter the competition queue the
4229        // moment that question is answered - whatever the answer said.
4230        let mut t = task("held, then asked about");
4231        t.hold_machine(Some("out of attempts".to_owned()));
4232        assert_eq!(t.status, TaskStatus::Held);
4233
4234        t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
4235        assert_eq!(t.status, TaskStatus::Blocked);
4236
4237        t.record_answer("what now?".to_owned(), "leave it held".to_owned());
4238        t.unblock("q1");
4239        assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
4240        assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
4241        assert_eq!(t.hold_source, Some(HoldSource::Machine));
4242        assert!(t.blocked_from.is_none(), "consumed once restored");
4243    }
4244
4245    #[test]
4246    fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
4247        let mut t = task("manually held, then asked about");
4248        t.hold_manual(Some("waiting on a dependency".to_owned()));
4249
4250        t.block(vec!["q1".to_owned()], None);
4251        t.unblock("q1");
4252
4253        assert_eq!(t.status, TaskStatus::Held);
4254        assert_eq!(t.hold_source, Some(HoldSource::Manual));
4255    }
4256
4257    #[test]
4258    fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
4259        // A second `Task::block` call - `crate::conduct` adding a question on
4260        // top of an existing block - must not overwrite `blocked_from` with
4261        // `Blocked` itself, or the task would restore into itself.
4262        let mut t = task("held, blocked twice");
4263        t.hold_machine(None);
4264        t.block(vec!["q1".to_owned()], Some("first".to_owned()));
4265        t.block(
4266            vec!["q1".to_owned(), "q2".to_owned()],
4267            Some("second".to_owned()),
4268        );
4269
4270        t.unblock("q1");
4271        assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
4272        t.unblock("q2");
4273        assert_eq!(t.status, TaskStatus::Held);
4274    }
4275
4276    #[test]
4277    fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
4278        // Whatever process was driving the run is gone by the time a
4279        // conductor's question about it gets answered - there is nothing left
4280        // to resume into.
4281        let mut t = task("blocked mid-run");
4282        t.start("run-1".to_owned());
4283        assert_eq!(t.status, TaskStatus::Running);
4284
4285        t.block(vec!["q1".to_owned()], None);
4286        t.unblock("q1");
4287        assert_eq!(t.status, TaskStatus::Queued);
4288    }
4289
4290    #[test]
4291    fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
4292        // `blocked_from` is `None` for a record written before schema 4 (or,
4293        // equivalently, deserialized straight from an on-disk file that never
4294        // had the field). Held evidence surviving on the task - never cleared
4295        // by `block` - is the only way left to tell such a record apart from
4296        // one blocked straight out of `Queued`.
4297        let mut t = task("legacy record, held before it was blocked");
4298        t.hold_source = Some(HoldSource::Machine);
4299        t.hold_reason = Some("legacy hold reason".to_owned());
4300        t.status = TaskStatus::Blocked;
4301        t.blocked_by = vec!["q1".to_owned()];
4302        t.blocked_from = None;
4303
4304        t.unblock("q1");
4305        assert_eq!(t.status, TaskStatus::Held);
4306    }
4307
4308    #[test]
4309    fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
4310        let mut t = task("legacy record, ordinary dependency block");
4311        t.status = TaskStatus::Blocked;
4312        t.blocked_by = vec!["dep".to_owned()];
4313        t.blocked_from = None;
4314
4315        t.unblock("dep");
4316        assert_eq!(t.status, TaskStatus::Queued);
4317    }
4318
4319    #[test]
4320    fn answering_a_question_is_recorded_and_survives_a_release() {
4321        let mut t = task("asked something");
4322        t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
4323        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
4324        t.unblock("q1");
4325        assert_eq!(t.status, TaskStatus::Queued);
4326        assert_eq!(t.answers.len(), 1);
4327        assert_eq!(t.answers[0].answer, "SQLite");
4328
4329        // A release resets attempts, not evidence - the same rule
4330        // `releasing_a_held_task_gives_it_a_real_second_chance` asserts for
4331        // `runs`.
4332        t.release();
4333        assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
4334    }
4335
4336    #[test]
4337    fn resumption_review_branch_requires_unchanged_authored_criteria() {
4338        for source in ["cli", "web"] {
4339            let mut t = task("review old candidate");
4340            t.request_review("candidate-A".into());
4341            t.hold_for_handover(Some("candidate-A".into()), "handover refused".into());
4342            let mut unchanged = t.clone();
4343            unchanged.release_operator(source, None);
4344            assert_eq!(unchanged.review_branch.as_deref(), Some("candidate-A"));
4345            t.edit(
4346                "review approved head".into(),
4347                "Reuse measured PR head B; fix Clippy; review".into(),
4348            )
4349            .unwrap();
4350            t.release_operator(source, None);
4351            assert!(t.review_branch.is_none());
4352            assert!(t.review_instruction.is_none());
4353            // Legacy records capture their old criteria at the supported edit.
4354            let mut legacy = unchanged;
4355            legacy.review_instruction = None;
4356            legacy.hold_for_handover(None, "still checked out".into());
4357            legacy
4358                .edit("new plan".into(), "Reuse measured PR head B".into())
4359                .unwrap();
4360            legacy.release_operator(source, None);
4361            assert!(legacy.review_branch.is_none());
4362        }
4363    }
4364
4365    #[test]
4366    fn a_refused_handover_keeps_the_review_branch_across_release() {
4367        let mut t = task("refused takeover");
4368        t.start("run-1".to_owned());
4369        t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
4370        assert_eq!(t.status, TaskStatus::Held);
4371        assert_eq!(t.attempts, 1);
4372        t.release();
4373        assert_eq!(t.status, TaskStatus::Queued);
4374        assert_eq!(t.attempts, 0);
4375        assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
4376
4377        // A fresh competition chosen afterwards is not a review.
4378        t.hold_for_handover(None, "again".to_owned());
4379        t.requeue();
4380        assert!(t.review_branch.is_none());
4381
4382        // A manual hold never keeps one.
4383        let mut m = task("manual");
4384        m.review_branch = Some("magi/x/A".to_owned());
4385        m.hold_manual(None);
4386        m.release();
4387        assert!(m.review_branch.is_none());
4388    }
4389
4390    #[test]
4391    fn requesting_review_requeues_the_task_and_remembers_the_branch() {
4392        let mut t = task("blocked run with a surviving branch");
4393        t.start("run-1".to_owned());
4394        t.fail("blocked with major findings", 5);
4395        assert_eq!(t.status, TaskStatus::Failed);
4396
4397        t.request_review("magi/eba2/A".to_owned());
4398        assert_eq!(t.status, TaskStatus::Queued);
4399        assert_eq!(t.attempts, 0);
4400        assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
4401
4402        // An ordinary release (a human overriding the choice) drops it again.
4403        t.release();
4404        assert!(t.review_branch.is_none());
4405    }
4406
4407    #[test]
4408    fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
4409        let mut t = task("retry");
4410        t.start("run-1".to_owned());
4411        t.requeue();
4412        assert!(t.fresh_start);
4413
4414        t.release();
4415        assert!(!t.fresh_start);
4416    }
4417
4418    #[test]
4419    fn urgent_only_changes_queued_or_held_and_preserves_settings() {
4420        for status in [
4421            TaskStatus::Queued,
4422            TaskStatus::Held,
4423            TaskStatus::Running,
4424            TaskStatus::Done,
4425            TaskStatus::Failed,
4426            TaskStatus::Blocked,
4427            TaskStatus::Parked,
4428        ] {
4429            let mut t = task("urgent settings");
4430            t.status = status;
4431            t.attempts = 2;
4432            t.hold_reason = Some("waiting".into());
4433            t.priority = 7;
4434            t.interrupt = true;
4435            let before = serde_json::to_value(&t).unwrap();
4436            for urgent in [true, true, false, false] {
4437                let result = t.set_urgent(urgent);
4438                let mut expected = before.clone();
4439                if matches!(status, TaskStatus::Queued | TaskStatus::Held) {
4440                    result.unwrap();
4441                    expected["urgent"] = urgent.into();
4442                } else {
4443                    let error = result.unwrap_err().to_string();
4444                    assert!(error.contains(status.as_str()), "{error}");
4445                    assert!(error.contains("queued or held"), "{error}");
4446                }
4447                assert_eq!(serde_json::to_value(&t).unwrap(), expected);
4448            }
4449        }
4450    }
4451
4452    #[test]
4453    fn priority_can_be_changed_while_queued_but_not_while_running() {
4454        let mut t = task("reprioritise me");
4455        t.set_priority(5).unwrap();
4456        assert_eq!(t.priority, 5);
4457
4458        t.start("run-1".to_owned());
4459        let err = t.set_priority(9).unwrap_err().to_string();
4460        assert!(err.contains("running"), "{err}");
4461        assert_eq!(t.priority, 5, "the rejected write must not partially apply");
4462    }
4463
4464    #[test]
4465    fn interrupt_can_be_marked_while_queued_but_not_while_running() {
4466        let mut t = task("interrupt me");
4467        assert!(!t.interrupt, "off unless asked, same as any other task");
4468
4469        t.set_interrupt(true).unwrap();
4470        assert!(t.interrupt);
4471
4472        t.start("run-1".to_owned());
4473        assert!(
4474            !t.interrupt,
4475            "the mark is one-shot: dispatching the task fulfils it, \
4476             whatever the run that follows ends up doing"
4477        );
4478        let err = t.set_interrupt(true).unwrap_err().to_string();
4479        assert!(err.contains("running"), "{err}");
4480        // Clearing is always allowed, even on a running task - there is
4481        // nothing left for it to interrupt once it has been claimed.
4482        t.set_interrupt(false).unwrap();
4483        assert!(!t.interrupt);
4484    }
4485
4486    /// R2-1-1: a task whose run fails and requeues must not go on
4487    /// re-triggering `crate::daemon`'s interrupt scheduler on every later
4488    /// boundary, attempt after attempt, until it exhausts its budget.
4489    #[test]
4490    fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
4491        let mut t = task("interrupt me");
4492        t.set_interrupt(true).unwrap();
4493        t.start("run-1".to_owned());
4494        t.fail("mock failure", 5);
4495        assert_eq!(t.status, TaskStatus::Failed);
4496        assert!(
4497            !t.interrupt,
4498            "one attempt already spent the mark; a retry is an ordinary \
4499             requeue, not a fresh interrupt request"
4500        );
4501    }
4502
4503    #[test]
4504    fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
4505        let (_dir, q) = queue();
4506        let mut a = task("first filed");
4507        let mut b = task("second filed");
4508        a.id = "20260101-000001-aaaa".to_owned();
4509        b.id = "20260101-000002-bbbb".to_owned();
4510        q.put(&mut a).unwrap();
4511        q.put(&mut b).unwrap();
4512
4513        assert_eq!(
4514            q.next_runnable().unwrap().id,
4515            a.id,
4516            "with equal priority the older task goes first, so a burst of \
4517             new work cannot starve it"
4518        );
4519        assert_eq!(
4520            q.list()[0].id,
4521            b.id,
4522            "but the list an operator reads is newest first, the same as \
4523             before priority existed - a's turn to run does not make it the \
4524             newest task"
4525        );
4526
4527        let mut a = q.get(&a.id).unwrap();
4528        a.set_priority(10).unwrap();
4529        q.put(&mut a).unwrap();
4530
4531        assert_eq!(
4532            q.next_runnable().unwrap().id,
4533            a.id,
4534            "a raised priority must be reflected the moment it is saved"
4535        );
4536        // `magi task list` and `GET /api/queue` both print `Queue::list()`
4537        // directly, so the raised task has to lead there too - not only in
4538        // what the loop would claim next.
4539        assert_eq!(
4540            q.list()[0].id,
4541            a.id,
4542            "the raised task must sort first in the list an operator reads, \
4543             not only in next_runnable's own ordering"
4544        );
4545    }
4546
4547    #[test]
4548    fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
4549        let mut t = Task::new(
4550            "old title".to_owned(),
4551            "old instruction".to_owned(),
4552            PathBuf::from("/repo"),
4553            Source::Agent {
4554                run: "20260101-000000-beef".to_owned(),
4555                node: "implement".to_owned(),
4556            },
4557        );
4558        let id = t.id.clone();
4559        let created_at = t.created_at;
4560        t.runs.push("20260101-000000-beef".to_owned());
4561
4562        t.edit("new title".to_owned(), "new instruction".to_owned())
4563            .unwrap();
4564
4565        assert_eq!(t.title, "new title");
4566        assert_eq!(t.instruction, "new instruction");
4567        assert_eq!(t.id, id, "editing must not mint a new id");
4568        assert_eq!(t.created_at, created_at);
4569        assert_eq!(
4570            t.source,
4571            Source::Agent {
4572                run: "20260101-000000-beef".to_owned(),
4573                node: "implement".to_owned(),
4574            },
4575            "editing must not turn agent attribution into human"
4576        );
4577        assert_eq!(t.runs, ["20260101-000000-beef"]);
4578    }
4579
4580    #[test]
4581    fn editing_is_refused_once_a_task_is_running_or_finished() {
4582        let mut running = task("in flight");
4583        running.start("run-1".to_owned());
4584        let err = running
4585            .edit("x".to_owned(), "y".to_owned())
4586            .unwrap_err()
4587            .to_string();
4588        assert!(err.contains("running"), "{err}");
4589
4590        let mut done = task("finished");
4591        done.succeed();
4592        let err = done
4593            .edit("x".to_owned(), "y".to_owned())
4594            .unwrap_err()
4595            .to_string();
4596        assert!(err.contains("done"), "{err}");
4597
4598        // Both queued and held are the point of the feature and must work.
4599        let mut queued = task("waiting");
4600        queued.edit("x".to_owned(), "y".to_owned()).unwrap();
4601        let mut held = task("parked");
4602        held.hold_machine(None);
4603        held.edit("x".to_owned(), "y".to_owned()).unwrap();
4604    }
4605
4606    #[test]
4607    fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
4608        let (_dir, q) = queue();
4609        let path = q.path_of("20260101-000000-aaaa");
4610        std::fs::create_dir_all(q.root()).unwrap();
4611        std::fs::write(
4612            &path,
4613            serde_json::json!({
4614                "schema": SCHEMA,
4615                "id": "20260101-000000-aaaa",
4616                "title": "from before hold reasons existed",
4617                "instruction": "from before hold reasons existed",
4618                "repo": ".",
4619                "source": { "kind": "human" },
4620                "status": "held",
4621                "created_at": Timestamp::now().to_string(),
4622                "updated_at": Timestamp::now().to_string(),
4623            })
4624            .to_string(),
4625        )
4626        .unwrap();
4627
4628        let task = q.get("20260101-000000-aaaa").expect("must still read");
4629        assert!(task.hold_reason.is_none());
4630        assert!(task.operator_held());
4631    }
4632
4633    #[test]
4634    fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
4635        let (_dir, q) = queue();
4636        let path = q.path_of("20260101-000000-bbbb");
4637        std::fs::create_dir_all(q.root()).unwrap();
4638        std::fs::write(
4639            &path,
4640            serde_json::json!({
4641                "schema": 2,
4642                "id": "20260101-000000-bbbb",
4643                "title": "old manual recovery",
4644                "instruction": "old manual recovery",
4645                "repo": ".",
4646                "source": { "kind": "human" },
4647                "status": "held",
4648                "hold_reason": "active manual recovery run20260912-224242-daf5",
4649                "created_at": Timestamp::now().to_string(),
4650                "updated_at": Timestamp::now().to_string(),
4651            })
4652            .to_string(),
4653        )
4654        .unwrap();
4655
4656        let task = q.get("20260101-000000-bbbb").expect("must still read");
4657        assert_eq!(task.hold_source, None);
4658        assert!(task.operator_held());
4659    }
4660
4661    #[test]
4662    fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
4663        let (_dir, q) = queue();
4664        let path = q.path_of("20260101-000000-aaaa");
4665        std::fs::create_dir_all(q.root()).unwrap();
4666        std::fs::write(
4667            &path,
4668            serde_json::json!({
4669                "schema": SCHEMA,
4670                "id": "20260101-000000-aaaa",
4671                "title": "from before diagnostics existed",
4672                "instruction": "from before diagnostics existed",
4673                "repo": ".",
4674                "source": { "kind": "human" },
4675                "status": "held",
4676                "created_at": Timestamp::now().to_string(),
4677                "updated_at": Timestamp::now().to_string(),
4678            })
4679            .to_string(),
4680        )
4681        .unwrap();
4682
4683        let task = q.get("20260101-000000-aaaa").expect("must still read");
4684        assert!(task.diagnostic.is_none());
4685    }
4686
4687    #[test]
4688    fn a_schema_1_task_with_no_blocking_fields_still_reads() {
4689        // Written by a build that predates `blocked_by`, `block_reason`,
4690        // `answers` and `review_branch` entirely - literal `"schema": 1`,
4691        // not `SCHEMA`, since the whole point is a build older than this one.
4692        let (_dir, q) = queue();
4693        let path = q.path_of("20260101-000000-aaaa");
4694        std::fs::create_dir_all(q.root()).unwrap();
4695        std::fs::write(
4696            &path,
4697            serde_json::json!({
4698                "schema": 1,
4699                "id": "20260101-000000-aaaa",
4700                "title": "from before blocking existed",
4701                "instruction": "from before blocking existed",
4702                "repo": ".",
4703                "source": { "kind": "human" },
4704                "status": "queued",
4705                "created_at": Timestamp::now().to_string(),
4706                "updated_at": Timestamp::now().to_string(),
4707            })
4708            .to_string(),
4709        )
4710        .unwrap();
4711
4712        let task = q.get("20260101-000000-aaaa").expect("must still read");
4713        assert!(task.blocked_by.is_empty());
4714        assert!(task.block_reason.is_none());
4715        assert!(task.answers.is_empty());
4716        assert!(task.review_branch.is_none());
4717    }
4718
4719    #[test]
4720    fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
4721        // A diagnostic belongs to the run that produced it. Left in place
4722        // across a release, an unrelated later failure - a config error, say -
4723        // would go on showing evidence for a problem that is no longer why the
4724        // task is stuck.
4725        let mut held = task("diagnosed");
4726        held.start("run-1".to_owned());
4727        held.fail("gate red", 1);
4728        held.diagnostic = Some("cargo test failed: ...".to_owned());
4729        assert_eq!(held.status, TaskStatus::Held);
4730
4731        held.release();
4732        assert!(held.diagnostic.is_none());
4733
4734        held.diagnostic = Some("cargo test failed: ...".to_owned());
4735        held.succeed();
4736        assert!(held.diagnostic.is_none());
4737    }
4738
4739    #[test]
4740    fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
4741        let mut t = task("retried");
4742        t.start("run-1".to_owned());
4743        t.diagnostic = Some("stale evidence from a previous hold".to_owned());
4744        t.fail("unrelated config error", 5);
4745        assert_eq!(t.status, TaskStatus::Failed);
4746        assert!(
4747            t.diagnostic.is_none(),
4748            "fail() must not let an old diagnostic outlive the run that produced it"
4749        );
4750    }
4751
4752    #[test]
4753    fn a_claim_is_exclusive_and_releases_on_drop() {
4754        let (_dir, q) = queue();
4755        let mut t = task("contended");
4756        q.put(&mut t).unwrap();
4757
4758        let held = q.claim(&t.id).unwrap();
4759        assert!(
4760            q.claim(&t.id).is_err(),
4761            "two daemons must not drive one task into two runs"
4762        );
4763        drop(held);
4764        assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
4765    }
4766
4767    #[test]
4768    fn a_round_trip_survives_disk() {
4769        let (_dir, q) = queue();
4770        let mut t = Task::new(
4771            "titled".to_owned(),
4772            "body".to_owned(),
4773            PathBuf::from("/repo"),
4774            Source::Agent {
4775                run: "20260101-000000-beef".to_owned(),
4776                node: "implement".to_owned(),
4777            },
4778        );
4779        t.priority = 3;
4780        q.put(&mut t).unwrap();
4781
4782        let back = q.get(&t.id).unwrap();
4783        assert_eq!(back.id, t.id);
4784        assert_eq!(back.priority, 3);
4785        assert_eq!(back.source.label(), "implement@beef");
4786        // A prefix is enough, the way run ids work everywhere else.
4787        assert_eq!(q.get(t.short()).unwrap().id, t.id);
4788    }
4789
4790    #[test]
4791    fn an_unreadable_task_does_not_take_the_queue_down() {
4792        let (_dir, q) = queue();
4793        let mut t = task("fine");
4794        q.put(&mut t).unwrap();
4795        std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
4796
4797        let listed = q.list();
4798        assert_eq!(listed.len(), 1, "the readable task still lists");
4799        assert_eq!(listed[0].id, t.id);
4800    }
4801
4802    #[test]
4803    fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
4804        let (_dir, q) = queue();
4805        let path = q.path_of("20260101-000000-aaaa");
4806        std::fs::create_dir_all(q.root()).unwrap();
4807        std::fs::write(
4808            &path,
4809            serde_json::json!({
4810                "schema": SCHEMA,
4811                "id": "20260101-000000-aaaa",
4812                "title": "from before solo existed",
4813                "instruction": "from before solo existed",
4814                "repo": ".",
4815                "source": { "kind": "human" },
4816                "status": "queued",
4817                "created_at": Timestamp::now().to_string(),
4818                "updated_at": Timestamp::now().to_string(),
4819            })
4820            .to_string(),
4821        )
4822        .unwrap();
4823
4824        let task = q.get("20260101-000000-aaaa").expect("must still read");
4825        assert!(!task.solo, "a queue file with no `solo` field means false");
4826    }
4827
4828    #[test]
4829    fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
4830        let (_dir, q) = queue();
4831        let path = q.path_of("20260101-000000-bbbb");
4832        std::fs::create_dir_all(q.root()).unwrap();
4833        std::fs::write(
4834            &path,
4835            serde_json::json!({
4836                "schema": SCHEMA,
4837                "id": "20260101-000000-bbbb",
4838                "title": "from before urgent existed",
4839                "instruction": "from before urgent existed",
4840                "repo": ".",
4841                "source": { "kind": "human" },
4842                "status": "queued",
4843                "created_at": Timestamp::now().to_string(),
4844                "updated_at": Timestamp::now().to_string(),
4845            })
4846            .to_string(),
4847        )
4848        .unwrap();
4849
4850        let task = q.get("20260101-000000-bbbb").expect("must still read");
4851        assert!(
4852            !task.urgent,
4853            "a queue file with no `urgent` field means false, same as `solo`"
4854        );
4855    }
4856
4857    #[test]
4858    fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
4859        let (_dir, q) = queue();
4860        let mut t = task("from the future");
4861        q.put(&mut t).unwrap();
4862        let path = q.path_of(&t.id);
4863        let body = std::fs::read_to_string(&path)
4864            .unwrap()
4865            .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
4866        std::fs::write(&path, body).unwrap();
4867
4868        let err = q.get(&t.id).unwrap_err().to_string();
4869        assert!(err.contains("schema 99"), "{err}");
4870    }
4871
4872    #[test]
4873    fn revision_moves_when_the_queue_changes() {
4874        let (_dir, q) = queue();
4875        assert_eq!(q.revision(), 0, "an empty queue has no revision");
4876        let mut t = task("first");
4877        q.put(&mut t).unwrap();
4878        assert!(q.revision() > 0, "a written task moves the revision");
4879    }
4880
4881    #[test]
4882    fn revision_moves_when_deleting_an_older_task() {
4883        let (dir, q) = queue();
4884        let questions = Questions::at(dir.path().join("questions"));
4885        let mut t1 = task("older");
4886        q.put(&mut t1).unwrap();
4887        // Ensure mtime ticks forward.
4888        std::thread::sleep(std::time::Duration::from_millis(10));
4889        let mut t2 = task("newer");
4890        q.put(&mut t2).unwrap();
4891
4892        let rev_before = q.revision();
4893        q.remove(&t1.id, false, &questions).unwrap();
4894        let rev_after = q.revision();
4895
4896        assert_ne!(
4897            rev_before, rev_after,
4898            "deleting an older task must change the revision so other clients see the deletion"
4899        );
4900    }
4901
4902    #[test]
4903    fn removing_a_task_takes_it_out_of_the_listing() {
4904        let (dir, q) = queue();
4905        let questions = Questions::at(dir.path().join("questions"));
4906        let mut t = task("delete me");
4907        q.put(&mut t).unwrap();
4908        let removed = q.remove(t.short(), false, &questions).unwrap();
4909        assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
4910        assert!(removed.released.is_empty() && removed.still_blocked.is_empty());
4911        assert!(q.list().is_empty());
4912        assert!(
4913            q.remove(&t.id, false, &questions).is_err(),
4914            "removing twice is an error"
4915        );
4916    }
4917
4918    #[test]
4919    fn removing_a_task_takes_its_stale_lock_with_it() {
4920        let (dir, q) = queue();
4921        let questions = Questions::at(dir.path().join("questions"));
4922        let mut t = task("interrupted");
4923        q.put(&mut t).unwrap();
4924
4925        // A daemon killed mid-run leaves this behind. Nothing holds it: the
4926        // process that would have dropped the guard is gone.
4927        let claim = q.claim(&t.id).unwrap();
4928        std::mem::forget(claim);
4929        assert!(
4930            q.claim(&t.id).is_err(),
4931            "the orphaned lock is what makes the task look claimed"
4932        );
4933
4934        // A live daemon on this task is refused, whatever the lock says.
4935        let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
4936        assert!(err.contains("live daemon"), "{err}");
4937        assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
4938
4939        // With no daemon behind it, the lock is stale and goes with the task.
4940        q.remove(&t.id, false, &questions).unwrap();
4941        assert!(q.list().is_empty());
4942        let mut again = task("interrupted");
4943        again.id = t.id.clone();
4944        q.put(&mut again).unwrap();
4945        assert!(
4946            q.claim(&t.id).is_ok(),
4947            "a task that comes back must be claimable, which a left-behind lock would prevent"
4948        );
4949    }
4950
4951    fn notices_of(dir: &Path) -> Vec<crate::notices::Notice> {
4952        crate::notices::Notices::at(dir.join("notifications")).list()
4953    }
4954
4955    #[test]
4956    fn removing_a_sole_dependency_releases_the_dependent_without_a_hold() {
4957        let (dir, q) = queue();
4958        let questions = Questions::at(dir.path().join("questions"));
4959        let mut dep = task("dependency");
4960        q.put(&mut dep).unwrap();
4961        let mut blocked = task("waiting");
4962        blocked.block(vec![dep.id.clone()], Some("waits".to_owned()));
4963        q.put(&mut blocked).unwrap();
4964
4965        let removed = q.remove(&dep.id, false, &questions).unwrap();
4966        assert_eq!(removed.released, [blocked.id.clone()]);
4967        assert!(removed.still_blocked.is_empty());
4968
4969        let after = q.get(&blocked.id).unwrap();
4970        assert_eq!(after.status, TaskStatus::Queued);
4971        assert!(after.blocked_by.is_empty());
4972        assert!(after.block_reason.is_none());
4973        let notes = notices_of(dir.path());
4974        assert_eq!(notes.len(), 1, "{notes:?}");
4975        assert_eq!(notes[0].severity, crate::notices::Severity::Info);
4976        assert_eq!(
4977            notes[0].link,
4978            Some(crate::notices::Link::Task {
4979                id: blocked.id.clone(),
4980            })
4981        );
4982    }
4983
4984    #[test]
4985    fn removing_one_of_two_dependencies_keeps_the_other() {
4986        let (dir, q) = queue();
4987        let questions = Questions::at(dir.path().join("questions"));
4988        let mut dep = task("dependency");
4989        q.put(&mut dep).unwrap();
4990        let mut other = task("other");
4991        q.put(&mut other).unwrap();
4992        let mut blocked = task("waiting");
4993        blocked.block(
4994            vec![dep.id.clone(), other.id.clone()],
4995            Some("waits on both".to_owned()),
4996        );
4997        q.put(&mut blocked).unwrap();
4998
4999        let removed = q.remove(&dep.id, false, &questions).unwrap();
5000        assert!(removed.released.is_empty());
5001        assert_eq!(removed.still_blocked, [blocked.id.clone()]);
5002
5003        let after = q.get(&blocked.id).unwrap();
5004        assert_eq!(after.status, TaskStatus::Blocked);
5005        assert_eq!(after.blocked_by, [other.id.clone()]);
5006        // The tombstone stays while a task still names the id... only this
5007        // one's dependency is gone, so nothing references it and it may go.
5008        assert!(missing_blockers(&q, &questions, &after.blocked_by).is_empty());
5009    }
5010
5011    #[test]
5012    fn a_dependency_deleted_before_its_dependents_were_rewritten_is_released_later() {
5013        let (dir, q) = queue();
5014        let questions = Questions::at(dir.path().join("questions"));
5015        let mut dep = task("dependency");
5016        q.put(&mut dep).unwrap();
5017        let mut blocked = task("waiting");
5018        blocked.block(vec![dep.id.clone()], None);
5019        q.put(&mut blocked).unwrap();
5020
5021        // The dependent is claimed elsewhere, so `remove` has to skip it.
5022        let claim = q.claim(&blocked.id).unwrap();
5023        let removed = q.remove(&dep.id, false, &questions).unwrap();
5024        assert!(removed.released.is_empty());
5025        drop(claim);
5026
5027        let mut task = q.get(&blocked.id).unwrap();
5028        assert_eq!(task.status, TaskStatus::Blocked);
5029        assert!(missing_blockers(&q, &questions, &task.blocked_by).is_empty());
5030        assert_eq!(
5031            q.apply_deleted_blockers(&mut task, &questions),
5032            [dep.id.clone()]
5033        );
5034        assert_eq!(task.status, TaskStatus::Queued);
5035    }
5036
5037    #[test]
5038    fn dependency_resolution_short_deleted_reference_releases_only_its_waiters() {
5039        let (dir, q) = queue();
5040        let questions = Questions::at(dir.path().join("questions"));
5041        let mut dep = task("dependency");
5042        dep.id = "20261013-193611-ca5b".into();
5043        q.put(&mut dep).unwrap();
5044        let mut waiting = task("waiting");
5045        waiting.block(vec!["ca5b".into()], None);
5046        q.put(&mut waiting).unwrap();
5047        q.remove(&dep.id, false, &questions).unwrap();
5048        assert_eq!(q.get(&waiting.id).unwrap().status, TaskStatus::Queued);
5049        assert_eq!(
5050            dependency(&q, &questions, "ca5b"),
5051            Dependency::Deleted(dep.id)
5052        );
5053    }
5054
5055    #[test]
5056    fn dependency_resolution_preserves_cross_store_ambiguity_and_exact_ids() {
5057        let (dir, queue) = queue();
5058        let questions = Questions::at(dir.path().join("questions"));
5059        let mut dep = task("dependency");
5060        dep.id = "20261013-193611-ca5b".into();
5061        queue.put(&mut dep).unwrap();
5062        std::fs::create_dir_all(questions.root()).unwrap();
5063        let question_id = "20261014-193611-ca5b";
5064        std::fs::write(questions.path_of(question_id), "unreadable question").unwrap();
5065        assert_eq!(questions.resolve_id("ca5b").unwrap(), question_id);
5066        assert_eq!(
5067            dependency(&queue, &questions, "ca5b"),
5068            Dependency::Ambiguous
5069        );
5070        assert_eq!(
5071            dependency(&queue, &questions, &dep.id),
5072            Dependency::Task(dep.id)
5073        );
5074        assert_eq!(
5075            dependency(&queue, &questions, question_id),
5076            Dependency::Question(question_id.into())
5077        );
5078        assert_eq!(
5079            dependency(&queue, &questions, "missing"),
5080            Dependency::Missing
5081        );
5082    }
5083
5084    #[test]
5085    fn dependency_resolution_directory_errors_are_unknown_not_missing() {
5086        let dir = tempfile::tempdir().unwrap();
5087        let q = Queue::at(dir.path().join("not-a-directory"));
5088        std::fs::write(q.root(), "file").unwrap();
5089        let questions = Questions::at(dir.path().join("questions"));
5090        assert_eq!(dependency(&q, &questions, "ca5b"), Dependency::Unknown);
5091        assert!(missing_blockers(&q, &questions, &["ca5b".into()]).is_empty());
5092    }
5093
5094    #[test]
5095    fn a_dependency_without_a_tombstone_is_still_missing() {
5096        let (dir, q) = queue();
5097        let questions = Questions::at(dir.path().join("questions"));
5098        let mut dep = task("dependency");
5099        q.put(&mut dep).unwrap();
5100        std::fs::remove_file(q.path_of(&dep.id)).unwrap();
5101        assert_eq!(
5102            missing_blockers(&q, &questions, std::slice::from_ref(&dep.id)),
5103            [dep.id.clone()]
5104        );
5105        assert!(deleted_blockers(&q, &questions, std::slice::from_ref(&dep.id)).is_empty());
5106    }
5107
5108    #[test]
5109    fn a_held_dependent_is_left_alone_by_a_dependency_deletion() {
5110        let mut t = task("held");
5111        t.hold_machine(Some("because".to_owned()));
5112        t.blocked_by = vec!["gone".to_owned()];
5113        assert!(!t.dependency_deleted("gone"));
5114        assert_eq!(t.status, TaskStatus::Held);
5115    }
5116
5117    #[test]
5118    fn a_failed_record_removal_takes_the_tombstone_back() {
5119        let (_dir, q) = queue();
5120        let mut t = task("stays");
5121        q.put(&mut t).unwrap();
5122        q.write_tombstone(&t.id).unwrap();
5123        let err = q.remove_record_with_attachments(&t.id, |_| Err(std::io::Error::other("nope")));
5124        assert!(err.is_err());
5125        // `remove` is what withdraws the marker; a live record is never
5126        // treated as deleted whatever is on disk.
5127        assert!(!q.was_deleted(&t.id));
5128    }
5129
5130    fn source_file(dir: &Path, name: &str, body: &str) -> PathBuf {
5131        let p = dir.join(name);
5132        std::fs::write(&p, body).unwrap();
5133        p
5134    }
5135
5136    #[test]
5137    fn an_attachment_copy_survives_deleting_its_source() {
5138        let (dir, q) = queue();
5139        let src = source_file(dir.path(), "shot.png", "pixels");
5140        let mut t = task("with a picture");
5141        let names = q.attach(&mut t, std::slice::from_ref(&src)).unwrap();
5142        q.put(&mut t).unwrap();
5143        std::fs::remove_file(&src).unwrap();
5144        assert_eq!(names, ["shot.png"]);
5145        let loaded = q.get(&t.id).unwrap();
5146        let paths = q.attachment_paths(&loaded);
5147        assert_eq!(paths.len(), 1);
5148        assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "pixels");
5149    }
5150
5151    #[test]
5152    fn attachment_names_that_could_traverse_or_are_odd_are_refused() {
5153        let (dir, q) = queue();
5154        let mut t = task("bad names");
5155        for name in ["a..b.png", ".hidden", "with space.png", "-x.png"] {
5156            let src = source_file(dir.path(), name, "x");
5157            assert!(
5158                q.attach(&mut t, &[src]).is_err(),
5159                "`{name}` must be refused"
5160            );
5161        }
5162        // A drive-qualified name cannot exist as a file on Windows (joining it
5163        // to a directory yields a drive-relative path to `foo.png` instead),
5164        // so the rule is asserted on the name itself, everywhere.
5165        assert!(!crate::ask::valid_asset_name("C:foo.png"));
5166        #[cfg(not(windows))]
5167        {
5168            let src = source_file(dir.path(), "C:foo.png", "x");
5169            assert!(q.attach(&mut t, &[src]).is_err());
5170        }
5171        let long = format!("{}.png", "a".repeat(70));
5172        let src = source_file(dir.path(), &long, "x");
5173        assert!(q.attach(&mut t, &[src]).is_err());
5174        assert!(t.attachments.is_empty());
5175        assert!(!q.attachments_dir(&t.id).exists());
5176    }
5177
5178    #[test]
5179    fn a_taken_attachment_name_is_numbered_not_overwritten() {
5180        let (dir, q) = queue();
5181        let a = source_file(dir.path(), "shot.png", "one");
5182        let sub = dir.path().join("other");
5183        std::fs::create_dir_all(&sub).unwrap();
5184        let b = source_file(&sub, "shot.png", "two");
5185        let mut t = task("collision");
5186        q.attach(&mut t, &[a]).unwrap();
5187        q.attach(&mut t, &[b]).unwrap();
5188        assert_eq!(t.attachments, ["shot.png", "shot-2.png"]);
5189        let paths = q.attachment_paths(&t);
5190        assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "one");
5191        assert_eq!(std::fs::read_to_string(&paths[1]).unwrap(), "two");
5192    }
5193
5194    #[test]
5195    fn a_renumbered_name_stays_inside_the_length_bound() {
5196        let (dir, q) = queue();
5197        let name = format!("{}.png", "a".repeat(60));
5198        assert_eq!(name.len(), 64);
5199        let a = source_file(dir.path(), &name, "one");
5200        let sub = dir.path().join("other");
5201        std::fs::create_dir_all(&sub).unwrap();
5202        let b = source_file(&sub, &name, "two");
5203        let mut t = task("long");
5204        q.attach(&mut t, &[a, b]).unwrap();
5205        assert_eq!(t.attachments.len(), 2);
5206        assert!(
5207            t.attachments
5208                .iter()
5209                .all(|n| crate::ask::valid_asset_name(n))
5210        );
5211        assert!(t.attachments[1].ends_with("-2.png"));
5212    }
5213
5214    #[test]
5215    fn a_failed_attach_keeps_existing_attachments_and_leaves_no_partial_copy() {
5216        let (dir, q) = queue();
5217        let good = source_file(dir.path(), "good.png", "ok");
5218        let mut t = task("partial");
5219        q.attach(&mut t, &[good]).unwrap();
5220        let more = source_file(dir.path(), "more.png", "ok");
5221        let missing = dir.path().join("missing.png");
5222        assert!(q.attach(&mut t, &[more, missing]).is_err());
5223        assert_eq!(t.attachments, ["good.png"]);
5224        let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
5225            .unwrap()
5226            .flatten()
5227            .collect();
5228        assert_eq!(on_disk.len(), 1);
5229    }
5230
5231    /// Make the next `put` of `t` fail: its temp file's path is a directory.
5232    fn block_put(q: &Queue, t: &Task) -> PathBuf {
5233        let tmp = q.path_of(&t.id).with_extension("json.tmp");
5234        std::fs::create_dir_all(&tmp).unwrap();
5235        tmp
5236    }
5237
5238    #[test]
5239    fn a_failed_put_leaves_no_new_attachment_directory() {
5240        let (dir, q) = queue();
5241        let mut t = task("fresh");
5242        let tmp = block_put(&q, &t);
5243        let src = source_file(dir.path(), "shot.png", "x");
5244        assert!(q.attach_and_put(&mut t, &[src]).is_err());
5245        assert!(t.attachments.is_empty());
5246        assert!(!q.attachments_dir(&t.id).exists());
5247        assert!(!q.path_of(&t.id).exists());
5248        std::fs::remove_dir(tmp).unwrap();
5249    }
5250
5251    #[test]
5252    fn a_failed_put_removes_only_the_copy_it_just_made() {
5253        let (dir, q) = queue();
5254        let mut t = task("edited");
5255        let first = source_file(dir.path(), "first.png", "1");
5256        q.attach_and_put(&mut t, &[first]).unwrap();
5257        block_put(&q, &t);
5258        let second = source_file(dir.path(), "second.png", "2");
5259        assert!(q.attach_and_put(&mut t, &[second]).is_err());
5260        assert_eq!(t.attachments, ["first.png"]);
5261        let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
5262            .unwrap()
5263            .flatten()
5264            .map(|e| e.file_name().to_string_lossy().into_owned())
5265            .collect();
5266        assert_eq!(on_disk, ["first.png"]);
5267        assert_eq!(q.get(&t.id).unwrap().attachments, ["first.png"]);
5268    }
5269
5270    #[test]
5271    fn a_leftover_removing_directory_is_swept_by_the_next_removal() {
5272        let (dir, q) = queue();
5273        let questions = Questions::at(dir.path().join("questions"));
5274        let gone = task("gone");
5275        let mut other = task("other");
5276        let mut live = task("live");
5277        q.put(&mut other).unwrap();
5278        q.put(&mut live).unwrap();
5279        // `gone` has no record: its removal finished deleting the record but
5280        // not the set-aside attachments.
5281        let orphan = q.root.join(format!("{}.attachments.removing", gone.id));
5282        std::fs::create_dir_all(&orphan).unwrap();
5283        std::fs::write(orphan.join("shot.png"), "x").unwrap();
5284        // `live` still has a record, so its `.removing` belongs to a removal
5285        // in progress and must be left alone.
5286        let busy = q.root.join(format!("{}.attachments.removing", live.id));
5287        std::fs::create_dir_all(&busy).unwrap();
5288
5289        q.remove(&other.id, false, &questions).unwrap();
5290        assert!(!orphan.exists(), "an orphan is swept");
5291        assert!(busy.exists(), "a removal in progress is left alone");
5292    }
5293
5294    #[test]
5295    fn a_blocked_aside_rename_fails_the_removal_and_loses_nothing() {
5296        let (dir, q) = queue();
5297        let questions = Questions::at(dir.path().join("questions"));
5298        let mut t = task("stuck");
5299        let src = source_file(dir.path(), "shot.png", "x");
5300        q.attach_and_put(&mut t, &[src]).unwrap();
5301        // A non-empty directory already at the set-aside name makes the rename
5302        // fail; its record is present, so the sweep leaves it in place.
5303        let aside = q.root.join(format!("{}.attachments.removing", t.id));
5304        std::fs::create_dir_all(&aside).unwrap();
5305        std::fs::write(aside.join("old.png"), "o").unwrap();
5306        assert!(q.remove(&t.id, false, &questions).is_err());
5307        assert!(q.path_of(&t.id).exists());
5308        assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
5309    }
5310
5311    #[test]
5312    fn a_failed_record_removal_puts_the_attachments_back() {
5313        let (dir, q) = queue();
5314        let mut t = task("rollback");
5315        let src = source_file(dir.path(), "shot.png", "x");
5316        q.attach_and_put(&mut t, &[src]).unwrap();
5317        let err = q
5318            .remove_record_with_attachments(&t.id, |_| {
5319                Err(std::io::Error::other("injected failure"))
5320            })
5321            .unwrap_err();
5322        assert!(format!("{err:#}").contains("injected failure"));
5323        assert!(q.path_of(&t.id).exists());
5324        assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
5325        assert!(
5326            !q.root
5327                .join(format!("{}.attachments.removing", t.id))
5328                .exists()
5329        );
5330    }
5331
5332    #[test]
5333    fn editing_a_task_keeps_its_attachments() {
5334        let (dir, q) = queue();
5335        let src = source_file(dir.path(), "shot.png", "x");
5336        let mut t = task("editable");
5337        q.attach(&mut t, &[src]).unwrap();
5338        t.edit("new".to_owned(), "new text".to_owned()).unwrap();
5339        q.put(&mut t).unwrap();
5340        assert_eq!(q.get(&t.id).unwrap().attachments, ["shot.png"]);
5341    }
5342
5343    #[test]
5344    fn removing_a_task_deletes_its_attachments() {
5345        let (dir, q) = queue();
5346        let questions = Questions::at(dir.path().join("questions"));
5347        let src = source_file(dir.path(), "shot.png", "x");
5348        let mut t = task("doomed");
5349        q.attach(&mut t, &[src]).unwrap();
5350        q.put(&mut t).unwrap();
5351        assert!(q.attachments_dir(&t.id).is_dir());
5352        q.remove(&t.id, false, &questions).unwrap();
5353        assert!(!q.attachments_dir(&t.id).exists());
5354        assert!(q.list().is_empty());
5355    }
5356
5357    #[test]
5358    fn a_task_written_before_attachments_still_reads() {
5359        let (_dir, q) = queue();
5360        let mut t = task("old");
5361        q.put(&mut t).unwrap();
5362        let path = q.path_of(&t.id);
5363        let mut v: serde_json::Value =
5364            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
5365        v.as_object_mut().unwrap().remove("attachments");
5366        std::fs::write(&path, v.to_string()).unwrap();
5367        assert!(q.get(&t.id).unwrap().attachments.is_empty());
5368    }
5369
5370    #[test]
5371    fn attachment_paths_are_absolute_even_when_the_root_is_relative() {
5372        let q = Queue::at(PathBuf::from("relative-queue"));
5373        let mut t = task("rel");
5374        t.attachments.push("shot.png".to_owned());
5375        let paths = q.attachment_paths(&t);
5376        assert!(paths[0].is_absolute(), "{}", paths[0].display());
5377        assert!(paths[0].ends_with(format!("{}.attachments/shot.png", t.id)));
5378    }
5379
5380    fn upgrade_parked(id: &str, started: &str) -> Task {
5381        let mut t = task(id);
5382        t.id = id.into();
5383        t.start(format!("run-{id}"));
5384        t.park("upgrade");
5385        t.record_pass_at(
5386            &format!("run-{id}"),
5387            0,
5388            Some(started.parse().unwrap()),
5389            1,
5390            Some(crate::run::ParkCause::Upgrade {
5391                from: "old".into(),
5392                to: None,
5393            }),
5394        );
5395        t
5396    }
5397
5398    #[test]
5399    fn upgrade_resume_order_follows_dispatch_claim_not_graph_start() {
5400        // A was claimed first but its graph pass started later (slow start-up).
5401        let stamp = |id: &str, dispatched: &str, started: &str| {
5402            let mut t = task(id);
5403            t.id = id.into();
5404            t.start(format!("run-{id}"));
5405            t.park("upgrade");
5406            t.dispatched_at = Some(dispatched.parse().unwrap());
5407            t.record_pass_at(
5408                &format!("run-{id}"),
5409                0,
5410                Some(started.parse().unwrap()),
5411                1,
5412                Some(crate::run::ParkCause::Upgrade {
5413                    from: "old".into(),
5414                    to: None,
5415                }),
5416            );
5417            t
5418        };
5419        let a = stamp("a", "2026-10-22T00:00:01Z", "2026-10-22T00:00:09Z");
5420        let b = stamp("b", "2026-10-22T00:00:02Z", "2026-10-22T00:00:03Z");
5421        assert!(dispatch_order(&a, &b).is_lt());
5422    }
5423
5424    #[test]
5425    fn upgrade_resume_order_survives_queue_reload_and_reversed_settlement() {
5426        let (_dir, q) = queue();
5427        let mut later = upgrade_parked("later", "2026-10-22T00:00:02Z");
5428        let mut earlier = upgrade_parked("earlier", "2026-10-22T00:00:01Z");
5429        earlier.priority = -10;
5430        let mut fresh = task("fresh");
5431        fresh.id = "newer".into();
5432        fresh.priority = 100;
5433        for t in [&mut fresh, &mut later, &mut earlier] {
5434            q.put(t).unwrap();
5435        }
5436        let reloaded = Queue::at(q.root().to_owned());
5437        let mut tasks = reloaded.list();
5438        tasks.sort_unstable_by(dispatch_order);
5439        assert_eq!(
5440            tasks.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
5441            vec!["earlier", "later", "newer"]
5442        );
5443        assert_eq!(reloaded.next_runnable().unwrap().id, "earlier");
5444        assert_eq!(reloaded.get("earlier").unwrap().attempts, 0);
5445    }
5446
5447    #[test]
5448    fn upgrade_resume_legacy_order_uses_settlement_and_id() {
5449        let mut a = upgrade_parked("a", "2026-10-22T00:00:02Z");
5450        let mut b = upgrade_parked("b", "2026-10-22T00:00:01Z");
5451        for t in [&mut a, &mut b] {
5452            let mut value = serde_json::to_value(&*t).unwrap();
5453            value["passes"][0]
5454                .as_object_mut()
5455                .unwrap()
5456                .remove("started_at");
5457            *t = serde_json::from_value(value).unwrap();
5458        }
5459        assert!(dispatch_order(&a, &b).is_lt());
5460        b.passes[0].settled_at = a.passes[0].settled_at;
5461        assert!(dispatch_order(&a, &b).is_lt());
5462    }
5463
5464    #[test]
5465    fn upgrade_resume_key_excludes_other_parks_and_explicit_task_changes() {
5466        let original = upgrade_parked("a", "2026-10-22T00:00:01Z");
5467        assert!(original.upgrade_resume_key().is_some());
5468        for cause in [
5469            crate::run::ParkCause::Operator,
5470            crate::run::ParkCause::Question,
5471            crate::run::ParkCause::Interrupt {
5472                reason: "interrupt".into(),
5473            },
5474            crate::run::ParkCause::Unspecified,
5475        ] {
5476            let mut t = original.clone();
5477            t.passes[0].parked = Some(cause);
5478            assert!(t.upgrade_resume_key().is_none());
5479            let mut fresh = task("fresh");
5480            fresh.priority = 1;
5481            assert!(dispatch_order(&fresh, &t).is_lt());
5482        }
5483        let mut t = original.clone();
5484        t.release();
5485        assert!(t.upgrade_resume_key().is_none());
5486        t.edit("edited".into(), "new instruction".into()).unwrap();
5487        assert!(t.upgrade_resume_key().is_none());
5488        let mut t = original.clone();
5489        t.requeue();
5490        t.status = TaskStatus::Parked;
5491        assert!(t.upgrade_resume_key().is_none());
5492        let mut t = original.clone();
5493        t.start("different-run".into());
5494        t.park("no upgrade");
5495        assert!(t.upgrade_resume_key().is_none());
5496    }
5497
5498    #[test]
5499    fn explicitly_resumed_older_run_becomes_current_without_duplicate_history() {
5500        let dir = tempfile::tempdir().unwrap();
5501        let q = Queue::at(dir.path().join("queue"));
5502        let mut t = task("resume");
5503        t.start("A".into());
5504        t.record_pass("A", 0, 1, None);
5505        t.start("B".into());
5506        q.put(&mut t).unwrap();
5507        assert_eq!(t.runs, vec!["A", "B"]);
5508        t.start("A".into());
5509        t.record_pass("A", 1, 3, None);
5510        q.put(&mut t).unwrap();
5511        let saved = q.get(&t.id).unwrap();
5512        assert_eq!(saved.runs, vec!["B", "A"]);
5513        assert_eq!(saved.runs.last().map(String::as_str), Some("A"));
5514        assert_eq!(saved.attempts, 3);
5515        assert_eq!(saved.passes.len(), 2);
5516        assert_eq!(saved.passes[0].run, "A");
5517        assert_eq!(saved.passes[1].run, "A");
5518    }
5519
5520    #[test]
5521    fn repeated_start_records_one_run_and_separate_passes() {
5522        let mut t = task("resume");
5523        t.start("same-run".into());
5524        t.record_pass("same-run", 0, 1, None);
5525        t.start("same-run".into());
5526        t.record_pass("same-run", 1, 2, None);
5527        assert_eq!(t.runs, vec!["same-run"]);
5528        assert_eq!(t.attempts, 2);
5529        assert_eq!(t.passes.len(), 2);
5530    }
5531
5532    #[test]
5533    fn link_run_adds_a_run_once_and_touches_nothing_else() {
5534        let dir = tempfile::tempdir().unwrap();
5535        let queue = Queue::at(dir.path().join("queue"));
5536        let mut t = Task::new(
5537            "t".to_owned(),
5538            "do it".to_owned(),
5539            PathBuf::from("."),
5540            Source::Human,
5541        );
5542        queue.put(&mut t).unwrap();
5543        let before = queue.get(&t.id).unwrap();
5544
5545        let linked = queue.link_run(&t.id[..4], "20260930-092817-ec34").unwrap();
5546        assert_eq!(linked.runs, vec!["20260930-092817-ec34".to_owned()]);
5547        assert_eq!(linked.status, before.status);
5548        assert_eq!(linked.attempts, before.attempts);
5549        assert_eq!(linked.interrupt, before.interrupt);
5550
5551        let again = queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
5552        assert_eq!(again.runs.len(), 1, "linking twice must not duplicate");
5553        assert_eq!(queue.get(&t.id).unwrap().runs.len(), 1);
5554        assert!(queue.link_run("no-such-task", "r").is_err());
5555    }
5556
5557    #[test]
5558    fn put_keeps_a_run_linked_after_the_writer_took_its_snapshot() {
5559        let dir = tempfile::tempdir().unwrap();
5560        let queue = Queue::at(dir.path().join("queue"));
5561        let mut t = Task::new(
5562            "t".to_owned(),
5563            "do it".to_owned(),
5564            PathBuf::from("."),
5565            Source::Human,
5566        );
5567        queue.put(&mut t).unwrap();
5568        // The daemon's copy, taken before the link.
5569        let mut snapshot = queue.get(&t.id).unwrap();
5570        queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
5571
5572        snapshot.start("20260930-000000-aaaa".to_owned());
5573        queue.put(&mut snapshot).unwrap();
5574
5575        let stored = queue.get(&t.id).unwrap();
5576        assert!(stored.runs.contains(&"20260930-092817-ec34".to_owned()));
5577        assert!(stored.runs.contains(&"20260930-000000-aaaa".to_owned()));
5578        assert_eq!(stored.attempts, 1);
5579    }
5580
5581    #[test]
5582    fn concurrent_links_and_daemon_saves_lose_nothing() {
5583        let dir = tempfile::tempdir().unwrap();
5584        let queue = Queue::at(dir.path().join("queue"));
5585        let mut t = Task::new(
5586            "t".to_owned(),
5587            "do it".to_owned(),
5588            PathBuf::from("."),
5589            Source::Human,
5590        );
5591        queue.put(&mut t).unwrap();
5592        let id = t.id.clone();
5593
5594        let linkers: Vec<_> = (0..4)
5595            .map(|n| {
5596                let (queue, id) = (queue.clone(), id.clone());
5597                std::thread::spawn(move || {
5598                    for k in 0..10 {
5599                        queue
5600                            .link_run(&id, &format!("20260930-00000{n}-l{k:03}"))
5601                            .unwrap();
5602                    }
5603                })
5604            })
5605            .collect();
5606        // The daemon's side: transitions saved from its own, ever staler,
5607        // copy of the task.
5608        let mut mine = queue.get(&id).unwrap();
5609        for k in 0..10 {
5610            mine.start(format!("20260930-000009-d{k:03}"));
5611            queue.put(&mut mine).unwrap();
5612        }
5613        for l in linkers {
5614            l.join().unwrap();
5615        }
5616
5617        let stored = queue.get(&id).unwrap();
5618        assert_eq!(stored.runs.len(), 50, "{:?}", stored.runs);
5619        assert_eq!(
5620            stored.attempts, 10,
5621            "linking never rewinds the daemon's work"
5622        );
5623    }
5624
5625    fn age_lock(path: &Path) {
5626        let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
5627        f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
5628            .unwrap();
5629    }
5630
5631    #[test]
5632    fn concurrent_stale_takeover_yields_one_holder() {
5633        use std::sync::atomic::{AtomicUsize, Ordering};
5634        use std::sync::{Arc, Barrier};
5635        for _ in 0..5 {
5636            let dir = tempfile::tempdir().unwrap();
5637            let q = Queue::at(dir.path().to_path_buf());
5638            let lock = dir.path().join("t.write-lock");
5639            std::fs::write(&lock, "dead-0000").unwrap();
5640            age_lock(&lock);
5641            let n = 6;
5642            let barrier = Arc::new(Barrier::new(n));
5643            let (now, max) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
5644            let handles: Vec<_> = (0..n)
5645                .map(|_| {
5646                    let (q, b, now, max) = (q.clone(), barrier.clone(), now.clone(), max.clone());
5647                    std::thread::spawn(move || {
5648                        b.wait();
5649                        let g = q.lock_task("t").unwrap();
5650                        let held = now.fetch_add(1, Ordering::SeqCst) + 1;
5651                        max.fetch_max(held, Ordering::SeqCst);
5652                        std::thread::sleep(std::time::Duration::from_millis(20));
5653                        now.fetch_sub(1, Ordering::SeqCst);
5654                        drop(g);
5655                    })
5656                })
5657                .collect();
5658            for h in handles {
5659                h.join().unwrap();
5660            }
5661            assert_eq!(max.load(Ordering::SeqCst), 1);
5662            assert!(!lock.exists());
5663        }
5664    }
5665
5666    #[test]
5667    fn dropping_a_replaced_lock_leaves_the_new_holders_token() {
5668        let dir = tempfile::tempdir().unwrap();
5669        let q = Queue::at(dir.path().to_path_buf());
5670        let lock = dir.path().join("t.write-lock");
5671        let a = q.lock_task("t").unwrap();
5672        // Simulate a foreign/legacy writer replacing the file. A modern
5673        // writer cannot acquire it while this guard's process is alive.
5674        let replacement = owner_token();
5675        std::fs::write(&lock, &replacement).unwrap();
5676        drop(a);
5677        assert_eq!(std::fs::read_to_string(&lock).unwrap(), replacement);
5678    }
5679
5680    #[test]
5681    fn aged_live_task_lock_excludes_updates_until_retirement_releases_it() {
5682        let dir = tempfile::tempdir().unwrap();
5683        let q = Queue::at(dir.path().to_path_buf());
5684        let held = q.lock_task("t").unwrap();
5685        let path = dir.path().join("t.write-lock");
5686        age_lock(&path);
5687        assert!(!break_stale(&path, &held.token));
5688        let (started_tx, started_rx) = std::sync::mpsc::channel();
5689        let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
5690        let updater = std::thread::spawn(move || {
5691            started_tx.send(()).unwrap();
5692            let _write = q.lock_task("t").unwrap();
5693            acquired_tx.send(()).unwrap();
5694        });
5695        started_rx.recv().unwrap();
5696        assert!(
5697            acquired_rx
5698                .recv_timeout(std::time::Duration::from_millis(100))
5699                .is_err()
5700        );
5701        assert_eq!(std::fs::read_to_string(&path).unwrap(), held.token);
5702        drop(held);
5703        acquired_rx
5704            .recv_timeout(std::time::Duration::from_secs(2))
5705            .unwrap();
5706        updater.join().unwrap();
5707        assert!(!path.exists());
5708    }
5709
5710    #[test]
5711    fn break_stale_leaves_a_lock_that_replaced_the_one_judged() {
5712        let dir = tempfile::tempdir().unwrap();
5713        let lock = dir.path().join("t.write-lock");
5714        std::fs::write(&lock, "new-token").unwrap();
5715        assert!(!break_stale(&lock, "old-token"));
5716        assert_eq!(std::fs::read_to_string(&lock).unwrap(), "new-token");
5717        // Same token but fresh: not stale any more, so also left alone.
5718        assert!(!break_stale(&lock, "new-token"));
5719        assert!(lock.exists());
5720        age_lock(&lock);
5721        assert!(break_stale(&lock, "new-token"));
5722        assert!(!lock.exists());
5723    }
5724
5725    #[test]
5726    fn a_stale_break_marker_is_recovered_and_a_fresh_one_is_respected() {
5727        let dir = tempfile::tempdir().unwrap();
5728        let lock = dir.path().join("t.write-lock");
5729        std::fs::write(&lock, "dead-1").unwrap();
5730        age_lock(&lock);
5731        let marker = break_marker(&lock, "dead-1");
5732        std::fs::write(&marker, "crashed-remover").unwrap();
5733        // Fresh marker: another remover is working, so nothing is broken.
5734        assert!(!break_stale(&lock, "dead-1"));
5735        assert!(lock.exists());
5736        // Stale marker: recovered, then the lock is broken.
5737        age_lock(&marker);
5738        assert!(break_stale(&lock, "dead-1"));
5739        assert!(!lock.exists());
5740        assert!(!marker.exists());
5741    }
5742
5743    fn chat_task() -> Task {
5744        Task::new(
5745            "t".to_owned(),
5746            "do t".to_owned(),
5747            PathBuf::from("."),
5748            Source::Agent {
5749                run: "20260902-000000-beef".to_owned(),
5750                node: CHAT_NODE.to_owned(),
5751            },
5752        )
5753    }
5754
5755    #[test]
5756    fn a_blocked_hold_keeps_its_anchor_but_a_new_run_starts_a_new_hold() {
5757        let mut t = chat_task();
5758        t.hold_machine(Some("waiting".into()));
5759        let old: Timestamp = "2020-01-01T00:00:00Z".parse().unwrap();
5760        t.held_at = Some(old);
5761        t.block(vec!["decision".into()], None);
5762        t.hold_machine(Some("same waiting decision".into()));
5763        assert_eq!(t.held_at, Some(old));
5764        t.start("real-new-run".into());
5765        t.fail("new failure", 1);
5766        assert_ne!(
5767            t.held_at,
5768            Some(old),
5769            "old answers cannot approve a new hold"
5770        );
5771    }
5772
5773    #[test]
5774    fn legacy_delivery_survives_upgrade_and_a_no_progress_run_link() {
5775        let (_dir, queue) = queue();
5776        let mut t = chat_task();
5777        t.start("old-run".into());
5778        t.hold_machine(Some("operator is considering recovery".into()));
5779        t.chat_notice = Some(ChatNotice {
5780            key: format!("held#{}", t.held_at.unwrap()),
5781            at: Timestamp::now(),
5782            outcome: ChatNoticeOutcome::Posted,
5783        });
5784        let mut raw = serde_json::to_value(&t).unwrap();
5785        raw.as_object_mut().unwrap().remove("chat_notices");
5786        raw.as_object_mut().unwrap().remove("hold_notice_cause");
5787        std::fs::create_dir_all(queue.path_of(&t.id).parent().unwrap()).unwrap();
5788        std::fs::write(queue.path_of(&t.id), serde_json::to_string(&raw).unwrap()).unwrap();
5789        assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5790        queue
5791            .modify(&t.id, |task| {
5792                task.requeue();
5793                task.link_run("unprepared-run");
5794                task.startup_notice_cause("startup-source");
5795                task.fail("same unresolved preflight", 0);
5796                true
5797            })
5798            .unwrap();
5799        assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5800        queue
5801            .modify(&t.id, |task| {
5802                task.succeed();
5803                true
5804            })
5805            .unwrap();
5806        assert_eq!(
5807            queue.get(&t.id).unwrap().chat_notice_due().as_deref(),
5808            Some("done")
5809        );
5810    }
5811
5812    #[test]
5813    fn a_dependency_wait_does_not_change_when_released_and_blocked_again() {
5814        let mut t = chat_task();
5815        t.block(vec!["a".into(), "b".into()], Some("waiting".into()));
5816        let key = t.chat_notice_key().unwrap();
5817        t.chat_notices.push(key);
5818        t.release();
5819        t.block(
5820            vec!["b".into(), "a".into(), "a".into()],
5821            Some("still waiting on prerequisites".into()),
5822        );
5823        assert_eq!(t.chat_notice_due(), None);
5824        t.block(
5825            vec!["new-question".into()],
5826            Some("different decision".into()),
5827        );
5828        assert!(t.chat_notice_due().is_some());
5829    }
5830
5831    #[test]
5832    fn running_startup_failures_preserve_stage_and_report_distinct_blockers() {
5833        let mut t = chat_task();
5834        t.start("existing-run".into());
5835        t.fail("implementation failed", 1);
5836        let failed = t.chat_notice_key().unwrap();
5837        t.chat_notices.push(failed.clone());
5838        t.release_operator("test", None);
5839        t.start("existing-run".into());
5840        t.fail_startup("startup-source", "same unresolved recovery source", 1);
5841        assert_eq!(
5842            t.chat_notice_due(),
5843            None,
5844            "no-progress recovery stays quiet"
5845        );
5846        t.release_operator("test", None);
5847        t.start("existing-run".into());
5848        t.fail_startup("startup-attachments", "attachment missing", 1);
5849        let attachments = t.chat_notice_due().expect("new attachment blocker");
5850        assert_ne!(attachments, failed);
5851        assert_eq!(
5852            t.hold_notice_cause.as_ref().unwrap().stage,
5853            "startup-attachments"
5854        );
5855        t.chat_notices.push(attachments);
5856        t.release_operator("test", None);
5857        t.start("existing-run".into());
5858        t.fail_startup(
5859            "startup-attachments",
5860            "same missing attachment, reworded",
5861            1,
5862        );
5863        assert_eq!(
5864            t.chat_notice_due(),
5865            None,
5866            "same stage after attempt counter reset"
5867        );
5868        t.release_operator("test", None);
5869        t.start("existing-run".into());
5870        t.fail_startup("startup-config", "different preflight stage", 1);
5871        assert!(t.chat_notice_due().is_some());
5872        assert_eq!(
5873            t.hold_notice_cause.as_ref().unwrap().stage,
5874            "startup-config"
5875        );
5876    }
5877
5878    #[test]
5879    fn structured_causes_preserve_no_progress_retries_and_report_new_passes() {
5880        let mut t = chat_task();
5881        t.hold_machine(Some("startup source missing".into()));
5882        t.startup_notice_cause("startup-source");
5883        let first = t.chat_notice_key().unwrap();
5884        t.chat_notices.push(first.clone());
5885        t.release_operator("test", None);
5886        t.startup_notice_cause("startup-source");
5887        t.hold_machine(Some("same source cannot be read".into()));
5888        assert_eq!(t.chat_notice_due(), None);
5889        t.startup_notice_cause("startup-config");
5890        assert!(t.chat_notice_due().is_some(), "different startup stage");
5891        t.notice_cause("failed", Some("existing-run"), Some(0));
5892        let run_failure = t.chat_notice_key().unwrap();
5893        t.chat_notices.push(run_failure.clone());
5894        t.release_operator("test", None);
5895        t.startup_notice_cause("startup-source");
5896        t.hold_machine(Some("no new work happened".into()));
5897        assert_eq!(t.chat_notice_due(), None);
5898        t.notice_cause("failed", Some("existing-run"), Some(1));
5899        assert!(t.chat_notice_due().is_some(), "a real resumed pass failed");
5900        assert_ne!(t.chat_notice_key().unwrap(), run_failure);
5901    }
5902
5903    #[test]
5904    fn legacy_hold_baseline_does_not_replay_or_hide_new_failures_and_completion() {
5905        let mut t = chat_task();
5906        t.hold_machine(Some("still waiting".into()));
5907        t.chat_notice = Some(ChatNotice {
5908            key: "held#2026-10-14T00:00:00Z".into(),
5909            at: Timestamp::now(),
5910            outcome: ChatNoticeOutcome::Posted,
5911        });
5912        assert_eq!(t.chat_notice_due(), None, "old hold baseline");
5913        t.import_chat_notice();
5914        t.notice_cause("failed", Some("new-run"), Some(0));
5915        assert!(t.chat_notice_due().is_some(), "new producer evidence");
5916        t.succeed();
5917        assert_eq!(t.chat_notice_due().as_deref(), Some("done"));
5918    }
5919
5920    #[test]
5921    fn a_chat_notice_is_due_only_for_a_final_state_and_only_once() {
5922        let mut t = chat_task();
5923        assert_eq!(t.chat_notice_due(), None, "queued");
5924        t.start("r1".to_owned());
5925        assert_eq!(t.chat_notice_due(), None, "running");
5926        t.fail("boom", 3);
5927        assert_eq!(t.status, TaskStatus::Failed);
5928        assert_eq!(t.chat_notice_due(), None, "a retried failure is not final");
5929        t.start("r2".to_owned());
5930        t.park("upgrade");
5931        assert_eq!(t.chat_notice_due(), None, "parked");
5932        t.fail("boom", 1);
5933        assert_eq!(t.status, TaskStatus::Held);
5934        let key = t
5935            .chat_notice_due()
5936            .expect("held on the last attempt is final");
5937        t.chat_notice = Some(ChatNotice {
5938            key,
5939            at: Timestamp::now(),
5940            outcome: ChatNoticeOutcome::Posted,
5941        });
5942        assert_eq!(t.chat_notice_due(), None, "same hold, no second notice");
5943        t.release();
5944        assert_eq!(t.chat_notice_due(), None, "requeued after a release");
5945        t.start("r3".to_owned());
5946        t.succeed();
5947        assert!(t.chat_notice_due().is_some(), "done is a new final state");
5948    }
5949
5950    #[test]
5951    fn completed_chat_notices_keep_current_and_legacy_keys_after_requeue() {
5952        for key in ["done", "done#0", "done#1", "done#12"] {
5953            let mut t = chat_task();
5954            t.start("r1".to_owned());
5955            t.succeed();
5956            t.chat_notice = Some(ChatNotice {
5957                key: key.to_owned(),
5958                at: Timestamp::now(),
5959                outcome: ChatNoticeOutcome::Posted,
5960            });
5961            t.requeue();
5962            t.start("r2".to_owned());
5963            t.succeed();
5964            assert_eq!(t.chat_notice_key().as_deref(), Some("done"));
5965            assert_eq!(t.chat_notice_due(), None, "recorded key: {key}");
5966        }
5967    }
5968
5969    #[test]
5970    fn a_release_without_progress_keeps_the_unresolved_notice_cause() {
5971        let mut t = chat_task();
5972        t.hold_manual(Some("wait".to_owned()));
5973        let first = t.chat_notice_due().expect("held");
5974        t.chat_notice = Some(ChatNotice {
5975            key: first.clone(),
5976            at: Timestamp::now(),
5977            outcome: ChatNoticeOutcome::Posted,
5978        });
5979        t.hold_manual(Some("still waiting".to_owned()));
5980        assert_eq!(t.chat_notice_due(), None, "a re-hold keeps its start");
5981        t.release();
5982        std::thread::sleep(std::time::Duration::from_millis(5));
5983        t.hold_manual(None);
5984        assert_eq!(t.chat_notice_due(), None, "no progress, same cause");
5985    }
5986
5987    #[test]
5988    fn a_chat_notice_needs_a_chat_source_and_a_current_schema() {
5989        let mut human = task("h");
5990        human.succeed();
5991        assert_eq!(human.chat_notice_due(), None);
5992        // A follow-up only inherits the chat; it did not file the task.
5993        let mut inherited = task("i");
5994        inherited.origin_chat = Some("20260902-000000-beef".to_owned());
5995        inherited.succeed();
5996        assert_eq!(inherited.chat_notice_due(), None);
5997        let mut old = chat_task();
5998        old.schema = 12;
5999        old.status = TaskStatus::Done;
6000        assert_eq!(old.chat_notice_due(), None, "history is not replayed");
6001        let mut live = chat_task();
6002        live.schema = 12;
6003        live.start("r1".to_owned());
6004        live.succeed();
6005        assert!(live.chat_notice_due().is_some(), "started by this build");
6006        let mut held = chat_task();
6007        held.schema = 12;
6008        held.hold_machine(Some("config".to_owned()));
6009        assert!(held.chat_notice_due().is_some(), "held before any start");
6010    }
6011
6012    #[test]
6013    fn a_stale_snapshot_does_not_rewind_a_recorded_chat_notice() {
6014        let tmp = tempfile::tempdir().expect("tempdir");
6015        let q = Queue::at(tmp.path().join("queue"));
6016        let mut t = chat_task();
6017        t.succeed();
6018        q.put(&mut t).expect("put");
6019        let mut stale = q.get(&t.id).expect("get");
6020        let key = t.chat_notice_due().expect("due");
6021        q.modify(&t.id, |x| {
6022            x.chat_notice = Some(ChatNotice {
6023                key: key.clone(),
6024                at: Timestamp::now(),
6025                outcome: ChatNoticeOutcome::Posted,
6026            });
6027            true
6028        })
6029        .expect("modify");
6030        q.put(&mut stale).expect("stale put");
6031        assert_eq!(q.get(&t.id).expect("get").chat_notice_due(), None);
6032    }
6033
6034    #[test]
6035    fn empty_sources_are_omitted_so_older_builds_still_read_the_task() {
6036        // `Task` rejects unknown fields, so a build without `sources` cannot
6037        // read a record that carries the key, even an empty one.
6038        let mut t = task("plain");
6039        let value = serde_json::to_value(&t).unwrap();
6040        assert!(value.get("sources").is_none(), "{value}");
6041        t.sources.pick_commits.push(crate::refs::Input {
6042            token: "abc1234".into(),
6043            sha: "a".repeat(40),
6044            provenance: crate::refs::Provenance::ExplicitCli,
6045        });
6046        let value = serde_json::to_value(&t).unwrap();
6047        assert_eq!(value["sources"]["pick_commits"][0]["sha"], "a".repeat(40));
6048        let restored: Task = serde_json::from_value(value).unwrap();
6049        assert_eq!(restored.sources, t.sources);
6050    }
6051
6052    /// A queue whose id source yields `ids` in order, then fresh ones.
6053    fn queue_with_ids(dir: &Path, ids: &[&str]) -> Queue {
6054        let ids: Vec<String> = ids.iter().map(|s| (*s).to_owned()).collect();
6055        let n = std::sync::atomic::AtomicUsize::new(0);
6056        Queue::at(dir.join("queue")).with_id_source(move || {
6057            let i = n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6058            ids.get(i)
6059                .cloned()
6060                .unwrap_or_else(|| format!("20260101-000000-f{i:03x}"))
6061        })
6062    }
6063
6064    const TAKEN: &str = "20260101-000000-aaaa";
6065
6066    #[test]
6067    fn a_new_task_never_merges_into_a_record_with_its_candidate_id() {
6068        let dir = tempfile::tempdir().unwrap();
6069        let q = queue_with_ids(dir.path(), &[TAKEN, TAKEN]);
6070        let mut first = task("first");
6071        first.start("run-1".into());
6072        first.succeed();
6073        q.put(&mut first).unwrap();
6074        assert_eq!(first.id, TAKEN);
6075        let stored_before = q.get(&first.id).unwrap();
6076        assert!(!stored_before.runs.is_empty());
6077
6078        let mut second = task("second");
6079        q.put(&mut second).unwrap();
6080
6081        assert_ne!(second.id, first.id);
6082        assert!(second.runs.is_empty());
6083        assert!(second.passes.is_empty());
6084        assert!(second.chat_notices.is_empty());
6085        assert!(second.chat_notice.is_none());
6086        assert_eq!(second.chat_notice_due(), task("fresh").chat_notice_due());
6087        let a = q.get(&first.id).unwrap();
6088        let b = q.get(&second.id).unwrap();
6089        assert_eq!(a.title, "first");
6090        assert_eq!(a.runs, stored_before.runs);
6091        assert_eq!(a.passes, stored_before.passes);
6092        assert_eq!(a.chat_notices, stored_before.chat_notices);
6093        assert_eq!(a.chat_notice, stored_before.chat_notice);
6094        assert_eq!(a.chat_notice_due(), stored_before.chat_notice_due());
6095        assert_eq!(b.title, "second");
6096        assert!(b.runs.is_empty());
6097        assert!(b.passes.is_empty());
6098        assert!(b.chat_notice.is_none());
6099
6100        // A later save is an update: it still merges.
6101        let mut again = q.get(&second.id).unwrap();
6102        again.title = "second, edited".into();
6103        q.put(&mut again).unwrap();
6104        assert_eq!(q.get(&second.id).unwrap().title, "second, edited");
6105        assert_eq!(q.list().len(), 2);
6106    }
6107
6108    #[test]
6109    fn a_new_task_skips_an_id_with_an_orphaned_claim_or_attachments() {
6110        let dir = tempfile::tempdir().unwrap();
6111        let q = queue_with_ids(dir.path(), &["orphan-lock", "orphan-dir", "free"]);
6112        std::fs::create_dir_all(q.root()).unwrap();
6113        std::fs::write(q.root().join("orphan-lock.lock"), "x").unwrap();
6114        std::fs::create_dir_all(q.attachments_dir("orphan-dir")).unwrap();
6115        let mut t = task("t");
6116        q.put(&mut t).unwrap();
6117        assert_eq!(t.id, "free");
6118    }
6119
6120    #[test]
6121    fn a_claimed_new_task_holds_the_claim_on_the_reserved_id() {
6122        let dir = tempfile::tempdir().unwrap();
6123        let q = queue_with_ids(dir.path(), &[TAKEN, TAKEN]);
6124        let mut first = task("first");
6125        q.put(&mut first).unwrap();
6126        let mut second = task("second");
6127        let claim = q.file_claimed(&mut second).unwrap();
6128        assert_ne!(second.id, first.id);
6129        assert!(q.claim(&second.id).is_err());
6130        assert!(q.claim(&first.id).is_ok(), "the first task was not claimed");
6131        drop(claim);
6132    }
6133
6134    #[test]
6135    fn an_attached_new_task_skips_a_taken_id() {
6136        let dir = tempfile::tempdir().unwrap();
6137        let q = queue_with_ids(dir.path(), &[TAKEN, TAKEN]);
6138        let mut first = task("first");
6139        q.put(&mut first).unwrap();
6140        let src = source_file(dir.path(), "shot.png", "x");
6141        let mut second = task("second");
6142        q.attach_and_put(&mut second, &[src]).unwrap();
6143        assert_ne!(second.id, first.id);
6144        assert!(!q.attachments_dir(&first.id).exists());
6145        assert_eq!(q.attachment_paths(&second).len(), 1);
6146        assert!(q.attachment_paths(&second)[0].is_file());
6147    }
6148
6149    #[test]
6150    fn attaching_before_putting_a_new_task_reserves_the_id() {
6151        let dir = tempfile::tempdir().unwrap();
6152        let q = queue_with_ids(dir.path(), &[TAKEN, TAKEN]);
6153        let mut first = task("first");
6154        q.put(&mut first).unwrap();
6155        let src = source_file(dir.path(), "shot.png", "x");
6156        let mut second = task("second");
6157        q.attach(&mut second, &[src]).unwrap();
6158        assert_ne!(second.id, first.id);
6159        assert!(q.attachments_dir(&second.id).is_dir());
6160        q.put(&mut second).unwrap();
6161        assert_eq!(q.get(&second.id).unwrap().attachments.len(), 1);
6162        assert_eq!(q.get(&first.id).unwrap().title, "first");
6163        assert!(q.get(&first.id).unwrap().attachments.is_empty());
6164    }
6165
6166    #[test]
6167    fn create_new_keeps_its_already_exists_meaning() {
6168        let dir = tempfile::tempdir().unwrap();
6169        let q = Queue::at(dir.path().join("queue"));
6170        let mut a = task("a");
6171        a.id = "fixed-id".into();
6172        assert!(q.create_new(&mut a).unwrap());
6173        let mut b = task("b");
6174        b.id = "fixed-id".into();
6175        assert!(!q.create_new(&mut b).unwrap());
6176        assert_eq!(q.get("fixed-id").unwrap().title, "a");
6177    }
6178
6179    #[test]
6180    fn filing_gives_up_when_no_id_is_ever_free() {
6181        let dir = tempfile::tempdir().unwrap();
6182        let q = Queue::at(dir.path().join("queue")).with_id_source(|| TAKEN.to_owned());
6183        let mut first = task("first");
6184        q.put(&mut first).unwrap();
6185        let mut second = task("second");
6186        assert!(q.put(&mut second).is_err());
6187        assert_eq!(q.get(&first.id).unwrap().title, "first");
6188    }
6189}