Skip to main content

magi/
conduct.rs

1//! The conductor: a single agent seat that arranges the queue.
2//!
3//! [`crate::queue`] orders runnable tasks by `priority` alone; nothing in it
4//! can express that one task should wait for another, or that a task whose
5//! last run stopped short deserves a second look before the loop blindly
6//! retries it. Once per polling cycle, [`Conductor::maybe_run`] shows one
7//! agent seat three things - the runnable tasks, the tasks stuck `running`
8//! with no live daemon behind them, and the `failed`/`held` tasks nobody has
9//! decided about yet - and asks it to decide `blocked_by` for the first and a
10//! recovery for the other two. Everything else about the loop - which
11//! unblocked task runs next, one at a time, in `priority` order - is
12//! unchanged; see `crate::daemon`.
13//!
14//! # What the conductor may not do
15//!
16//! [`Decision`] has no field for `priority`, for deleting a task, or for
17//! touching git, a worktree, or a branch directly. [`Recovery::Review`] only
18//! ever reopens a branch `crate::daemon` itself resolved from the task's own
19//! run record ([`surviving_branch`]) - never a name the model wrote - through
20//! `crate::graph::Runner::review`, which reviews and verifies but never
21//! rewrites history.
22//!
23//! # Non-blocking by construction
24//!
25//! [`crate::ask::ask_and_wait`] is never called from here, and the prompt
26//! tells the model the same: that CLI command blocks until a human answers,
27//! and calling it from inside the conductor's own invocation would park the
28//! whole polling loop behind one task's question. Instead a decision that
29//! wants the operator's judgement carries a `question` field, and [`apply`]
30//! files it with [`Question::new`] and [`Questions::put`] and moves on in the
31//! same call. Something still has to read the owner's reply: the question
32//! carries a [`Deputy`] brief, and `magi serve` starts a short-lived seat of
33//! its own for it ([`crate::deputy`]) - never this loop.
34//!
35//! # Fails soft, always
36//!
37//! [`Conductor::maybe_run`] never returns an error: an unusable roster, a
38//! timed-out invocation, or a reply [`verdict::extract_json`] cannot parse are
39//! all logged and treated as "this cycle changes nothing." `crate::daemon`'s
40//! loop always falls through to its own `Queue::next_runnable` regardless of
41//! what happened here.
42
43use std::collections::{BTreeMap, BTreeSet};
44use std::path::{Path, PathBuf};
45use std::time::Duration;
46
47use anyhow::{Context as _, Result};
48use serde::{Deserialize, Serialize};
49
50use crate::agent::{self, Invocation, SeatState};
51use crate::ask::{Deputy, Question, Questions};
52use crate::config::Config;
53use crate::prompt;
54use crate::queue::{Queue, Task, TaskStatus};
55use crate::run::RunState;
56use crate::verdict;
57
58/// Seat name for the conductor's own CLI-side conversation, scoped away from
59/// every other seat magi ever opens - the same rule every other seat follows.
60const SEAT: &str = "conduct";
61
62/// Node name reported to the invoked agent (`MAGI_NODE`) and recorded on any
63/// question it files, so an operator reading the questions list can tell a
64/// conductor's question from one a run's own agent asked.
65pub const NODE: &str = "conduct";
66
67/// Wall-clock limit for one conductor turn. The conductor reads a queue
68/// listing and replies with json; it does not implement anything or run a
69/// build, so this is short - the same order of magnitude as
70/// `crate::chat`'s own single-turn, no-write invocations.
71const TURN_TIMEOUT: Duration = Duration::from_secs(300);
72
73/// What the conductor may choose for a `running`-but-stalled or a
74/// `failed`/`held` task.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum Recovery {
78    /// Put it back in line, attempts reset - the same effect as
79    /// `magi task release`.
80    Requeue,
81    /// Leave it for a human, unchanged otherwise - the same effect as
82    /// `magi task hold`.
83    Hold,
84    /// Reopen the task's surviving branch as a review-only pass
85    /// (`crate::graph::Runner::review`) instead of competing from scratch.
86    /// Only takes effect when [`surviving_branch`] can actually name one;
87    /// otherwise `crate::daemon` falls back to [`Recovery::Requeue`].
88    Review,
89    /// Close the task outright, as [`Task::succeed`] - the same effect as
90    /// `magi task done`. For a `failed`/`held` task only, never `running`:
91    /// this is for a task whose own goal is already known to be met outside
92    /// the loop entirely (the branch was merged and the worktree cleaned up
93    /// by hand, say) and competing it again would only spend attempts on
94    /// work that has nothing left to do, not for a task that merely stopped
95    /// mid-competition and might still need to run.
96    Done,
97}
98
99/// The operation for which the operator's decision is requested. These are
100/// fixed protocol values, independent of the language of the displayed choices.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
102#[serde(rename_all = "snake_case")]
103pub enum QuestionOperation {
104    /// Choose an existing implementation as the recovery source.
105    Recover,
106    /// Continue an existing run from its saved state.
107    Resume,
108    /// Authorize repairing existing work before continuing.
109    Repair,
110    /// Confirm that the task may be closed as complete.
111    Complete,
112    /// Request an operator inspection of the target.
113    Inspect,
114    /// Request a configuration change for the target.
115    Configure,
116}
117
118/// The concrete object of an operator decision. Sources resolve to commit ids;
119/// runs must belong to this task. A generated prose label is never an identity.
120#[derive(Debug, Clone, Deserialize)]
121#[serde(tag = "kind", rename_all = "snake_case")]
122pub enum QuestionTarget {
123    /// The task itself, for decisions without a more specific recorded object.
124    Task,
125    /// A run already owned by this task.
126    Run {
127        /// Full recorded run id.
128        id: String,
129    },
130    /// A concrete TOML setting. Different fields on the same task require
131    /// different decisions even when both operations are `configure`.
132    Setting {
133        /// Repository-relative TOML file containing the setting.
134        path: String,
135        /// Exact table/key components copied from that file.
136        keys: Vec<String>,
137    },
138    /// A recovery source which the repository can resolve to a commit.
139    Source {
140        /// Existing branch name or commit reference, never a display label.
141        reference: String,
142    },
143}
144
145/// The structured meaning of a question, separate from its displayed wording.
146#[derive(Debug, Clone, Deserialize)]
147pub struct QuestionDecision {
148    /// The concrete type of operator decision requested.
149    pub operation: QuestionOperation,
150    /// The recorded object the decision concerns.
151    pub target: QuestionTarget,
152}
153
154impl QuestionDecision {
155    fn identity(&self, task: &Task) -> Result<(QuestionOperation, String)> {
156        let target = match &self.target {
157            QuestionTarget::Task => format!("task:{}", task.id),
158            QuestionTarget::Run { id } => {
159                anyhow::ensure!(
160                    task.runs.contains(id),
161                    "question target is not this task's run"
162                );
163                format!("run:{id}")
164            }
165            QuestionTarget::Setting { path, keys } => {
166                use std::path::Component;
167                anyhow::ensure!(
168                    !path.is_empty()
169                        && Path::new(path)
170                            .components()
171                            .all(|c| matches!(c, Component::Normal(_)))
172                        && !keys.is_empty()
173                        && keys.iter().all(|key| !key.is_empty()),
174                    "question setting must name a repository file and concrete keys"
175                );
176                let root = task.repo.canonicalize()?;
177                let file = root.join(path).canonicalize()?;
178                let relative = file
179                    .strip_prefix(&root)
180                    .context("question setting is outside the repository")?;
181                let content = std::fs::read_to_string(&file)?;
182                let value: toml::Value = toml::from_str(&content)?;
183                let mut setting = &value;
184                for key in keys {
185                    setting = setting
186                        .get(key)
187                        .context("question setting key does not exist")?;
188                }
189                format!("setting:{}", serde_json::to_string(&(relative, keys))?)
190            }
191            QuestionTarget::Source { reference } => {
192                use crate::proc::Quiet as _;
193                let output = std::process::Command::new("git")
194                    .args([
195                        "rev-parse",
196                        "--verify",
197                        "--end-of-options",
198                        &format!("{reference}^{{commit}}"),
199                    ])
200                    .current_dir(&task.repo)
201                    .quiet()
202                    .output()
203                    .context("resolve question source")?;
204                anyhow::ensure!(
205                    output.status.success(),
206                    "question source cannot be resolved"
207                );
208                let commit = String::from_utf8(output.stdout)?.trim().to_owned();
209                anyhow::ensure!(
210                    !commit.is_empty() && commit.bytes().all(|c| c.is_ascii_hexdigit()),
211                    "invalid question source commit"
212                );
213                format!("source:{commit}")
214            }
215        };
216        Ok((self.operation, target))
217    }
218}
219
220/// The conductor's decision for one task. Deliberately has no `priority`
221/// field: see this module's doc.
222#[derive(Debug, Clone, Default, Deserialize)]
223pub struct Decision {
224    /// Task id, expected to be copied verbatim from what it was shown.
225    pub id: String,
226    /// Ids this task should be blocked on. Meaningful only for a runnable
227    /// (`queued`) task, or a machine-held task whose missing-only decision
228    /// is unconfirmed against the queue. Ignored otherwise.
229    #[serde(default)]
230    pub blocked_by: Vec<String>,
231    /// Dependencies claimed missing as the sole reason for this question or
232    /// hold. Must name references in `blocked_by`. Empty (including legacy
233    /// replies) means the question/hold may have an independent purpose and
234    /// must be preserved. Never set for decisions needing separate consent.
235    #[serde(default)]
236    pub missing_dependencies: Vec<String>,
237    /// One line explaining the block or the recovery.
238    #[serde(default)]
239    pub reason: Option<String>,
240    /// Recovery for a stalled or finished task. For a runnable (`queued`)
241    /// one, only [`Recovery::Hold`] has any effect. An independent hold takes
242    /// precedence over `blocked_by`, so dependency completion cannot release it.
243    /// Use a hold when the operator already said the task must not compete,
244    /// rather than asking a `question` that restates that answer. `Requeue`, `Review`,
245    /// and `Done` have no meaning for a task that is already in line.
246    #[serde(default)]
247    pub recovery: Option<Recovery>,
248    /// A question for the operator. When present, [`apply`] files it (unless
249    /// one is already open for this task) and blocks the task on its id
250    /// instead of acting on `blocked_by` or `recovery`. Only a missing-only
251    /// decision explicitly marked by [`Self::missing_dependencies`] may be
252    /// replaced with dependency waiting unless the queue confirms missing blockers.
253    #[serde(default)]
254    pub question: Option<String>,
255    /// Fixed answers for `question`, if it has any. Empty means free text.
256    #[serde(default)]
257    pub choices: Vec<String>,
258    /// Stable operation and concrete target of a question. Missing on legacy
259    /// decisions: those share the task's existing cause, regardless of prose.
260    #[serde(default)]
261    pub question_decision: Option<QuestionDecision>,
262    /// Task or question ids carrying independently verifiable new blocking evidence.
263    #[serde(default)]
264    pub evidence: Vec<String>,
265}
266
267/// The conductor's whole reply for one cycle.
268///
269/// `decisions` is deliberately **not** `#[serde(default)]`, unlike every
270/// other field in this module. [`verdict::extract_json`] disambiguates
271/// between several balanced `{...}` spans in one reply by trying the type the
272/// caller wants against each of them, last first, and keeping the first that
273/// fits - which only works when a span that is not really the answer can
274/// fail to fit. A `Verdict` with no required field at all would make every
275/// span fit, including a `{}` left by stray trailing prose, and the reply's
276/// real `decisions` - earlier in the text - would never be reached. Requiring
277/// the key costs nothing: the prompt already asks for it on every reply, `[]`
278/// included.
279#[derive(Debug, Clone, Default, Deserialize)]
280pub struct Verdict {
281    /// One entry per task the conductor chose to say something about. A task
282    /// left out of this list is left exactly as it was.
283    pub decisions: Vec<Decision>,
284}
285
286/// A view of a task built for [`prompt::conduct`], shared by the runnable and
287/// stalled sections.
288fn view(t: &Task, max_attempts: usize) -> prompt::ConductTask {
289    prompt::ConductTask {
290        id: t.id.clone(),
291        title: t.title.clone(),
292        instruction: t.instruction.clone(),
293        repo: t.repo.display().to_string(),
294        priority: t.priority,
295        status: t.status.as_str().to_owned(),
296        attempts: t.attempts,
297        max_attempts: t.effective_attempt_limit(max_attempts),
298        last_error: t.last_error.clone(),
299        hold_reason: t.hold_reason.clone(),
300        hold_source: t.hold_source.map(|source| source.label().to_owned()),
301        blocked_by: t.blocked_by.clone(),
302        answers: t
303            .answers
304            .iter()
305            .map(|a| prompt::ConductAnswer {
306                question: a.question.clone(),
307                answer: a.answer.clone(),
308            })
309            .collect(),
310        operator_resume: t.resumption.as_ref().map(|r| format!(
311            "Explicit operator resumption generation {} via {} at {}. Approved instruction: {}. \
312             The first {} historical answers and prior hold ({:?}) are superseded as waiting premises. \
313             To block again cite evidence ids from a later owned question, changed dependency, or new failed run; \
314             wording alone is insufficient.",
315            r.generation, r.source, r.at, r.instruction, r.answers_through, r.cleared_reason
316        )).or_else(|| t.resume_override.as_ref().map(|o| {
317            format!(
318                "the operator explicitly answered \"resume\" at {}; do not hold this \
319                 task again for the same reason unless there is new information",
320                o.at
321            )
322        })),
323    }
324}
325
326/// Did the operator answer a `magi ask` choice with a `resume` action that
327/// this task has not yet acted on? Then `Recovery::Requeue` must not turn the
328/// task into a fresh competition either.
329fn pinned_resume(task: &Task) -> bool {
330    task.resume_override
331        .as_ref()
332        .is_some_and(|o| o.pinned_run.is_some())
333}
334
335/// Verify references independently of the model's reason text. Old evidence
336/// cannot become new merely by being described differently.
337fn new_blocking_evidence(
338    queue: &Queue,
339    questions: &Questions,
340    task: &Task,
341    d: &Decision,
342) -> Vec<String> {
343    let Some(r) = &task.resumption else {
344        return Vec::new();
345    };
346    d.evidence
347        .iter()
348        .filter_map(|id| {
349            let version = if let Ok(q) = questions.get(id) {
350                (q.asked_at > r.at
351                    && (q.run == task.id || task.runs.contains(&q.run))
352                    && q.node != NODE
353                    && q.status != crate::ask::QuestionStatus::Abandoned)
354                    .then(|| {
355                        format!(
356                            "question:{id}:{}:{:?}:{}",
357                            q.asked_at,
358                            q.status,
359                            q.resolution().unwrap_or_default()
360                        )
361                    })
362            } else if d.blocked_by.contains(id) {
363                queue
364                    .get(id)
365                    .ok()
366                    .filter(|dep| {
367                        dep.id != task.id
368                            && dep.status != TaskStatus::Done
369                            && (dep.created_at > r.at
370                                || dep.held_at.is_some_and(|at| at > r.at)
371                                || dep.operator_decided_at.is_some_and(|at| at > r.at)
372                                || dep.passes.iter().any(|p| p.settled_at > r.at)
373                                || (dep.status == TaskStatus::Blocked
374                                    && dep.blocked_at.is_some_and(|at| at > r.at)))
375                    })
376                    .map(|dep| {
377                        format!(
378                            "dependency:{id}:{:?}:{:?}:{:?}",
379                            dep.status,
380                            dep.chat_notice_key(),
381                            dep.hold_notice_cause
382                        )
383                    })
384            } else if task.runs.iter().any(|run| run == id) {
385                RunState::load(id)
386                    .ok()
387                    .filter(|run| {
388                        // A resumed run keeps its id. Only a pass started after
389                        // approval (or a newly created legacy run) is new evidence.
390                        run.updated_at > r.at
391                            && run
392                                .passes
393                                .last()
394                                .map_or(run.created_at > r.at, |p| p.started_at > r.at)
395                            && matches!(
396                                run.status,
397                                crate::run::RunStatus::Failed
398                                    | crate::run::RunStatus::Blocked
399                                    | crate::run::RunStatus::Stalled
400                            )
401                    })
402                    .map(|run| {
403                        format!(
404                            "run:{id}:{:?}:{:?}",
405                            run.status,
406                            run.passes.last().map(|p| p.index)
407                        )
408                    })
409            } else {
410                None
411            };
412            version.filter(|v| !r.blocking_evidence.contains(v))
413        })
414        .collect()
415}
416
417/// Durable approval is enforced by verified evidence at the mutation boundary.
418/// A pinned run stays protected; legacy transient overrides keep their original
419/// bounded contradiction policy until an explicit new approval replaces it.
420fn may_hold(task: &mut Task, reason: &str) -> bool {
421    if pinned_resume(task) {
422        return false;
423    }
424    if task.resumption.is_some() {
425        return true; // The mutation boundary already verified new evidence.
426    }
427    let Some(o) = task.resume_override.as_mut() else {
428        return true;
429    };
430    if o.forced || o.pinned_run.is_some() {
431        tracing::warn!(
432            "conductor tried to hold task {} after the operator forced a resume: {reason}",
433            task.id
434        );
435        return false;
436    }
437    if o.conductor_rehold.is_some() {
438        return false;
439    }
440    o.conductor_rehold = Some(reason.to_owned());
441    true
442}
443
444/// Severity as a lowercase word, matching how `crate::verdict::Severity` is
445/// spelled everywhere else an operator or a model reads it.
446fn severity_str(s: crate::verdict::Severity) -> &'static str {
447    match s {
448        crate::verdict::Severity::Nit => "nit",
449        crate::verdict::Severity::Minor => "minor",
450        crate::verdict::Severity::Major => "major",
451        crate::verdict::Severity::Blocker => "blocker",
452    }
453}
454
455/// The branch a task's last run left behind, if the tally ever ran on it -
456/// what [`Recovery::Review`] reopens. Derived from `crate::run::RunState`
457/// alone, never from anything the conductor wrote, so a hallucinated branch
458/// name can never reach `crate::graph::Runner::review`.
459fn surviving_branch(task: &Task) -> Option<String> {
460    let last = task.runs.last()?;
461    let state = RunState::load(last).ok()?;
462    if !crate::daemon::compatible_run_instruction(&state, task) {
463        return None;
464    }
465    state.winner().map(|c| c.branch.clone())
466}
467
468/// The reason to record when the conductor holds a `failed`/`held` task via
469/// [`Recovery::Hold`]. Always `Some`, never a bare `d.reason.clone()`.
470///
471/// [`Task::hold_machine`] only overwrites [`Task::hold_reason`] when given
472/// one, precisely so a stalled task's existing diagnosis survives a
473/// decision that has nothing new to add. That is the right default when the
474/// task was not already `held` - but a task that *is* already `held`, and
475/// is being held again here, is a different case: its current
476/// `hold_reason` may still read as a cause `crate::triage` knows how to
477/// re-check on its own (a disk-pressure message, say - see
478/// `triage::machine_cause_resolved`), and if the model gives no reason,
479/// leaving that text untouched would make this decision - the conductor
480/// choosing, informed by the operator's own answer, to keep the task held
481/// anyway - indistinguishable from the original, never-reconsidered hold.
482/// `crate::triage` would then read the stale text the next time its one
483/// recognised cause looks resolved and release the task straight through
484/// the decision this call was recording. Prepending (not appending) the new
485/// note keeps the old text as context without leaving the string starting
486/// with whatever pattern `crate::triage` matched before.
487///
488/// Two bounds keep the string from growing, which it once did without limit
489/// (every re-hold nested the whole previous reason one level deeper, and every
490/// change relit the task's notice): a note equal to the current reason - or to
491/// its outermost note - adds nothing, so the result is `None` and the caller
492/// writes nothing; and only ONE `(previously: ...)` level is ever kept, the
493/// prior being cut back to its outermost note before it is embedded.
494fn reaffirmed_hold_reason(task: &Task, d: &Decision) -> Option<String> {
495    let note = match &d.reason {
496        Some(reason) => reason.clone(),
497        None => match task.answers.last() {
498            Some(a) => format!(
499                "conduct held this again with no new reason given; last operator \
500                 answer on record: {}",
501                a.answer
502            ),
503            None => "conduct held this again with no reason given".to_owned(),
504        },
505    };
506    match task.hold_reason.as_deref() {
507        Some(prior) if !prior.is_empty() => {
508            let outer = outermost_hold_note(prior);
509            if note.trim() == prior.trim() || note.trim() == outer {
510                None
511            } else {
512                Some(format!("{note}\n\n(previously: {outer})"))
513            }
514        }
515        _ => Some(note),
516    }
517}
518
519/// Separator [`reaffirmed_hold_reason`] puts between a note and the reason it
520/// replaced.
521const PREVIOUSLY: &str = "\n\n(previously: ";
522
523/// A hold reason without any `(previously: ...)` tail: the text before the
524/// first separator, trimmed.
525fn outermost_hold_note(reason: &str) -> &str {
526    reason.split(PREVIOUSLY).next().unwrap_or(reason).trim()
527}
528
529/// The conductor's stated reason for a hold, or a stand-in when it gave none.
530fn hold_note(d: &Decision) -> String {
531    d.reason
532        .clone()
533        .unwrap_or_else(|| "(no reason given)".to_owned())
534}
535
536/// Everything the conductor is shown about a `failed`/`held` task's last run.
537async fn outcome_for(task: &Task, repo: &Path) -> prompt::ConductOutcome {
538    let Some(run_id) = task.runs.last().cloned() else {
539        return prompt::ConductOutcome {
540            run_id: "(none)".to_owned(),
541            unreadable: Some("this task has not produced a run yet".to_owned()),
542            run_status: None,
543            open_findings: Vec::new(),
544            rounds_used: 0,
545            rounds_max: 0,
546            rounds: Vec::new(),
547            branch: None,
548            branch_head: None,
549            references: None,
550            empty_candidate: false,
551        };
552    };
553    let state = match RunState::load(&run_id) {
554        Ok(s) => s,
555        Err(e) => {
556            // The exact failure this feature exists to stop hiding: a schema
557            // mismatch (or any other unreadable state) must never be treated
558            // as "nothing to recover" - it is surfaced here, verbatim, rather
559            // than swallowed into a quiet re-competition.
560            tracing::warn!(
561                "conductor: could not read run {run_id} for task {}: {e:#}",
562                task.short()
563            );
564            return prompt::ConductOutcome {
565                run_id,
566                unreadable: Some(format!("{e:#}")),
567                run_status: None,
568                open_findings: Vec::new(),
569                rounds_used: 0,
570                rounds_max: 0,
571                rounds: Vec::new(),
572                branch: None,
573                branch_head: None,
574                references: None,
575                empty_candidate: false,
576            };
577        }
578    };
579
580    let finding_view = |f: &crate::verdict::Finding| prompt::ConductFinding {
581        id: f.id.clone(),
582        title: f.title.clone(),
583        severity: severity_str(f.severity).to_owned(),
584    };
585    let open_findings = state
586        .open_findings()
587        .into_iter()
588        .map(finding_view)
589        .collect();
590    let rounds = state
591        .reviews
592        .iter()
593        .map(|r| prompt::ConductRound {
594            round: r.round,
595            findings: r
596                .reviews
597                .iter()
598                .flat_map(|rec| rec.findings.iter())
599                .map(finding_view)
600                .collect(),
601            addressed: r
602                .fix
603                .as_ref()
604                .map(|fx| fx.addressed.clone())
605                .unwrap_or_default(),
606            rejected: r
607                .fix
608                .as_ref()
609                .map(|fx| {
610                    fx.rejected
611                        .iter()
612                        .map(|rej| prompt::ConductRejection {
613                            id: rej.id.clone(),
614                            why: rej.why.clone(),
615                        })
616                        .collect()
617                })
618                .unwrap_or_default(),
619        })
620        .collect();
621    let branch = state.winner().map(|c| c.branch.clone());
622    let branch_head = match &branch {
623        Some(b) => crate::git::rev_parse(repo, b)
624            .await
625            .ok()
626            .map(|h| h.chars().take(8).collect()),
627        None => None,
628    };
629
630    prompt::ConductOutcome {
631        run_id,
632        unreadable: None,
633        run_status: Some(state.status.as_str().to_owned()),
634        open_findings,
635        rounds_used: state.reviews.len(),
636        rounds_max: state.config.graph.review_rounds,
637        rounds,
638        branch,
639        branch_head,
640        references: crate::refs::describe(&state.seeds),
641        empty_candidate: state.merge.as_ref().is_some_and(|m| m.empty),
642    }
643}
644
645/// Whether the task's latest run's winner branch already has an open pull
646/// request into the run's own base - a fact the repository can answer, so the
647/// operator is never asked it. `None` when there is no run or winner to check.
648async fn open_pr_fact(task: &Task, repo: &Path) -> Option<String> {
649    let id = task.runs.last()?;
650    let state = match RunState::load(id) {
651        Ok(s) => s,
652        Err(e) => return Some(format!("could not check open pull requests: {e:#}")),
653    };
654    let branch = state.winner()?.branch.clone();
655    let base = state.base_branch.clone();
656    match crate::land::find_open_pr(repo, &branch, &base).await {
657        Ok(crate::land::OpenPr::None) => None,
658        Ok(crate::land::OpenPr::One { url, .. }) => Some(format!(
659            "pull request {url} is already open for {branch} into {base}"
660        )),
661        Ok(crate::land::OpenPr::Many(urls)) => Some(format!(
662            "several pull requests are already open for {branch} into {base}: {}",
663            urls.join(" ")
664        )),
665        Err(e) => Some(format!("could not check open pull requests: {e:#}")),
666    }
667}
668
669/// Append what the repository says about the branches and commits a task
670/// names to every question the conductor is about to file, so the operator is
671/// never asked a fact git can answer ("is it already on main?"). Done here,
672/// in code, rather than trusted to the model: a question the model wrote
673/// without checking still reaches the operator with the answer attached.
674/// When it cannot be checked the question says so instead of guessing.
675async fn attach_facts(cfg: &Config, repo: &Path, queue: &Queue, verdict: &mut Verdict) {
676    for d in verdict
677        .decisions
678        .iter_mut()
679        .filter(|d| d.question.is_some())
680    {
681        let Ok(task) = queue.get(&d.id) else {
682            continue;
683        };
684        let text = format!("{}\n{}", task.title, task.instruction);
685        let pr_fact = open_pr_fact(&task, repo_for(&task, repo).as_path()).await;
686        if crate::refs::scan(&text).is_empty() {
687            if let (Some(fact), Some(q)) = (pr_fact, d.question.as_mut()) {
688                q.push_str(&format!(
689                    "\n\nChecked against the repository (magi did this, not the model):\n{fact}"
690                ));
691            }
692            continue;
693        }
694        let repo = repo_for(&task, repo);
695        let remote = &cfg.merge.remote;
696        let base = crate::git::merge_base_branch(&repo, remote, cfg.merge.base.as_deref())
697            .await
698            .ok();
699        let facts = match base {
700            Some(base) => {
701                let base_name = base.clone();
702                let tracking = format!("{remote}/{base}");
703                // Best effort: a stale tracking ref would report work that
704                // has since landed as still missing from the base.
705                let refreshed = crate::git::fetch(&repo, remote, &base)
706                    .await
707                    .is_ok_and(|o| o.ok());
708                // Only the remote's tip counts: a local branch of the same
709                // name is whatever the checkout happens to hold.
710                let against = tracking;
711                match crate::git::rev_parse(&repo, &against).await {
712                    Ok(tip) => crate::refs::describe(
713                        &crate::refs::resolve(&repo, &tip, remote, &text).await,
714                    )
715                    .map(|facts| {
716                        if refreshed {
717                            facts
718                        } else {
719                            format!(
720                                "{facts}\n(could not fetch {remote}/{base_name}: this is \
721                                 against the last fetched `{against}`, which may be behind \
722                                 the remote)"
723                            )
724                        }
725                    }),
726                    Err(_) => Some(format!(
727                        "could NOT be checked against the repository: {against} is not \
728                         available here, so whether the work is in the base is unknown"
729                    )),
730                }
731            }
732            None => Some("could not check the repository: no base branch known".to_owned()),
733        };
734        let facts = match (facts, pr_fact) {
735            (Some(f), Some(p)) => Some(format!("{f}\n{p}")),
736            (f, p) => f.or(p),
737        };
738        if let (Some(facts), Some(q)) = (facts, d.question.as_mut()) {
739            q.push_str(&format!(
740                "\n\nChecked against the repository (magi did this, not the model):\n{facts}"
741            ));
742        }
743    }
744}
745
746/// A `failed`/`held` task together with how its last run ended.
747async fn finished_view(t: &Task, repo: &Path, max_attempts: usize) -> prompt::ConductFinished {
748    prompt::ConductFinished {
749        task: view(t, max_attempts),
750        outcome: outcome_for(t, &repo_for(t, repo)).await,
751    }
752}
753
754/// The repository containing a task's branch. A task filed without a
755/// repository uses the daemon's repository, exactly as its later attempt does.
756fn repo_for(task: &Task, fallback: &Path) -> PathBuf {
757    if task.repo.as_os_str().is_empty() || task.repo == Path::new(".") {
758        fallback.to_path_buf()
759    } else {
760        task.repo.clone()
761    }
762}
763
764/// Apply one decision to the queue and the question store.
765///
766/// Takes the task's own claim before touching it: a model call spans a whole
767/// agent turn, and the queue can have moved on by the time its answer comes
768/// back. A claim that cannot be taken means something else owns this task
769/// right now - most often a live daemon mid-competition on it - so the
770/// conductor's now-stale view of it is dropped rather than raced against; see
771/// `crate::queue::Queue::claim`'s own doc on why a claim is proof, not a
772/// guess.
773///
774/// A decision is matched against the task's *current* status, re-read under
775/// the claim, not against whichever section of the prompt it came from: a
776/// `blocked_by` only takes effect on a `queued` task, and `recovery` only on
777/// one `running` (stalled) or `failed`/`held`, so a decision that no longer
778/// matches what the task actually is - it moved on between the read that
779/// built the prompt and this write - changes nothing.
780fn apply_one(
781    queue: &Queue,
782    questions: &Questions,
783    d: &Decision,
784    snapshot: Option<&Task>,
785) -> Result<()> {
786    let _claim = queue
787        .claim(&d.id)
788        .with_context(|| format!("task {} is claimed elsewhere right now", d.id))?;
789    let mut task = queue.get(&d.id).context("no such task")?;
790
791    // Compare the actual input, not a generation echoed by the model. Even
792    // question creation must be discarded after an intervening operator edit.
793    if snapshot.is_some_and(|old| decision_input(old) != decision_input(&task)) {
794        return Ok(());
795    }
796    let blocking =
797        d.question.is_some() || !d.blocked_by.is_empty() || d.recovery == Some(Recovery::Hold);
798    let evidence = new_blocking_evidence(queue, questions, &task, d);
799    if blocking && task.resumption.is_some() && evidence.is_empty() {
800        crate::notices::raise(crate::notices::conductor_resumption(
801            &task.id,
802            task.short(),
803            d.reason.as_deref(),
804            &d.blocked_by,
805            &d.evidence,
806            d.question.is_some(),
807        ));
808        return Ok(());
809    }
810
811    if blocking && !evidence.is_empty() {
812        let mut cause = evidence.clone();
813        cause.sort();
814        cause.dedup();
815        task.notice_cause(
816            &format!("conductor-evidence:{}", cause.join(",")),
817            None,
818            None,
819        );
820    }
821    if blocking {
822        if let Some(r) = task.resumption.as_mut() {
823            r.blocking_evidence.extend(evidence);
824        }
825    }
826
827    // A conductor answer is never operator authorization.  In particular,
828    // do this before questions and blocking too: either would reclassify a
829    // manual hold and let a later deterministic resolver queue it.
830    if task.operator_held() {
831        return Ok(());
832    }
833
834    // A claimed task may have deferred the pre-scheduling triage sweep.
835    // Leave an open or answered-unapplied question to that sweep rather
836    // than move the task out from under it.
837    if task.status == TaskStatus::Held && crate::triage::pending_for(questions, &task) {
838        return Ok(());
839    }
840
841    // The operator's `resume` action is waiting for its turn: a question or a
842    // dependency would park the task before the daemon can run it.
843    let pinned = pinned_resume(&task);
844    if pinned && (d.question.is_some() || !d.blocked_by.is_empty()) {
845        return Ok(());
846    }
847
848    // A missing-only decision with unverifiable aliases is not evidence for
849    // a question or hold. Do not guess that two ambiguous/unknown references
850    // mean the same record, or treat failure to prove that as proof of absence.
851    if matches!(task.status, TaskStatus::Queued | TaskStatus::Held)
852        && !d.missing_dependencies.is_empty()
853        && (d.question.is_some() || d.recovery == Some(Recovery::Hold))
854        && d.missing_dependencies
855            .iter()
856            .any(|id| !d.blocked_by.contains(id))
857        && d.missing_dependencies
858            .iter()
859            .chain(&d.blocked_by)
860            .any(|id| {
861                matches!(
862                    crate::queue::dependency(queue, questions, id),
863                    crate::queue::Dependency::Ambiguous | crate::queue::Dependency::Unknown
864                )
865            })
866    {
867        return Ok(());
868    }
869
870    // Only an explicit missing-only claim can justify replacing a question
871    // or hold. Existing prerequisites alone say nothing about independent
872    // consent, destructive-operation questions, or other reasons to stay held.
873    // Ambiguous or unreadable inventories cannot confirm absence either: keep
874    // the original reference waiting until it can be resolved authoritatively.
875    if matches!(task.status, TaskStatus::Queued | TaskStatus::Held)
876        && !d.missing_dependencies.is_empty()
877        && d.missing_dependencies.iter().all(|id| {
878            let claim = crate::queue::dependency(queue, questions, id);
879            claim != crate::queue::Dependency::Missing
880                && d.blocked_by.iter().any(|blocker| {
881                    blocker == id
882                        || (matches!(
883                            &claim,
884                            crate::queue::Dependency::Task(_)
885                                | crate::queue::Dependency::Question(_)
886                                | crate::queue::Dependency::Deleted(_)
887                        ) && crate::queue::dependency(queue, questions, blocker) == claim)
888                })
889        })
890        && (d.question.is_some() || d.recovery == Some(Recovery::Hold))
891        && crate::queue::missing_blockers(queue, questions, &d.blocked_by).is_empty()
892    {
893        let mut ids = d.blocked_by.clone();
894        crate::queue::normalize_blockers(queue, questions, &mut ids);
895        let reason = format!("Waiting on dependencies: {}", ids.join(", "));
896        task.block(ids, Some(reason));
897        return queue.put(&mut task);
898    }
899
900    if let Some(text) = &d.question {
901        if task.status == TaskStatus::Done {
902            return Ok(());
903        }
904        let mut held = task.clone();
905        held.status = TaskStatus::Held;
906        let targets: BTreeSet<_> = d
907            .blocked_by
908            .iter()
909            .filter_map(|id| {
910                if let Ok(dep) = queue.get(id) {
911                    Some(format!("task:{id}:{:?}", dep.chat_notice_key()))
912                } else if questions.get(id).is_ok() {
913                    Some(format!("question:{id}"))
914                } else {
915                    None
916                }
917            })
918            .collect();
919        let decision = d
920            .question_decision
921            .as_ref()
922            .and_then(|q| match q.identity(&task) {
923                Ok(identity) => Some(identity),
924                Err(error) => {
925                    // Invalid optional metadata must not discard the question
926                    // or leave a runnable task without its decision barrier.
927                    tracing::warn!(task = %task.id, %error, "ignoring invalid question decision target");
928                    None
929                }
930            });
931        let context_value =
932            serde_json::json!([&task.instruction, held.chat_notice_key(), targets, decision]);
933        let context = serde_json::to_string(&context_value)?;
934        // An answer alone does not justify asking the same decision again.
935        // New producer evidence, an instruction edit or a different dependency
936        // target can open another question; rewording cannot.
937        if questions.list().iter().any(|q| {
938            q.node == NODE
939                && q.run == task.id
940                && q.status == crate::ask::QuestionStatus::Answered
941                && q.decision_context.as_ref().is_some_and(|stored| {
942                    if stored == &context {
943                        return true;
944                    }
945                    // Missing metadata cannot manufacture a new target. This
946                    // also baselines older contexts whose last item was labels.
947                    decision.is_none()
948                        && serde_json::from_str::<serde_json::Value>(stored)
949                            .ok()
950                            .and_then(|v| {
951                                v.as_array().map(|items| {
952                                    items.len() == 4
953                                        && items[..3]
954                                            == context_value.as_array().expect("context")[..3]
955                                })
956                            })
957                            == Some(true)
958                })
959        }) {
960            return Ok(());
961        }
962        // `Question::run` is a task id for conductor questions, but ordinary
963        // graph questions use it as a run id. A coincidental equality must
964        // not block this task on an answer meant for another node.
965        let question_id = match questions
966            .list()
967            .into_iter()
968            .find(|q| q.status.open() && q.node == NODE && q.run == task.id)
969        {
970            Some(existing) => existing.id,
971            None => {
972                let mut q = Question::new(
973                    task.id.clone(),
974                    NODE.to_owned(),
975                    SEAT.to_owned(),
976                    text.clone(),
977                    d.reason.clone().unwrap_or_default(),
978                    d.choices.clone(),
979                );
980                q.decision_context = Some(context);
981                // The wait is handed to a deputy (`crate::deputy`), so a
982                // free-text reply reaches an agent that knows why this was
983                // asked. The conductor still never blocks: this only records
984                // what the deputy will be told, and `serve` starts it.
985                q.deputy = Some(Deputy::new(crate::deputy::brief(
986                    &task.id,
987                    d.reason.as_deref().unwrap_or_default(),
988                    &q.choices,
989                    &q.actions,
990                )));
991                if task.repo.is_dir() && task.repo != Path::new(".") {
992                    q.cwd = Some(task.repo.to_string_lossy().into_owned());
993                }
994                questions.put(&mut q)?;
995                q.id
996            }
997        };
998        task.block(vec![question_id], d.reason.clone());
999        return queue.put(&mut task);
1000    }
1001
1002    match task.status {
1003        // See `Decision::recovery`'s doc: the only lever a runnable task has
1004        // besides `blocked_by` is holding it outright, for a task whose
1005        // answers already say it should not compete again.
1006        TaskStatus::Queued if d.recovery == Some(Recovery::Hold) => {
1007            if may_hold(&mut task, &hold_note(d)) {
1008                task.hold_machine(d.reason.clone());
1009                queue.put(&mut task)?;
1010            }
1011        }
1012        TaskStatus::Queued if !d.blocked_by.is_empty() => {
1013            let mut ids = d.blocked_by.clone();
1014            crate::queue::normalize_blockers(queue, questions, &mut ids);
1015            task.block(ids, d.reason.clone());
1016            queue.put(&mut task)?;
1017        }
1018        TaskStatus::Running => match d.recovery {
1019            Some(Recovery::Requeue) if pinned_resume(&task) => {}
1020            Some(Recovery::Requeue) => {
1021                task.requeue();
1022                queue.put(&mut task)?;
1023            }
1024            Some(Recovery::Hold) if may_hold(&mut task, &hold_note(d)) => {
1025                task.hold_machine(d.reason.clone());
1026                queue.put(&mut task)?;
1027            }
1028            // `Review` reopens a branch, which only makes sense once a run
1029            // has actually stopped; a task still `running` has nothing to
1030            // reopen yet.
1031            _ => {}
1032        },
1033        TaskStatus::Failed | TaskStatus::Held => match d.recovery {
1034            // The operator picked a specific run to continue; a fresh
1035            // competition would throw that run away.
1036            Some(Recovery::Requeue) if pinned_resume(&task) => {}
1037            Some(Recovery::Requeue) => {
1038                task.requeue();
1039                queue.put(&mut task)?;
1040            }
1041            Some(Recovery::Hold) => {
1042                // Decided before `may_hold`, which spends the one override: a
1043                // note that adds nothing writes nothing (and so raises no
1044                // notice and moves no queue revision).
1045                if let Some(reason) = reaffirmed_hold_reason(&task, d) {
1046                    if may_hold(&mut task, &hold_note(d)) {
1047                        task.hold_machine(Some(reason));
1048                        queue.put(&mut task)?;
1049                    }
1050                }
1051            }
1052            Some(Recovery::Review) => {
1053                if let Some(branch) = surviving_branch(&task) {
1054                    task.request_review(branch);
1055                    queue.put(&mut task)?;
1056                }
1057                // No survivable branch: a decision naming `review` here is
1058                // simply not actionable, and is dropped rather than guessed
1059                // at - `crate::daemon` applies the same "no branch, no
1060                // review" rule again, from its own read, right before it
1061                // would actually start the run.
1062            }
1063            Some(Recovery::Done) => {
1064                task.succeed();
1065                // Same reason `magi task done` and the web UI's equivalent
1066                // do this: the conductor closing a task by hand is just as
1067                // much a finished story as the loop's own settle path.
1068                crate::daemon::supersede_prior_runs(&task, &crate::run::home());
1069                queue.put(&mut task)?;
1070            }
1071            None => {}
1072        },
1073        // `queued` with nothing to block on, `done`, or already `blocked`:
1074        // nothing for this decision to do.
1075        _ => {}
1076    }
1077    Ok(())
1078}
1079
1080/// Apply every decision in `verdict`. A single bad decision - a task id that
1081/// no longer exists, one already claimed elsewhere - is logged and skipped
1082/// rather than losing every other decision in the same reply.
1083pub fn apply(queue: &Queue, questions: &Questions, verdict: &Verdict) -> Result<()> {
1084    for d in &verdict.decisions {
1085        if let Err(e) = apply_one(queue, questions, d, None) {
1086            tracing::warn!("conductor decision for task {}: {e:#}", d.id);
1087        }
1088    }
1089    Ok(())
1090}
1091
1092/// Where the conductor's seat is kept between turns, so the daemon waiter can
1093/// resume it when the owner answers a question it asked.
1094///
1095/// A graph seat's session lives in its run's `RunState`; the conductor has no
1096/// run, and until this file its seat existed only in memory.
1097pub fn seat_path(home: &Path) -> PathBuf {
1098    home.join("conduct").join("seat.json")
1099}
1100
1101/// The seat the conductor last used, if it has taken a turn.
1102pub fn load_seat(home: &Path) -> Option<SeatState> {
1103    serde_json::from_str(&std::fs::read_to_string(seat_path(home)).ok()?).ok()
1104}
1105
1106fn busy_path(home: &Path) -> PathBuf {
1107    home.join("conduct").join("busy")
1108}
1109
1110/// Is a conductor turn in flight right now?
1111///
1112/// A marker file rather than shared memory because the waiter reads it and the
1113/// conductor is not necessarily in its process. A marker older than one turn's
1114/// timeout (plus slack) was left by a turn that died and counts for nothing.
1115pub fn busy(home: &Path) -> bool {
1116    std::fs::metadata(busy_path(home))
1117        .and_then(|m| m.modified())
1118        .ok()
1119        .and_then(|t| t.elapsed().ok())
1120        .is_some_and(|age| age < TURN_TIMEOUT + Duration::from_secs(30))
1121}
1122
1123/// Removes the busy marker when a turn ends, however it ends.
1124struct Busy(PathBuf);
1125
1126impl Busy {
1127    fn mark(home: &Path) -> Self {
1128        let path = busy_path(home);
1129        if let Some(dir) = path.parent() {
1130            let _ = std::fs::create_dir_all(dir);
1131        }
1132        let _ = std::fs::write(&path, std::process::id().to_string());
1133        Self(path)
1134    }
1135}
1136
1137impl Drop for Busy {
1138    fn drop(&mut self) {
1139        let _ = std::fs::remove_file(&self.0);
1140    }
1141}
1142
1143/// The conductor's state across polling cycles: its own CLI-side conversation
1144/// and the last (revision, stalled ∪ finished ids) pair it actually acted on.
1145#[derive(Debug, Default)]
1146pub struct Conductor {
1147    seat: Option<SeatState>,
1148    last_seen: Option<(u64, BTreeSet<String>)>,
1149    /// Content fingerprint of each held task the model was last shown, see
1150    /// [`input_fingerprint`]. A held task whose fingerprint still matches is
1151    /// settled: the conductor's own rewrites of it do not count as news.
1152    considered: BTreeMap<String, String>,
1153}
1154
1155/// What the conductor's decision about a held task is a function of: the task
1156/// without the fields the conductor itself rewrites (`updated_at`,
1157/// `hold_reason`, the recorded re-hold). Independent of how the model words a
1158/// repeated decision.
1159fn decision_input(task: &Task) -> serde_json::Value {
1160    let mut v = serde_json::to_value(task).unwrap_or_default();
1161    if let Some(o) = v.as_object_mut() {
1162        o.remove("chat_notice");
1163        o.remove("chat_notices");
1164        // A delivery write changes only bookkeeping, not the decision input.
1165        o.remove("updated_at");
1166    }
1167    v
1168}
1169
1170fn input_fingerprint(task: &Task) -> String {
1171    let mut v = decision_input(task);
1172    if let Some(o) = v.as_object_mut() {
1173        o.remove("hold_reason");
1174        if let Some(ro) = o.get_mut("resume_override").and_then(|r| r.as_object_mut()) {
1175            ro.remove("conductor_rehold");
1176        }
1177    }
1178    v.to_string()
1179}
1180
1181impl Conductor {
1182    /// A conductor that has never run.
1183    #[must_use]
1184    pub fn new() -> Self {
1185        Self::default()
1186    }
1187
1188    /// Held tasks among `stalled` / `finished` whose input is unchanged since
1189    /// the model last saw them. Their files are left out of the revision, so
1190    /// the conductor re-holding one (however it words it) is not news, while
1191    /// an owner's answer changes the fingerprint and brings the file back in.
1192    fn settled(
1193        considered: &BTreeMap<String, String>,
1194        stalled: &[Task],
1195        finished: &[Task],
1196    ) -> BTreeSet<String> {
1197        stalled
1198            .iter()
1199            .chain(finished)
1200            .filter(|t| t.status == TaskStatus::Held)
1201            .filter(|t| considered.get(&t.id) == Some(&input_fingerprint(t)))
1202            .map(|t| t.id.clone())
1203            .collect()
1204    }
1205
1206    fn snapshot_with(
1207        considered: &BTreeMap<String, String>,
1208        queue: &Queue,
1209        stalled: &[Task],
1210        finished: &[Task],
1211    ) -> (u64, BTreeSet<String>) {
1212        let ids = stalled
1213            .iter()
1214            .chain(finished)
1215            .map(|t| t.id.clone())
1216            .collect();
1217        let skip = Self::settled(considered, stalled, finished);
1218        (queue.revision_excluding(&skip), ids)
1219    }
1220
1221    fn snapshot(
1222        &self,
1223        queue: &Queue,
1224        stalled: &[Task],
1225        finished: &[Task],
1226    ) -> (u64, BTreeSet<String>) {
1227        Self::snapshot_with(&self.considered, queue, stalled, finished)
1228    }
1229
1230    /// Whether calling the conductor could possibly do anything different
1231    /// from last time: [`Queue::revision`] moved, or the set of stalled and
1232    /// finished task ids changed.
1233    ///
1234    /// Deliberately **not** "stalled or finished is non-empty" - a task
1235    /// sitting stalled or finished with nobody changing anything about it
1236    /// must not be re-shown to the model every single poll forever; only a
1237    /// change in *which* tasks are stalled or finished, or a queue write
1238    /// changing something about a runnable one, is worth another look.
1239    ///
1240    /// Cheap and config-free on purpose, so `crate::daemon`'s poll loop can
1241    /// skip `Config::discover`'s synchronous I/O entirely on a cycle where
1242    /// this says no - which [`Conductor::maybe_run`] would otherwise only
1243    /// discover after paying for that load. Both ask the identical question,
1244    /// from the same [`Conductor::last_seen`], so they can never disagree
1245    /// about whether there is anything to look at.
1246    #[must_use]
1247    pub fn worth_a_look(&self, queue: &Queue, stalled: &[Task], finished: &[Task]) -> bool {
1248        self.last_seen.as_ref() != Some(&self.snapshot(queue, stalled, finished))
1249    }
1250
1251    /// Call the conductor once, unless nothing has changed since the last
1252    /// time it was worth calling - see [`Conductor::worth_a_look`], the exact
1253    /// same test. Never fatal - see this module's doc.
1254    #[allow(clippy::too_many_arguments)]
1255    pub async fn maybe_run(
1256        &mut self,
1257        cfg: &Config,
1258        repo: &Path,
1259        queue: &Queue,
1260        questions: &Questions,
1261        home: &Path,
1262        queued: &[Task],
1263        stalled: &[Task],
1264        finished: &[Task],
1265        max_attempts: usize,
1266    ) {
1267        if self.last_seen.as_ref() == Some(&self.snapshot(queue, stalled, finished)) {
1268            return;
1269        }
1270        // What the model is about to be shown becomes "considered" before the
1271        // snapshot is taken, so its own re-hold writes land in the excluded set.
1272        self.considered = stalled
1273            .iter()
1274            .chain(finished)
1275            .filter(|t| t.status == TaskStatus::Held)
1276            .map(|t| (t.id.clone(), input_fingerprint(t)))
1277            .collect();
1278        self.last_seen = Some(self.snapshot(queue, stalled, finished));
1279        if let Err(e) = self
1280            .run_once(
1281                cfg,
1282                repo,
1283                queue,
1284                questions,
1285                home,
1286                queued,
1287                stalled,
1288                finished,
1289                max_attempts,
1290            )
1291            .await
1292        {
1293            // The conductor's own write moves the revision, so one more cycle
1294            // follows; a repeat decision writes nothing (see
1295            // `reaffirmed_hold_reason`), which is what ends the loop. The
1296            // revision is deliberately never re-read here: it cannot tell our
1297            // write from an owner's concurrent update.
1298            tracing::warn!("conductor: {e:#}");
1299        }
1300    }
1301
1302    #[allow(clippy::too_many_arguments)]
1303    async fn run_once(
1304        &mut self,
1305        cfg: &Config,
1306        repo: &Path,
1307        queue: &Queue,
1308        questions: &Questions,
1309        home: &Path,
1310        queued: &[Task],
1311        stalled: &[Task],
1312        finished: &[Task],
1313        max_attempts: usize,
1314    ) -> Result<()> {
1315        if queued.is_empty() && stalled.is_empty() && finished.is_empty() {
1316            return Ok(());
1317        }
1318
1319        let primary = cfg
1320            .resolve_roles()
1321            .context("resolving the conductor seat")?
1322            .conductor;
1323        // A string keeps today's resolution exactly (no CLI preflight here;
1324        // an unavailable CLI fails at invocation). Only a multi-id array
1325        // builds a chain, whose members are each tried at most once.
1326        let chain = match cfg.roles.conductor.as_ref() {
1327            Some(c) if c.ids().len() > 1 => {
1328                agent::pick_chain(&cfg.agents, Some(c), &agent::installed, "conductor")?
1329            }
1330            _ => vec![primary],
1331        };
1332
1333        let runnable_views: Vec<prompt::ConductTask> =
1334            queued.iter().map(|t| view(t, max_attempts)).collect();
1335        let stalled_views: Vec<prompt::ConductTask> =
1336            stalled.iter().map(|t| view(t, max_attempts)).collect();
1337        let mut finished_views = Vec::with_capacity(finished.len());
1338        for t in finished {
1339            finished_views.push(finished_view(t, repo, max_attempts).await);
1340        }
1341
1342        let body = prompt::with_overlay(
1343            prompt::conduct(
1344                &runnable_views,
1345                &stalled_views,
1346                &finished_views,
1347                &cfg.graph.language,
1348            ),
1349            cfg.prompts.overlay(NODE),
1350        );
1351
1352        let body = format!(
1353            "{body}\n\n{}",
1354            crate::queue::dependency_facts(queue, questions)
1355        );
1356        let artifacts = home.join("conduct").join("artifacts");
1357        // Bound to a local: `Invocation` only borrows the cache path, and the
1358        // `Option<PathBuf>` `cache_dir()` returns has to outlive that borrow.
1359        let cache_dir = cfg.cache_dir();
1360
1361        // One marker for the whole chain: a fallback is still this turn.
1362        let busy = Busy::mark(home);
1363        let mut result: Result<Verdict> = Err(anyhow::anyhow!("no conductor agent ran"));
1364        for spec in &chain {
1365            // The prompt carries the whole queue state every cycle, so a
1366            // fallback agent needs no history - but its seat is its own.
1367            let needs_new_seat = !matches!(&self.seat, Some(s) if s.agent == spec.id);
1368            if needs_new_seat {
1369                self.seat = Some(SeatState::new(SEAT, &spec.id, crate::rng::entropy()));
1370            }
1371            let seat = self.seat.as_mut().expect("just ensured a seat exists");
1372            // The first agent keeps the plain stem; a fallback's own artifacts
1373            // must not overwrite it.
1374            let stem = if std::ptr::eq(spec, &chain[0]) {
1375                format!("turn-{}", seat.turns + 1)
1376            } else {
1377                format!("turn-{}-{}", seat.turns + 1, spec.id)
1378            };
1379            let inv = Invocation {
1380                coordination: cfg.coordination.as_ref(),
1381                cwd: repo,
1382                prompt: &body,
1383                timeout: TURN_TIMEOUT,
1384                // The conductor never edits anything - it only decides what
1385                // blocks a task and what to do about one stuck or finished.
1386                allow_write: false,
1387                unsandboxed: false,
1388                sessions: cfg.graph.sessions,
1389                artifacts: &artifacts,
1390                stem: &stem,
1391                run: NODE,
1392                node: NODE,
1393                cache_dir: cache_dir.as_deref(),
1394                attachments: &[],
1395                writable: &[],
1396            };
1397            let out = agent::invoke(spec, seat, &inv).await;
1398            // Kept after every turn, failed or not: the CLI-side conversation is
1399            // what a resumed question needs, and it exists once the turn ran.
1400            if let Ok(body) = serde_json::to_string(&*seat) {
1401                let path = seat_path(home);
1402                if let Some(dir) = path.parent() {
1403                    let _ = std::fs::create_dir_all(dir);
1404                }
1405                let _ = std::fs::write(path, body);
1406            }
1407            let advance = agent::chain_advances(&out);
1408            result = match out {
1409                Err(e) => Err(e.context("invoking the conductor")),
1410                Ok(out) if advance => Err(anyhow::anyhow!(
1411                    "no usable reply (exit {:?}, timed out {})",
1412                    out.exit_code,
1413                    out.timed_out
1414                )),
1415                Ok(out) => verdict::extract_json(&out.text)
1416                    .context("the conductor's reply could not be parsed"),
1417            };
1418            if result.is_ok() {
1419                break;
1420            }
1421            if chain.len() > 1 {
1422                tracing::warn!("conductor: `{}` failed, trying the next agent", spec.id);
1423            }
1424        }
1425        drop(busy);
1426        let mut verdict = result?;
1427        attach_facts(cfg, repo, queue, &mut verdict).await;
1428        for d in &verdict.decisions {
1429            let snapshot = queued
1430                .iter()
1431                .chain(stalled)
1432                .chain(finished)
1433                .find(|t| t.id == d.id);
1434            if let Some(snapshot) = snapshot {
1435                if let Err(e) = apply_one(queue, questions, d, Some(snapshot)) {
1436                    tracing::warn!("conductor decision for task {}: {e:#}", d.id);
1437                }
1438            }
1439        }
1440        Ok(())
1441    }
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446    use std::collections::BTreeMap;
1447
1448    use tempfile::tempdir;
1449
1450    use super::*;
1451    use crate::ask::{Answer, QuestionStatus};
1452    use crate::config::{AgentKind, AgentSpec, Graph};
1453    use crate::queue::Source;
1454
1455    fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
1456        let path = dir.join("mock-conduct-agent.sh");
1457        std::fs::write(&path, script).expect("write mock");
1458        AgentSpec {
1459            id: "mock".to_owned(),
1460            kind: AgentKind::Command,
1461            model: None,
1462            command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
1463            extra_args: Vec::new(),
1464            env,
1465            prompt_delivery: None,
1466        }
1467    }
1468
1469    fn config(spec: AgentSpec) -> Config {
1470        Config {
1471            agents: vec![spec],
1472            graph: Graph {
1473                language: "en".to_owned(),
1474                ..Graph::default()
1475            },
1476            ..Config::default()
1477        }
1478    }
1479
1480    fn task(title: &str) -> Task {
1481        Task::new(
1482            title.to_owned(),
1483            format!("do {title}"),
1484            std::path::PathBuf::from("."),
1485            Source::Human,
1486        )
1487    }
1488
1489    #[test]
1490    fn delivery_bookkeeping_does_not_restart_conductor_deliberation() {
1491        let mut t = task("waiting");
1492        t.hold_machine(Some("waiting for recovery".into()));
1493        let before = input_fingerprint(&t);
1494        let snapshot = decision_input(&t);
1495        t.chat_notice = Some(crate::queue::ChatNotice {
1496            key: t.chat_notice_key().unwrap(),
1497            at: jiff::Timestamp::now(),
1498            outcome: crate::queue::ChatNoticeOutcome::Posted,
1499        });
1500        t.chat_notices.push(t.chat_notice_key().unwrap());
1501        t.updated_at = jiff::Timestamp::now();
1502        assert_eq!(before, input_fingerprint(&t));
1503        assert_eq!(snapshot, decision_input(&t));
1504        t.hold_reason = Some("recovery remains under consideration".into());
1505        assert_eq!(before, input_fingerprint(&t));
1506        t.record_answer("a different question".into(), "a new decision".into());
1507        assert_ne!(before, input_fingerprint(&t));
1508    }
1509
1510    const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
1511    const GARBAGE: &str = "#!/bin/sh\ncat >/dev/null\nprintf 'not json at all\\n'\n";
1512
1513    fn env(reply: &str) -> BTreeMap<String, String> {
1514        BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
1515    }
1516
1517    const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
1518
1519    /// A throwaway repo with one commit on `main` and a second branch ahead
1520    /// of it, so `outcome_for`'s own `git::rev_parse` call has a real head to
1521    /// resolve.
1522    fn init_repo_with_branch(dir: &Path, branch: &str) {
1523        use crate::proc::Quiet as _;
1524        let run = |args: &[&str]| {
1525            let out = std::process::Command::new("git")
1526                .args(args)
1527                .current_dir(dir)
1528                .quiet()
1529                .output()
1530                .expect("spawn git");
1531            assert!(
1532                out.status.success(),
1533                "git {args:?} failed: {}",
1534                String::from_utf8_lossy(&out.stderr)
1535            );
1536        };
1537        run(&["init", "-b", "main"]);
1538        run(&["config", "user.name", "magi test"]);
1539        run(&["config", "user.email", "magi@example.com"]);
1540        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
1541        run(&["add", "-A"]);
1542        run(&["commit", "-m", "init"]);
1543        run(&["checkout", "-b", branch]);
1544        std::fs::write(dir.join("change.txt"), "x\n").unwrap();
1545        run(&["add", "-A"]);
1546        run(&["commit", "-m", "candidate work"]);
1547    }
1548
1549    fn review_round_with_finding(
1550        round: usize,
1551        finding_id: &str,
1552        title: &str,
1553        addressed: &[&str],
1554        rejected: &[(&str, &str)],
1555    ) -> crate::run::ReviewRound {
1556        crate::run::ReviewRound {
1557            round,
1558            head: "deadbeef".to_owned(),
1559            verified_head: None,
1560            verified_at: None,
1561            reviews: vec![crate::run::ReviewRecord {
1562                attempts: 0,
1563                reviewer: 1,
1564                agent: "mock".to_owned(),
1565                summary: String::new(),
1566                findings: vec![crate::verdict::Finding {
1567                    id: finding_id.to_owned(),
1568                    severity: crate::verdict::Severity::Major,
1569                    file: None,
1570                    line: None,
1571                    title: title.to_owned(),
1572                    detail: String::new(),
1573                }],
1574                vote: None,
1575                failed: None,
1576                duration_ms: 0,
1577            }],
1578            e2e: Vec::new(),
1579            verify_retried: false,
1580            e2e_deferred: false,
1581            e2e_defer_reason: None,
1582            fix: Some(crate::run::FixRecord {
1583                agent: "mock".to_owned(),
1584                addressed: addressed.iter().map(|s| (*s).to_owned()).collect(),
1585                rejected: rejected
1586                    .iter()
1587                    .map(|(id, why)| crate::verdict::Rejection {
1588                        id: (*id).to_owned(),
1589                        why: (*why).to_owned(),
1590                    })
1591                    .collect(),
1592                notes: String::new(),
1593                committed: false,
1594                failed: None,
1595                duration_ms: 0,
1596                continuation: None,
1597            }),
1598            blocking: 1,
1599            answered: 1,
1600            expected: 1,
1601            clean: false,
1602            progressed: true,
1603            vote_split: false,
1604            reconsideration: Vec::new(),
1605            verdict: None,
1606        }
1607    }
1608
1609    #[test]
1610    fn explicit_resumption_supersedes_history_and_rejects_stale_decisions() {
1611        crate::run::pin_test_home();
1612        let dir = tempdir().unwrap();
1613        let queue = Queue::at(dir.path().join("queue"));
1614        let questions = Questions::at(dir.path().join("questions"));
1615        let mut t = task("recover");
1616        t.answers.push(crate::queue::AnsweredQuestion {
1617            question: "Inspect remote first?".into(),
1618            answer: "Inspect, then await resumption instructions".into(),
1619        });
1620        t.hold_machine(Some("waiting for remote inspection".into()));
1621        queue.put(&mut t).unwrap();
1622        let snapshot = queue.get(&t.id).unwrap();
1623        t.edit(
1624            "recover".into(),
1625            "Reuse the measured PR head; fix Clippy; review".into(),
1626        )
1627        .unwrap();
1628        t.release_operator("cli", None);
1629        queue.put(&mut t).unwrap();
1630        let approval = t.resumption.clone();
1631        let d = Decision {
1632            id: t.id.clone(),
1633            recovery: Some(Recovery::Hold),
1634            reason: Some("remote inspection still required".into()),
1635            ..Decision::default()
1636        };
1637        apply_one(&queue, &questions, &d, Some(&snapshot)).unwrap();
1638        let stale_question = Decision {
1639            question: Some("Inspect remote?".into()),
1640            ..d.clone()
1641        };
1642        apply_one(&queue, &questions, &stale_question, Some(&snapshot)).unwrap();
1643        assert!(questions.list().is_empty());
1644        for reason in [
1645            "waiting for remote inspection",
1646            "inspection has not been confirmed",
1647        ] {
1648            let repeated = Decision {
1649                reason: Some(reason.into()),
1650                ..d.clone()
1651            };
1652            apply(
1653                &queue,
1654                &questions,
1655                &Verdict {
1656                    decisions: vec![repeated],
1657                },
1658            )
1659            .unwrap();
1660            assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
1661        }
1662        let reloaded = Queue::at(dir.path().join("queue")).get(&t.id).unwrap();
1663        assert_eq!(reloaded.resumption, approval);
1664        assert_eq!(reloaded.answers, t.answers);
1665        let v = view(&reloaded, 3);
1666        assert!(v.operator_resume.unwrap().contains("superseded"));
1667        let mut dep = task("new prerequisite");
1668        dep.hold_machine(Some("new authentication failure".into()));
1669        queue.put(&mut dep).unwrap();
1670        let new_blocker = Decision {
1671            blocked_by: vec![dep.id.clone()],
1672            evidence: vec![dep.id.clone()],
1673            // Exercise dependency waiting, without inheriting a separate hold.
1674            recovery: None,
1675            reason: Some("new prerequisite failed".into()),
1676            ..d.clone()
1677        };
1678        apply(
1679            &queue,
1680            &questions,
1681            &Verdict {
1682                decisions: vec![new_blocker.clone()],
1683            },
1684        )
1685        .unwrap();
1686        let mut after = queue.get(&t.id).unwrap();
1687        assert_eq!(after.status, TaskStatus::Blocked);
1688        // Automatic release does not grant fresh operator authority or make
1689        // the same unchanged evidence reusable.
1690        after.release();
1691        queue.put(&mut after).unwrap();
1692        apply(
1693            &queue,
1694            &questions,
1695            &Verdict {
1696                decisions: vec![new_blocker],
1697            },
1698        )
1699        .unwrap();
1700        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
1701        after.start("new-run".into());
1702        assert_eq!(after.resumption.as_ref().unwrap().generation, 1);
1703        after.hold_manual(Some("operator says wait".into()));
1704        queue.put(&mut after).unwrap();
1705        apply(&queue, &questions, &Verdict { decisions: vec![d] }).unwrap();
1706        assert!(queue.get(&t.id).unwrap().operator_held());
1707    }
1708
1709    #[test]
1710    fn newly_blocked_dependency_is_material_evidence_after_approval() {
1711        let dir = tempdir().unwrap();
1712        let queue = Queue::at(dir.path().join("queue"));
1713        let questions = Questions::at(dir.path().join("questions"));
1714        let mut dep = task("old queued dependency");
1715        queue.put(&mut dep).unwrap();
1716        let mut t = task("approved task");
1717        t.release_operator("test", None);
1718        queue.put(&mut t).unwrap();
1719        let mut q = Question::new(
1720            dep.id.clone(),
1721            "check".into(),
1722            "check".into(),
1723            "New dependency approval".into(),
1724            String::new(),
1725            Vec::new(),
1726        );
1727        questions.put(&mut q).unwrap();
1728        dep.block(vec![q.id.clone()], Some("new decision needed".into()));
1729        let changed = dep.blocked_at;
1730        queue.put(&mut dep).unwrap();
1731        dep.block(vec![q.id], Some("same decision paraphrased".into()));
1732        assert_eq!(
1733            dep.blocked_at, changed,
1734            "prose is not new blocking evidence"
1735        );
1736        queue.put(&mut dep).unwrap();
1737        let d = Decision {
1738            id: t.id.clone(),
1739            blocked_by: vec![dep.id.clone()],
1740            evidence: vec![dep.id],
1741            ..Decision::default()
1742        };
1743        assert_eq!(new_blocking_evidence(&queue, &questions, &t, &d).len(), 1);
1744        apply_one(&queue, &questions, &d, None).unwrap();
1745        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
1746    }
1747
1748    #[test]
1749    fn resumed_run_evidence_uses_approval_time_not_run_list_offsets() {
1750        crate::run::pin_test_home();
1751        let dir = tempdir().unwrap();
1752        let queue = Queue::at(dir.path().join("queue"));
1753        let questions = Questions::at(dir.path().join("questions"));
1754        let mut t = task("resume evidence");
1755        let mut run = RunState::new(
1756            t.repo.clone(),
1757            "main".into(),
1758            "base".into(),
1759            "work".into(),
1760            crate::config::Config::default(),
1761        );
1762        run.begin_pass();
1763        run.status = crate::run::RunStatus::Failed;
1764        run.save().unwrap();
1765        t.start(run.id.clone());
1766        // Model legacy duplicate history, which the next start will deduplicate.
1767        t.runs.push(run.id.clone());
1768        t.release_operator("cli", None);
1769        let approval = t.resumption.as_ref().unwrap().at;
1770        let decision = Decision {
1771            evidence: vec![run.id.clone()],
1772            ..Decision::default()
1773        };
1774        assert!(new_blocking_evidence(&queue, &questions, &t, &decision).is_empty());
1775        t.start(run.id.clone());
1776        assert_eq!(t.runs.len(), 1);
1777        run.begin_pass();
1778        run.save().unwrap();
1779        assert!(run.passes.last().unwrap().started_at > approval);
1780        let evidence = new_blocking_evidence(&queue, &questions, &t, &decision);
1781        assert_eq!(evidence.len(), 1);
1782        t.resumption.as_mut().unwrap().blocking_evidence = evidence;
1783        assert!(new_blocking_evidence(&queue, &questions, &t, &decision).is_empty());
1784        run.begin_pass();
1785        run.save().unwrap();
1786        assert_eq!(
1787            new_blocking_evidence(&queue, &questions, &t, &decision).len(),
1788            1
1789        );
1790
1791        let mut q = Question::new(
1792            run.id.clone(),
1793            "review".into(),
1794            "check".into(),
1795            "New failure".into(),
1796            String::new(),
1797            Vec::new(),
1798        );
1799        questions.put(&mut q).unwrap();
1800        let decision = Decision {
1801            evidence: vec![q.id.clone()],
1802            ..Decision::default()
1803        };
1804        assert_eq!(
1805            new_blocking_evidence(&queue, &questions, &t, &decision).len(),
1806            1
1807        );
1808        q.asked_at = approval;
1809        questions.put(&mut q).unwrap();
1810        assert!(new_blocking_evidence(&queue, &questions, &t, &decision).is_empty());
1811        q.asked_at = jiff::Timestamp::now();
1812        q.run = "unrelated".into();
1813        questions.put(&mut q).unwrap();
1814        assert!(new_blocking_evidence(&queue, &questions, &t, &decision).is_empty());
1815
1816        let mut fresh = RunState::new(
1817            t.repo.clone(),
1818            "main".into(),
1819            "base".into(),
1820            "fresh work".into(),
1821            crate::config::Config::default(),
1822        );
1823        fresh.status = crate::run::RunStatus::Failed;
1824        fresh.save().unwrap();
1825        t.start(fresh.id.clone());
1826        let decision = Decision {
1827            evidence: vec![fresh.id.clone()],
1828            ..Decision::default()
1829        };
1830        assert_eq!(
1831            new_blocking_evidence(&queue, &questions, &t, &decision).len(),
1832            1
1833        );
1834    }
1835
1836    #[test]
1837    fn outcome_for_carries_every_rounds_findings_and_the_branch_head() {
1838        crate::run::pin_test_home();
1839        let dir = tempdir().unwrap();
1840        let default_repo = dir.path().join("default");
1841        let task_repo = dir.path().join("task");
1842        std::fs::create_dir_all(&default_repo).unwrap();
1843        std::fs::create_dir_all(&task_repo).unwrap();
1844        init_repo_with_branch(&default_repo, "other-branch");
1845        init_repo_with_branch(&task_repo, "magi/f00d/A");
1846
1847        let mut config = Config::default();
1848        config.graph.review_rounds = 6;
1849        let mut state = crate::run::RunState::new(
1850            task_repo.clone(),
1851            "main".to_owned(),
1852            "deadbeef".to_owned(),
1853            "task".to_owned(),
1854            config,
1855        );
1856        state.status = crate::run::RunStatus::Blocked;
1857        state.candidates.push(crate::run::Candidate {
1858            index: 0,
1859            label: 'A',
1860            agent: "mock".to_owned(),
1861            branch: "magi/f00d/A".to_owned(),
1862            worktree: task_repo.clone(),
1863            summary: String::new(),
1864            stat: String::new(),
1865            files: 1,
1866            commits: 1,
1867            empty: false,
1868            failed: None,
1869            verified_noop: None,
1870            duration_ms: 0,
1871            folded: false,
1872        });
1873        state.tally = Some(crate::run::Tally {
1874            first_choice: std::collections::BTreeMap::new(),
1875            borda: std::collections::BTreeMap::new(),
1876            winner: 'A',
1877            rankings: 0,
1878            unanimous_initial: false,
1879            deliberated: false,
1880            changed_votes: 0,
1881            unanimous_final: false,
1882            tie_break: None,
1883            judges: 0,
1884            present: 0,
1885            quorum: 0,
1886            met_quorum: true,
1887            uncontested: Some("solo".to_owned()),
1888        });
1889        state.reviews = vec![
1890            review_round_with_finding(
1891                1,
1892                "R1-1-2",
1893                "answer content is dropped",
1894                &[],
1895                &[("R1-1-2", "the id leaving blocked_by is enough")],
1896            ),
1897            review_round_with_finding(2, "R2-1-3", "answer content is still dropped", &[], &[]),
1898        ];
1899        state.save().unwrap();
1900
1901        let mut t = task("outcome test");
1902        t.repo = task_repo;
1903        t.runs.push(state.id.clone());
1904
1905        let finished = tokio_test_block_on(finished_view(&t, &default_repo, 2));
1906        let outcome = finished.outcome;
1907
1908        assert!(outcome.unreadable.is_none());
1909        assert_eq!(outcome.run_status.as_deref(), Some("blocked"));
1910        assert_eq!(outcome.rounds_used, 2);
1911        assert_eq!(outcome.rounds_max, 6);
1912        assert_eq!(outcome.rounds.len(), 2);
1913        assert_eq!(outcome.rounds[0].findings[0].id, "R1-1-2");
1914        assert_eq!(outcome.rounds[0].rejected[0].id, "R1-1-2");
1915        assert!(outcome.rounds[1].addressed.is_empty());
1916        assert!(outcome.rounds[1].rejected.is_empty());
1917        assert_eq!(outcome.branch.as_deref(), Some("magi/f00d/A"));
1918        assert!(
1919            outcome.branch_head.is_some(),
1920            "a real branch must resolve a head commit: {outcome:?}"
1921        );
1922    }
1923
1924    /// A tiny single-threaded block-on, so an `async fn` can be exercised
1925    /// from a plain `#[test]` without pulling `tokio::test`'s multi-thread
1926    /// runtime into a test that does no other async work.
1927    fn tokio_test_block_on<F: std::future::Future>(f: F) -> F::Output {
1928        tokio::runtime::Builder::new_current_thread()
1929            .enable_all()
1930            .build()
1931            .unwrap()
1932            .block_on(f)
1933    }
1934
1935    #[test]
1936    fn view_resolves_task_attempt_limit_before_daemon_default() {
1937        let mut t = task("budget");
1938        assert_eq!(view(&t, 7).max_attempts, 7);
1939        t.change_attempt_limit(std::num::NonZeroUsize::new(3))
1940            .unwrap();
1941        assert_eq!(view(&t, 7).max_attempts, 3);
1942        t.change_attempt_limit(None).unwrap();
1943        assert_eq!(view(&t, 7).max_attempts, 7);
1944    }
1945
1946    #[test]
1947    fn view_carries_a_tasks_recorded_answers_into_the_conductor_prompt_input() {
1948        let mut t = task("answered");
1949        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
1950        let v = view(&t, 2);
1951        assert_eq!(v.answers.len(), 1);
1952        assert_eq!(v.answers[0].question, "Which backend?");
1953        assert_eq!(v.answers[0].answer, "SQLite");
1954    }
1955
1956    #[tokio::test]
1957    async fn dependency_resolution_rechecks_questions_in_the_production_conductor_path() {
1958        for held in [false, true] {
1959            for reference in ["ca5b", "20261013-193611-ca5b"] {
1960                for question in [false, true] {
1961                    let dir = tempdir().unwrap();
1962                    let queue = Queue::at(dir.path().join("queue"));
1963                    let questions = Questions::at(dir.path().join("questions"));
1964                    let mut dep = task("prerequisite");
1965                    dep.id = "20261013-193611-ca5b".into();
1966                    dep.start("existing-run".into());
1967                    queue.put(&mut dep).unwrap();
1968                    let mut waiting = task("waiting");
1969                    if held {
1970                        waiting.hold_machine(Some("dependency could not be located".into()));
1971                    }
1972                    queue.put(&mut waiting).unwrap();
1973                    let missing_reference = if reference == "ca5b" {
1974                        dep.id.as_str()
1975                    } else {
1976                        "ca5b"
1977                    };
1978                    let reply = serde_json::json!({"decisions": [{
1979                        "id": waiting.id, "blocked_by": [reference],
1980                        "missing_dependencies": [missing_reference],
1981                        "question": question.then_some("ca5b is missing; where is its replacement?"),
1982                        "recovery": (!question).then_some("hold"),
1983                        "reason": "ca5b is missing"
1984                    }]}).to_string();
1985                    let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
1986                    let (queued, finished) = if held {
1987                        (vec![], vec![waiting.clone()])
1988                    } else {
1989                        (vec![waiting.clone()], vec![])
1990                    };
1991                    Conductor::new()
1992                        .run_once(
1993                            &cfg,
1994                            dir.path(),
1995                            &queue,
1996                            &questions,
1997                            dir.path(),
1998                            &queued,
1999                            &[],
2000                            &finished,
2001                            2,
2002                        )
2003                        .await
2004                        .unwrap();
2005                    let after = queue.get(&waiting.id).unwrap();
2006                    assert_eq!(after.status, TaskStatus::Blocked);
2007                    assert_eq!(after.blocked_by, [dep.id]);
2008                    assert_eq!(after.blocked_from, Some(waiting.status));
2009                    assert!(!after.block_reason.unwrap().contains("missing"));
2010                    assert!(questions.list().is_empty());
2011                    assert_eq!(after.instruction, waiting.instruction);
2012                }
2013            }
2014        }
2015    }
2016
2017    #[tokio::test]
2018    async fn dependency_resolution_inconclusive_missing_only_decisions_keep_waiting() {
2019        for held in [false, true] {
2020            for unknown in [false, true] {
2021                for question in [false, true] {
2022                    let dir = tempdir().unwrap();
2023                    let queue = Queue::at(dir.path().join("queue"));
2024                    let questions = Questions::at(dir.path().join("questions"));
2025                    let mut dep = task("running prerequisite");
2026                    dep.id = "20261013-193611-ca5b".into();
2027                    dep.start("existing-run".into());
2028                    queue.put(&mut dep).unwrap();
2029                    let reference = if unknown {
2030                        // Even an exact, existing task id is Unknown when
2031                        // the question inventory cannot be scanned.
2032                        std::fs::write(questions.root(), "not a directory").unwrap();
2033                        dep.id.clone()
2034                    } else {
2035                        let mut other = task("another prerequisite");
2036                        other.id = "20261014-193611-ca5b".into();
2037                        queue.put(&mut other).unwrap();
2038                        "ca5b".to_owned()
2039                    };
2040                    assert_eq!(
2041                        crate::queue::dependency(&queue, &questions, &reference),
2042                        if unknown {
2043                            crate::queue::Dependency::Unknown
2044                        } else {
2045                            crate::queue::Dependency::Ambiguous
2046                        }
2047                    );
2048                    let mut waiting = task("waiting");
2049                    if held {
2050                        waiting.hold_machine(Some("dependency could not be located".into()));
2051                    }
2052                    queue.put(&mut waiting).unwrap();
2053                    let reply = serde_json::json!({"decisions": [{
2054                        "id": waiting.id, "blocked_by": [reference],
2055                        "missing_dependencies": [reference],
2056                        "question": question.then_some("ca5b is missing"),
2057                        "recovery": (!question).then_some("hold"),
2058                        "reason": "ca5b is missing"
2059                    }]})
2060                    .to_string();
2061                    let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
2062                    let (queued, finished) = if held {
2063                        (vec![], vec![waiting.clone()])
2064                    } else {
2065                        (vec![waiting.clone()], vec![])
2066                    };
2067                    Conductor::new()
2068                        .run_once(
2069                            &cfg,
2070                            dir.path(),
2071                            &queue,
2072                            &questions,
2073                            dir.path(),
2074                            &queued,
2075                            &[],
2076                            &finished,
2077                            2,
2078                        )
2079                        .await
2080                        .unwrap();
2081                    let after = queue.get(&waiting.id).unwrap();
2082                    assert_eq!(after.status, TaskStatus::Blocked);
2083                    assert_eq!(after.blocked_by, [reference]);
2084                    assert_eq!(after.blocked_from, Some(waiting.status));
2085                    assert!(!after.block_reason.unwrap().contains("missing"));
2086                    assert_eq!(after.instruction, waiting.instruction);
2087                    if unknown {
2088                        assert!(questions.root().is_file());
2089                    } else {
2090                        assert!(questions.list().is_empty());
2091                    }
2092                }
2093            }
2094        }
2095    }
2096
2097    #[tokio::test]
2098    async fn dependency_resolution_unverifiable_alias_decisions_are_discarded() {
2099        for held in [false, true] {
2100            for unknown in [false, true] {
2101                for question in [false, true] {
2102                    for short_blocker in [false, true] {
2103                        let dir = tempdir().unwrap();
2104                        let queue = Queue::at(dir.path().join("queue"));
2105                        let questions = Questions::at(dir.path().join("questions"));
2106                        let mut dep = task("running prerequisite");
2107                        dep.id = "20261013-193611-ca5b".into();
2108                        dep.start("existing-run".into());
2109                        queue.put(&mut dep).unwrap();
2110                        if unknown {
2111                            std::fs::write(questions.root(), "not a directory").unwrap();
2112                        } else {
2113                            let mut other = task("another prerequisite");
2114                            other.id = "20261014-193611-ca5b".into();
2115                            queue.put(&mut other).unwrap();
2116                        }
2117                        let (blocker, missing) = if short_blocker {
2118                            ("ca5b", dep.id.as_str())
2119                        } else {
2120                            (dep.id.as_str(), "ca5b")
2121                        };
2122                        let mut waiting = task("waiting");
2123                        if held {
2124                            waiting.hold_machine(Some("dependency could not be located".into()));
2125                        }
2126                        queue.put(&mut waiting).unwrap();
2127                        let reply = serde_json::json!({"decisions": [{
2128                            "id": waiting.id, "blocked_by": [blocker],
2129                            "missing_dependencies": [missing],
2130                            "question": question.then_some("ca5b is missing"),
2131                            "recovery": (!question).then_some("hold"),
2132                            "reason": "ca5b is missing"
2133                        }]})
2134                        .to_string();
2135                        let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
2136                        let (queued, finished) = if held {
2137                            (vec![], vec![waiting.clone()])
2138                        } else {
2139                            (vec![waiting.clone()], vec![])
2140                        };
2141                        Conductor::new()
2142                            .run_once(
2143                                &cfg,
2144                                dir.path(),
2145                                &queue,
2146                                &questions,
2147                                dir.path(),
2148                                &queued,
2149                                &[],
2150                                &finished,
2151                                2,
2152                            )
2153                            .await
2154                            .unwrap();
2155                        assert_eq!(
2156                            serde_json::to_value(queue.get(&waiting.id).unwrap()).unwrap(),
2157                            serde_json::to_value(&waiting).unwrap(),
2158                            "unverifiable aliases must leave the entire task unchanged"
2159                        );
2160                        if unknown {
2161                            assert!(questions.root().is_file());
2162                        } else {
2163                            assert!(questions.list().is_empty());
2164                        }
2165                    }
2166                }
2167            }
2168        }
2169    }
2170
2171    #[tokio::test]
2172    async fn dependency_resolution_genuine_missing_reference_still_asks() {
2173        let dir = tempdir().unwrap();
2174        let queue = Queue::at(dir.path().join("queue"));
2175        let questions = Questions::at(dir.path().join("questions"));
2176        let mut waiting = task("missing prerequisite");
2177        queue.put(&mut waiting).unwrap();
2178        let reply = serde_json::json!({"decisions": [{
2179            "id": waiting.id, "blocked_by": ["not-present"],
2180            "missing_dependencies": ["not-present"],
2181            "question": "Which prerequisite replaces the missing task?"
2182        }]})
2183        .to_string();
2184        let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
2185        Conductor::new()
2186            .run_once(
2187                &cfg,
2188                dir.path(),
2189                &queue,
2190                &questions,
2191                dir.path(),
2192                &[waiting.clone()],
2193                &[],
2194                &[],
2195                2,
2196            )
2197            .await
2198            .unwrap();
2199        let after = queue.get(&waiting.id).unwrap();
2200        assert_eq!(after.status, TaskStatus::Blocked);
2201        assert_eq!(
2202            questions.get(&after.blocked_by[0]).unwrap().summary,
2203            "Which prerequisite replaces the missing task?"
2204        );
2205    }
2206
2207    #[tokio::test]
2208    async fn dependency_resolution_keeps_decisions_during_unrelated_task_progress() {
2209        let dir = tempdir().unwrap();
2210        let queue = Queue::at(dir.path().join("queue"));
2211        let questions = Questions::at(dir.path().join("questions"));
2212        let mut dep = task("prerequisite");
2213        dep.id = "20261013-193611-ca5b".into();
2214        dep.start("existing-run".into());
2215        queue.put(&mut dep).unwrap();
2216        let mut unrelated = task("unrelated work");
2217        unrelated.start("other-run".into());
2218        queue.put(&mut unrelated).unwrap();
2219        // Make the old and new revision distinguishable without relying on
2220        // the filesystem clock advancing while the mock process starts.
2221        std::fs::File::options()
2222            .write(true)
2223            .open(queue.path_of(&unrelated.id))
2224            .unwrap()
2225            .set_times(std::fs::FileTimes::new().set_modified(std::time::UNIX_EPOCH))
2226            .unwrap();
2227        unrelated.succeed();
2228        let mut waiting = task("waiting");
2229        let mut asking = task("needs a product decision");
2230        let mut holding = task("must stay held");
2231        queue.put(&mut waiting).unwrap();
2232        queue.put(&mut asking).unwrap();
2233        queue.put(&mut holding).unwrap();
2234        let reply = serde_json::json!({"decisions": [
2235            {"id": waiting.id, "blocked_by": ["ca5b"], "reason": "wait for implementation"},
2236            {"id": asking.id, "question": "Which behavior do you want?"},
2237            {"id": holding.id, "recovery": "hold", "reason": "already implemented elsewhere"}
2238        ]})
2239        .to_string();
2240        let mut vars = env(&reply);
2241        vars.insert(
2242            "UNRELATED_PATH".into(),
2243            queue.path_of(&unrelated.id).to_string_lossy().into_owned(),
2244        );
2245        vars.insert(
2246            "UNRELATED_RECORD".into(),
2247            serde_json::to_string(&unrelated).unwrap(),
2248        );
2249        let script = r#"#!/bin/sh
2250cat >/dev/null
2251printf '%s' "$UNRELATED_RECORD" > "$UNRELATED_PATH"
2252printf '%s\n' "$MOCK_REPLY"
2253"#;
2254        let cfg = config(mock_agent(dir.path(), script, vars));
2255        Conductor::new()
2256            .run_once(
2257                &cfg,
2258                dir.path(),
2259                &queue,
2260                &questions,
2261                dir.path(),
2262                &[waiting.clone(), asking.clone(), holding.clone()],
2263                &[],
2264                &[],
2265                2,
2266            )
2267            .await
2268            .unwrap();
2269        assert_eq!(queue.get(&unrelated.id).unwrap().status, TaskStatus::Done);
2270        let after = queue.get(&waiting.id).unwrap();
2271        assert_eq!(after.status, TaskStatus::Blocked);
2272        assert_eq!(after.blocked_by, [dep.id]);
2273        let asked = queue.get(&asking.id).unwrap();
2274        assert_eq!(asked.status, TaskStatus::Blocked);
2275        assert_eq!(
2276            questions.get(&asked.blocked_by[0]).unwrap().summary,
2277            "Which behavior do you want?"
2278        );
2279        assert_eq!(queue.get(&holding.id).unwrap().status, TaskStatus::Held);
2280    }
2281
2282    #[test]
2283    fn a_dependency_decision_blocks_the_task_and_leaves_priority_alone() {
2284        let dir = tempdir().unwrap();
2285        let queue = Queue::at(dir.path().join("queue"));
2286        let questions = Questions::at(dir.path().join("questions"));
2287        let mut a = task("a");
2288        a.priority = 9;
2289        queue.put(&mut a).unwrap();
2290
2291        let verdict = Verdict {
2292            decisions: vec![Decision {
2293                id: a.id.clone(),
2294                blocked_by: vec!["20260101-000000-dead".to_owned()],
2295                reason: Some("waits on the other task".to_owned()),
2296                recovery: None,
2297                question: None,
2298                choices: Vec::new(),
2299                question_decision: None,
2300                evidence: Vec::new(),
2301                missing_dependencies: Vec::new(),
2302            }],
2303        };
2304        apply(&queue, &questions, &verdict).unwrap();
2305
2306        let back = queue.get(&a.id).unwrap();
2307        assert_eq!(back.status, TaskStatus::Blocked);
2308        assert_eq!(back.blocked_by, ["20260101-000000-dead"]);
2309        assert_eq!(
2310            back.priority, 9,
2311            "the conductor's reply cannot carry priority"
2312        );
2313    }
2314
2315    #[test]
2316    fn a_question_decision_files_one_and_blocks_on_its_id() {
2317        let dir = tempdir().unwrap();
2318        let queue = Queue::at(dir.path().join("queue"));
2319        let questions = Questions::at(dir.path().join("questions"));
2320        let mut t = task("ambiguous");
2321        queue.put(&mut t).unwrap();
2322
2323        let verdict = Verdict {
2324            decisions: vec![Decision {
2325                id: t.id.clone(),
2326                blocked_by: Vec::new(),
2327                reason: Some("which backend?".to_owned()),
2328                recovery: None,
2329                question: Some("Which storage backend?".to_owned()),
2330                choices: vec!["SQLite".to_owned(), "Redis".to_owned()],
2331                question_decision: None,
2332                evidence: Vec::new(),
2333                missing_dependencies: Vec::new(),
2334            }],
2335        };
2336        apply(&queue, &questions, &verdict).unwrap();
2337
2338        let back = queue.get(&t.id).unwrap();
2339        assert_eq!(back.status, TaskStatus::Blocked);
2340        assert_eq!(back.blocked_by.len(), 1);
2341        let q = questions.get(&back.blocked_by[0]).unwrap();
2342        assert_eq!(q.summary, "Which storage backend?");
2343        assert_eq!(q.node, NODE);
2344        assert!(q.status.open());
2345    }
2346
2347    #[test]
2348    fn a_task_with_an_open_question_already_reuses_it_rather_than_filing_a_second_one() {
2349        let dir = tempdir().unwrap();
2350        let queue = Queue::at(dir.path().join("queue"));
2351        let questions = Questions::at(dir.path().join("questions"));
2352        let mut t = task("asked once");
2353        queue.put(&mut t).unwrap();
2354
2355        let decision = Decision {
2356            id: t.id.clone(),
2357            reason: Some("still deciding".to_owned()),
2358            question: Some("Which backend?".to_owned()),
2359            ..Decision::default()
2360        };
2361        apply(
2362            &queue,
2363            &questions,
2364            &Verdict {
2365                decisions: vec![decision.clone()],
2366            },
2367        )
2368        .unwrap();
2369        assert_eq!(questions.list().len(), 1);
2370        let first_question_id = queue.get(&t.id).unwrap().blocked_by[0].clone();
2371
2372        // An operator releasing the blocked task by hand, without answering,
2373        // puts it back at `Queued` while the question stays open - exactly
2374        // the case the guard in `apply_one` exists for: a later cycle
2375        // proposing the very same question must reuse it, not file a second.
2376        let mut released = queue.get(&t.id).unwrap();
2377        released.release();
2378        queue.put(&mut released).unwrap();
2379
2380        apply(
2381            &queue,
2382            &questions,
2383            &Verdict {
2384                decisions: vec![decision],
2385            },
2386        )
2387        .unwrap();
2388        assert_eq!(questions.list().len(), 1, "no duplicate question was filed");
2389        let after = queue.get(&t.id).unwrap();
2390        assert_eq!(
2391            after.blocked_by,
2392            [first_question_id],
2393            "the existing open question is reused, not replaced"
2394        );
2395    }
2396
2397    #[test]
2398    fn a_same_id_question_from_another_node_is_not_reused() {
2399        let dir = tempdir().unwrap();
2400        let queue = Queue::at(dir.path().join("queue"));
2401        let questions = Questions::at(dir.path().join("questions"));
2402        let mut t = task("must ask the conductor");
2403        queue.put(&mut t).unwrap();
2404
2405        let mut unrelated = Question::new(
2406            t.id.clone(),
2407            "review".to_owned(),
2408            "reviewer-1".to_owned(),
2409            "An unrelated review question".to_owned(),
2410            String::new(),
2411            Vec::new(),
2412        );
2413        questions.put(&mut unrelated).unwrap();
2414
2415        apply(
2416            &queue,
2417            &questions,
2418            &Verdict {
2419                decisions: vec![Decision {
2420                    id: t.id.clone(),
2421                    question: Some("Which backend?".to_owned()),
2422                    ..Decision::default()
2423                }],
2424            },
2425        )
2426        .unwrap();
2427
2428        let blocked_by = &queue.get(&t.id).unwrap().blocked_by;
2429        assert_eq!(blocked_by.len(), 1);
2430        assert_ne!(blocked_by[0], unrelated.id);
2431        assert!(questions.get(&unrelated.id).unwrap().status.open());
2432        assert_eq!(questions.get(&blocked_by[0]).unwrap().node, NODE);
2433    }
2434
2435    #[test]
2436    fn answering_the_question_lets_the_resolver_clear_the_block_with_the_answer_kept() {
2437        let dir = tempdir().unwrap();
2438        let queue = Queue::at(dir.path().join("queue"));
2439        let questions = Questions::at(dir.path().join("questions"));
2440        let mut t = task("waits on an answer");
2441        queue.put(&mut t).unwrap();
2442
2443        apply(
2444            &queue,
2445            &questions,
2446            &Verdict {
2447                decisions: vec![Decision {
2448                    id: t.id.clone(),
2449                    blocked_by: Vec::new(),
2450                    reason: None,
2451                    recovery: None,
2452                    question: Some("Which backend?".to_owned()),
2453                    choices: Vec::new(),
2454                    question_decision: None,
2455                    evidence: Vec::new(),
2456                    missing_dependencies: Vec::new(),
2457                }],
2458            },
2459        )
2460        .unwrap();
2461        let blocked = queue.get(&t.id).unwrap();
2462        let question_id = blocked.blocked_by[0].clone();
2463
2464        let mut q = questions.get(&question_id).unwrap();
2465        q.answer(Answer::Text("SQLite".to_owned())).unwrap();
2466        questions.put(&mut q).unwrap();
2467        assert_eq!(q.status, QuestionStatus::Answered);
2468
2469        // `crate::daemon::resolve_blockers` is the deterministic resolver
2470        // that actually does this on the real queue; here it is enough to
2471        // prove the pure steps it is built from behave together.
2472        let mut task_after = queue.get(&t.id).unwrap();
2473        task_after.record_answer(q.summary.clone(), "SQLite".to_owned());
2474        task_after.unblock(&question_id);
2475        assert_eq!(task_after.status, TaskStatus::Queued);
2476        assert_eq!(task_after.answers[0].answer, "SQLite");
2477    }
2478
2479    #[test]
2480    fn a_stalled_task_can_be_requeued_or_held() {
2481        let dir = tempdir().unwrap();
2482        let queue = Queue::at(dir.path().join("queue"));
2483        let questions = Questions::at(dir.path().join("questions"));
2484
2485        let mut requeue_me = task("stuck a");
2486        requeue_me.start("run-1".to_owned());
2487        queue.put(&mut requeue_me).unwrap();
2488
2489        let mut hold_me = task("stuck b");
2490        hold_me.start("run-2".to_owned());
2491        queue.put(&mut hold_me).unwrap();
2492
2493        apply(
2494            &queue,
2495            &questions,
2496            &Verdict {
2497                decisions: vec![
2498                    Decision {
2499                        id: requeue_me.id.clone(),
2500                        recovery: Some(Recovery::Requeue),
2501                        ..Decision::default()
2502                    },
2503                    Decision {
2504                        id: hold_me.id.clone(),
2505                        recovery: Some(Recovery::Hold),
2506                        reason: Some("looks broken".to_owned()),
2507                        ..Decision::default()
2508                    },
2509                ],
2510            },
2511        )
2512        .unwrap();
2513
2514        let requeued = queue.get(&requeue_me.id).unwrap();
2515        assert_eq!(requeued.status, TaskStatus::Queued);
2516        assert_eq!(requeued.attempts, 0);
2517
2518        let held = queue.get(&hold_me.id).unwrap();
2519        assert_eq!(held.status, TaskStatus::Held);
2520        assert_eq!(held.hold_reason.as_deref(), Some("looks broken"));
2521    }
2522
2523    #[test]
2524    fn a_machine_held_task_asked_about_restores_to_held_once_answered() {
2525        // Reproduces the reported bug: a task held after burning its
2526        // attempts, once the conductor asks a follow-up question about it,
2527        // must come back `held` - not `queued` - once the question is
2528        // answered, whatever the answer said.
2529        let dir = tempdir().unwrap();
2530        let queue = Queue::at(dir.path().join("queue"));
2531        let questions = Questions::at(dir.path().join("questions"));
2532        let mut t = task("held out of attempts");
2533        t.hold_machine(Some("out of attempts".to_owned()));
2534        queue.put(&mut t).unwrap();
2535
2536        apply(
2537            &queue,
2538            &questions,
2539            &Verdict {
2540                decisions: vec![Decision {
2541                    id: t.id.clone(),
2542                    reason: Some("what should happen to this one?".to_owned()),
2543                    question: Some("Hold it, or try again?".to_owned()),
2544                    ..Decision::default()
2545                }],
2546            },
2547        )
2548        .unwrap();
2549        let blocked = queue.get(&t.id).unwrap();
2550        assert_eq!(blocked.status, TaskStatus::Blocked);
2551        let question_id = blocked.blocked_by[0].clone();
2552
2553        let mut q = questions.get(&question_id).unwrap();
2554        q.answer(Answer::Text("leave it held".to_owned())).unwrap();
2555        questions.put(&mut q).unwrap();
2556
2557        // What `crate::daemon::resolve_blockers` does on the real queue.
2558        let mut after = queue.get(&t.id).unwrap();
2559        after.record_answer(q.summary.clone(), "leave it held".to_owned());
2560        after.unblock(&question_id);
2561        assert_eq!(
2562            after.status,
2563            TaskStatus::Held,
2564            "must not fall back to queued"
2565        );
2566        assert_eq!(after.hold_reason.as_deref(), Some("out of attempts"));
2567    }
2568
2569    #[test]
2570    fn a_reaffirmed_hold_with_no_new_reason_is_not_silently_auto_released_by_triage() {
2571        // A different path to the same bug round 1 fixed: a task
2572        // machine-held for disk pressure, blocked on a conductor question,
2573        // answered "keep it held", and restored to `held`. If the
2574        // conductor's next decision reconfirms `recovery: hold` with no new
2575        // `reason` - allowed, `Decision::reason` is optional - `hold_machine`
2576        // must not silently leave the stale disk-pressure text in place, or
2577        // `crate::triage::run_once` reads it as an unexamined, auto-resolvable
2578        // hold and releases the task straight through the operator's answer
2579        // the instant disk space looks fine again.
2580        let dir = tempdir().unwrap();
2581        let queue = Queue::at(dir.path().join("queue"));
2582        let questions = Questions::at(dir.path().join("questions"));
2583        let mut t = task("disk pressure, then reconsidered");
2584        t.hold_machine(Some(
2585            "not enough free space to start a run: 10 bytes free, 100 required by \
2586             `[disk] min_free_bytes`"
2587                .to_owned(),
2588        ));
2589        t.record_answer(
2590            "How should this be handled?".to_owned(),
2591            "keep it held, a human will look at it later".to_owned(),
2592        );
2593        queue.put(&mut t).unwrap();
2594
2595        apply(
2596            &queue,
2597            &questions,
2598            &Verdict {
2599                decisions: vec![Decision {
2600                    id: t.id.clone(),
2601                    recovery: Some(Recovery::Hold),
2602                    ..Decision::default()
2603                }],
2604            },
2605        )
2606        .unwrap();
2607
2608        let after = queue.get(&t.id).unwrap();
2609        assert_eq!(after.status, TaskStatus::Held);
2610        assert!(
2611            !after
2612                .hold_reason
2613                .as_deref()
2614                .unwrap_or_default()
2615                .starts_with("not enough free space"),
2616            "the stale disk-pressure text must not survive a reconfirmed hold: {:?}",
2617            after.hold_reason
2618        );
2619
2620        // The disk gate would report space is fine now - `crate::triage`
2621        // must not read the old text and release the task through it.
2622        let cfg_dir = tempdir().unwrap();
2623        let config = cfg_dir.path().join("magi.toml");
2624        std::fs::write(&config, "[disk]\nmin_free_bytes = 0\n").unwrap();
2625        let report =
2626            crate::triage::run_once(&queue, &questions, Some(&config), jiff::Timestamp::now());
2627        assert!(report.resumed.is_empty(), "must not be auto-released");
2628        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Held);
2629    }
2630
2631    #[test]
2632    fn explicit_resumption_rehold_requires_new_evidence_and_another_answer() {
2633        crate::run::pin_test_home();
2634        let dir = tempdir().unwrap();
2635        let queue = Queue::at(dir.path().join("queue"));
2636        let questions = Questions::at(dir.path().join("questions"));
2637        let config = dir.path().join("magi.toml");
2638        std::fs::write(&config, "[disk]\nmin_free_bytes = 0\n").unwrap();
2639        let now = jiff::Timestamp::now();
2640        let triage = || crate::triage::run_once(&queue, &questions, Some(&config), now);
2641        let open = || {
2642            questions
2643                .list()
2644                .into_iter()
2645                .filter(|q| q.node == "triage" && q.status.open())
2646                .collect::<Vec<_>>()
2647        };
2648        let hold = Decision {
2649            recovery: Some(Recovery::Hold),
2650            reason: Some("waiting on manual worktree cleanup".to_owned()),
2651            ..Decision::default()
2652        };
2653
2654        let mut t = task("looping hold");
2655        t.hold_machine(Some("waiting on manual worktree cleanup".to_owned()));
2656        queue.put(&mut t).unwrap();
2657        let hold = Decision {
2658            id: t.id.clone(),
2659            ..hold
2660        };
2661
2662        // 1. triage asks the ordinary machine question; the operator resumes.
2663        assert_eq!(triage().asked.len(), 1);
2664        let first = open().remove(0);
2665        let mut q = questions.get(&first.id).unwrap();
2666        let resume = q.choices[0].clone();
2667        q.answer(Answer::Choice(resume)).unwrap();
2668        questions.put(&mut q).unwrap();
2669        assert_eq!(triage().answered.len(), 1);
2670        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
2671
2672        // Rewording the old cause cannot undo the first explicit answer.
2673        apply(
2674            &queue,
2675            &questions,
2676            &Verdict {
2677                decisions: vec![hold.clone()],
2678            },
2679        )
2680        .unwrap();
2681        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
2682        assert!(triage().asked.is_empty());
2683
2684        // A new independently filed check can justify a later hold.
2685        let mut evidence = Question::new(
2686            t.id.clone(),
2687            "preflight".into(),
2688            "check".into(),
2689            "New permission failure".into(),
2690            String::new(),
2691            Vec::new(),
2692        );
2693        questions.put(&mut evidence).unwrap();
2694        let hold = Decision {
2695            evidence: vec![evidence.id.clone()],
2696            reason: Some("new permission check failed".into()),
2697            ..hold
2698        };
2699        apply(
2700            &queue,
2701            &questions,
2702            &Verdict {
2703                decisions: vec![hold.clone()],
2704            },
2705        )
2706        .unwrap();
2707        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Held);
2708
2709        // The new hold earns one question identifying the new evidence.
2710        assert_eq!(triage().asked.len(), 1);
2711        let second = open().remove(0);
2712        assert!(second.detail.contains("new permission check failed"));
2713        assert!(triage().asked.is_empty(), "no duplicate question");
2714        assert_eq!(open().len(), 1);
2715
2716        // Another explicit answer supersedes this later hold too.
2717        let mut q = questions.get(&second.id).unwrap();
2718        let force = q.choices[0].clone();
2719        q.answer(Answer::Choice(force)).unwrap();
2720        questions.put(&mut q).unwrap();
2721        assert_eq!(triage().answered.len(), 1);
2722        apply(
2723            &queue,
2724            &questions,
2725            &Verdict {
2726                decisions: vec![hold],
2727            },
2728        )
2729        .unwrap();
2730        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
2731    }
2732
2733    #[test]
2734    fn conductor_resumption_notice_does_not_solicit_unregistered_questions() {
2735        crate::run::pin_test_home();
2736        let dir = tempdir().unwrap();
2737        let queue = Queue::at(dir.path().join("queue"));
2738        let questions = Questions::at(dir.path().join("questions"));
2739
2740        let mut t = task("task with explicit resumption");
2741        t.status = TaskStatus::Queued;
2742        t.resumption = Some(crate::queue::Resumption {
2743            generation: 1,
2744            source: "test".into(),
2745            question_id: Some("q1".into()),
2746            at: jiff::Timestamp::now(),
2747            instruction: "resume".into(),
2748            answers_through: 0,
2749            runs_through: 0,
2750            cleared_reason: None,
2751            blocking_evidence: Vec::new(),
2752        });
2753        queue.put(&mut t).unwrap();
2754
2755        // Conductor suggests a question and dependencies without new evidence
2756        let decision = Decision {
2757            id: t.id.clone(),
2758            question: Some("How should we proceed with this task?".into()),
2759            blocked_by: vec!["dep-x".into()],
2760            reason: Some("needs clarification".into()),
2761            recovery: Some(Recovery::Hold),
2762            ..Decision::default()
2763        };
2764
2765        apply(
2766            &queue,
2767            &questions,
2768            &Verdict {
2769                decisions: vec![decision],
2770            },
2771        )
2772        .unwrap();
2773
2774        // Task stays Queued (not re-blocked on old evidence)
2775        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
2776        // No question was filed in the questions store
2777        assert!(questions.list().is_empty());
2778
2779        // A notice was filed with human-readable text and task link
2780        let notices = crate::notices::Notices::open();
2781        let notice = notices
2782            .get(&crate::notices::id_of(&format!(
2783                "conductor-resumption:{}",
2784                t.id
2785            )))
2786            .expect("conductor-resumption notice filed");
2787
2788        assert_eq!(
2789            notice.link,
2790            Some(crate::notices::Link::Task { id: t.id.clone() })
2791        );
2792        // Message does NOT contain the question prose to avoid asking for non-existent answers
2793        assert!(!notice.message.contains("How should we proceed"));
2794        // Message explicitly notes that no reply is required
2795        assert!(
2796            notice
2797                .message
2798                .contains("No reply to this notice is needed.")
2799        );
2800        // No raw Rust debug representations
2801        assert!(!notice.message.contains("Some("));
2802        assert!(!notice.message.contains("None"));
2803        assert!(!notice.message.contains("[]"));
2804        assert!(!notice.message.contains("{:?}"));
2805        // Mention proposed dependencies cleanly
2806        assert!(notice.message.contains("Proposed dependencies: dep-x."));
2807    }
2808
2809    #[test]
2810    fn a_runnable_task_can_be_held_directly_without_a_question() {
2811        let dir = tempdir().unwrap();
2812        let queue = Queue::at(dir.path().join("queue"));
2813        let questions = Questions::at(dir.path().join("questions"));
2814        let mut t = task("already answered, should stay put");
2815        queue.put(&mut t).unwrap();
2816
2817        apply(
2818            &queue,
2819            &questions,
2820            &Verdict {
2821                decisions: vec![Decision {
2822                    id: t.id.clone(),
2823                    recovery: Some(Recovery::Hold),
2824                    reason: Some("operator already said keep this held".to_owned()),
2825                    ..Decision::default()
2826                }],
2827            },
2828        )
2829        .unwrap();
2830
2831        let after = queue.get(&t.id).unwrap();
2832        assert_eq!(after.status, TaskStatus::Held);
2833        assert_eq!(after.hold_source, Some(crate::queue::HoldSource::Machine));
2834    }
2835
2836    #[test]
2837    fn done_recovery_closes_a_held_task_whose_goal_is_already_met() {
2838        crate::run::pin_test_home();
2839        // Reproduces the other half of the reported bug (task 6081): once
2840        // the operator's answer says the work already happened outside the
2841        // loop - PR merged, worktree cleaned up - the conductor needs an
2842        // actual terminal state to put the task in, not just a hold it will
2843        // keep being re-asked about.
2844        let dir = tempdir().unwrap();
2845        let queue = Queue::at(dir.path().join("queue"));
2846        let questions = Questions::at(dir.path().join("questions"));
2847        let mut t = task("already merged by hand");
2848        t.hold_machine(Some("branch survived, awaiting a decision".to_owned()));
2849        t.record_answer(
2850            "Handle this one?".to_owned(),
2851            "already merged and cleaned up, close it".to_owned(),
2852        );
2853        queue.put(&mut t).unwrap();
2854
2855        apply(
2856            &queue,
2857            &questions,
2858            &Verdict {
2859                decisions: vec![Decision {
2860                    id: t.id.clone(),
2861                    recovery: Some(Recovery::Done),
2862                    reason: Some("operator confirmed this already landed".to_owned()),
2863                    ..Decision::default()
2864                }],
2865            },
2866        )
2867        .unwrap();
2868
2869        let after = queue.get(&t.id).unwrap();
2870        assert_eq!(after.status, TaskStatus::Done);
2871        assert!(after.hold_reason.is_none());
2872        assert_eq!(after.answers.len(), 1, "the record of why is kept");
2873    }
2874
2875    #[test]
2876    fn done_recovery_is_ignored_for_a_runnable_or_running_task() {
2877        let dir = tempdir().unwrap();
2878        let queue = Queue::at(dir.path().join("queue"));
2879        let questions = Questions::at(dir.path().join("questions"));
2880
2881        let mut queued = task("never ran yet");
2882        queue.put(&mut queued).unwrap();
2883
2884        let mut running = task("mid-run");
2885        running.start("run-1".to_owned());
2886        queue.put(&mut running).unwrap();
2887
2888        for id in [queued.id.clone(), running.id.clone()] {
2889            apply(
2890                &queue,
2891                &questions,
2892                &Verdict {
2893                    decisions: vec![Decision {
2894                        id,
2895                        recovery: Some(Recovery::Done),
2896                        ..Decision::default()
2897                    }],
2898                },
2899            )
2900            .unwrap();
2901        }
2902
2903        assert_eq!(queue.get(&queued.id).unwrap().status, TaskStatus::Queued);
2904        assert_eq!(queue.get(&running.id).unwrap().status, TaskStatus::Running);
2905    }
2906
2907    #[test]
2908    fn a_question_after_two_settled_answers_is_still_filed_and_blocks() {
2909        // A genuine question must stay answerable however many were settled
2910        // before it: refusing it left `magi answer --list` empty.
2911        let dir = tempdir().unwrap();
2912        let queue = Queue::at(dir.path().join("queue"));
2913        let questions = Questions::at(dir.path().join("questions"));
2914        let mut t = task("asked about repeatedly");
2915        t.hold_machine(Some("out of attempts".to_owned()));
2916        t.record_answer("Handle this one? (1)".to_owned(), "not yet".to_owned());
2917        t.record_answer(
2918            "Handle this one? (2)".to_owned(),
2919            "still not yet".to_owned(),
2920        );
2921        queue.put(&mut t).unwrap();
2922        assert_eq!(questions.list().len(), 0);
2923
2924        let verdict = Verdict {
2925            decisions: vec![Decision {
2926                id: t.id.clone(),
2927                question: Some("Branch conflicts with origin/main, how do we proceed?".to_owned()),
2928                ..Decision::default()
2929            }],
2930        };
2931        apply(&queue, &questions, &verdict).unwrap();
2932
2933        let filed = questions.list();
2934        assert_eq!(filed.len(), 1, "the question was filed");
2935        assert_eq!(
2936            filed[0].summary,
2937            "Branch conflicts with origin/main, how do we proceed?"
2938        );
2939        let deputy = filed[0]
2940            .deputy
2941            .as_ref()
2942            .expect("the wait is handed to a deputy");
2943        assert!(deputy.brief.contains(&t.id), "{}", deputy.brief);
2944        assert_eq!(
2945            deputy.starts, 0,
2946            "filing never starts anything: the loop does not wait"
2947        );
2948        assert!(filed[0].status.open());
2949        assert_eq!(filed[0].node, NODE);
2950        let after = queue.get(&t.id).unwrap();
2951        assert_eq!(after.status, TaskStatus::Blocked);
2952        assert_eq!(after.blocked_by, vec![filed[0].id.clone()]);
2953        assert_eq!(after.answers.len(), 2, "the prior answers are untouched");
2954        assert!(
2955            !after
2956                .hold_reason
2957                .clone()
2958                .unwrap_or_default()
2959                .contains("conduct tried to ask"),
2960            "no hold was applied"
2961        );
2962
2963        apply(&queue, &questions, &verdict).unwrap();
2964        let again = questions.list();
2965        assert_eq!(again.len(), 1, "the open question is reused");
2966        assert_eq!(again[0].id, filed[0].id);
2967    }
2968
2969    #[test]
2970    fn question_source_identity_resolves_aliases_and_rejects_invented_targets() {
2971        let dir = tempdir().unwrap();
2972        init_repo_with_branch(dir.path(), "source-a");
2973        let mut t = task("source decision");
2974        t.repo = dir.path().into();
2975        let decision = |reference: &str| QuestionDecision {
2976            operation: QuestionOperation::Recover,
2977            target: QuestionTarget::Source {
2978                reference: reference.into(),
2979            },
2980        };
2981        assert_eq!(
2982            decision("source-a").identity(&t).unwrap(),
2983            decision("refs/heads/source-a").identity(&t).unwrap()
2984        );
2985        assert_ne!(
2986            decision("source-a").identity(&t).unwrap(),
2987            decision("main").identity(&t).unwrap()
2988        );
2989        assert!(decision("invented-source-label").identity(&t).is_err());
2990        let unrelated = QuestionDecision {
2991            operation: QuestionOperation::Resume,
2992            target: QuestionTarget::Run {
2993                id: "unrelated-run".into(),
2994            },
2995        };
2996        assert!(unrelated.identity(&t).is_err());
2997    }
2998
2999    #[test]
3000    fn invalid_question_targets_still_file_and_block_queued_tasks() {
3001        let dir = tempdir().unwrap();
3002        init_repo_with_branch(dir.path(), "source-a");
3003        let queue = Queue::at(dir.path().join("queue"));
3004        let questions = Questions::at(dir.path().join("questions"));
3005        for target in [
3006            QuestionTarget::Source {
3007                reference: "missing-source".into(),
3008            },
3009            QuestionTarget::Run {
3010                id: "unrelated-run".into(),
3011            },
3012        ] {
3013            let mut t = task("decision with invalid metadata");
3014            t.repo = dir.path().into();
3015            queue.put(&mut t).unwrap();
3016            let d = Decision {
3017                id: t.id.clone(),
3018                question: Some("Which recovery should proceed?".into()),
3019                choices: vec!["inspect first".into(), "keep waiting".into()],
3020                question_decision: Some(QuestionDecision {
3021                    operation: QuestionOperation::Recover,
3022                    target,
3023                }),
3024                ..Decision::default()
3025            };
3026            apply_one(&queue, &questions, &d, None).unwrap();
3027            let q = questions
3028                .list()
3029                .into_iter()
3030                .find(|q| q.run == t.id)
3031                .unwrap();
3032            assert_eq!(q.summary, d.question.as_deref().unwrap());
3033            assert_eq!(q.choices, d.choices);
3034            assert!(q.status.open());
3035            let context: serde_json::Value =
3036                serde_json::from_str(q.decision_context.as_ref().unwrap()).unwrap();
3037            assert!(context[3].is_null(), "invalid metadata was discarded");
3038            let after = queue.get(&t.id).unwrap();
3039            assert_eq!(after.status, TaskStatus::Blocked);
3040            assert_eq!(after.blocked_by, vec![q.id.clone()]);
3041            apply_one(&queue, &questions, &d, None).unwrap();
3042            assert_eq!(questions.list().iter().filter(|q| q.run == t.id).count(), 1);
3043        }
3044    }
3045
3046    #[test]
3047    fn answered_conductor_question_needs_changed_facts_before_reasking() {
3048        let dir = tempdir().unwrap();
3049        let queue = Queue::at(dir.path().join("queue"));
3050        let questions = Questions::at(dir.path().join("questions"));
3051        let mut t = task("recovery decision");
3052        t.hold_machine(Some("out of attempts".into()));
3053        t.runs = vec!["source-run-a".into(), "source-run-b".into()];
3054        queue.put(&mut t).unwrap();
3055        let mut d = Decision {
3056            id: t.id.clone(),
3057            question: Some("Recover from source A?".into()),
3058            choices: vec!["approve source A".into(), "keep held".into()],
3059            question_decision: Some(QuestionDecision {
3060                operation: QuestionOperation::Recover,
3061                target: QuestionTarget::Run {
3062                    id: "source-run-a".into(),
3063                },
3064            }),
3065            ..Decision::default()
3066        };
3067        apply_one(&queue, &questions, &d, None).unwrap();
3068        let mut q = questions.list().pop().unwrap();
3069        q.answer(crate::ask::Answer::Choice("keep held".into()))
3070            .unwrap();
3071        questions.put(&mut q).unwrap();
3072        queue
3073            .modify(&t.id, |task| {
3074                task.unblock(&q.id);
3075                task.hold_machine(Some("recovery remains under consideration".into()));
3076                true
3077            })
3078            .unwrap();
3079        d.question = Some("Should we keep waiting on recovery?".into());
3080        d.choices = vec!["recover using source A".into(), "remain on hold".into()];
3081        apply_one(&queue, &questions, &d, None).unwrap();
3082        assert_eq!(questions.list().len(), 1, "no repeated operator turn");
3083        d.question = Some("Recover from source B?".into());
3084        // The labels can remain identical while the approval target changes.
3085        d.question_decision.as_mut().unwrap().target = QuestionTarget::Run {
3086            id: "source-run-b".into(),
3087        };
3088        apply_one(&queue, &questions, &d, None).unwrap();
3089        assert_eq!(
3090            questions.list().len(),
3091            2,
3092            "new decision with unchanged task facts"
3093        );
3094        let after = queue.get(&t.id).unwrap();
3095        assert_eq!(after.instruction, t.instruction);
3096        assert_eq!(after.runs, t.runs);
3097        assert_eq!(after.status, TaskStatus::Blocked);
3098    }
3099
3100    #[test]
3101    fn a_second_conductor_question_is_still_allowed_after_one_settled_answer() {
3102        let dir = tempdir().unwrap();
3103        let queue = Queue::at(dir.path().join("queue"));
3104        let questions = Questions::at(dir.path().join("questions"));
3105        let mut t = task("asked about once already");
3106        t.hold_machine(Some("out of attempts".to_owned()));
3107        t.record_answer("Handle this one?".to_owned(), "not yet".to_owned());
3108        queue.put(&mut t).unwrap();
3109
3110        apply(
3111            &queue,
3112            &questions,
3113            &Verdict {
3114                decisions: vec![Decision {
3115                    id: t.id.clone(),
3116                    question: Some("Still not sure - now what?".to_owned()),
3117                    ..Decision::default()
3118                }],
3119            },
3120        )
3121        .unwrap();
3122
3123        assert_eq!(questions.list().len(), 1, "the second question was filed");
3124        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
3125    }
3126
3127    #[test]
3128    fn a_held_task_with_an_open_triage_question_is_left_to_triage() {
3129        // `crate::triage` never calls `Task::block`, and only ever looks at
3130        // tasks still `held` - so a conductor question that moved this task
3131        // to `blocked` would orphan triage's own open question. The guard in
3132        // `apply_one` must leave the task alone until triage's question
3133        // settles.
3134        let dir = tempdir().unwrap();
3135        let queue = Queue::at(dir.path().join("queue"));
3136        let questions = Questions::at(dir.path().join("questions"));
3137        let mut t = task("held, triage already asking about it");
3138        t.hold_machine(Some("cause unclear".to_owned()));
3139        queue.put(&mut t).unwrap();
3140
3141        let mut triage_q = Question::new(
3142            t.id.clone(),
3143            crate::triage::NODE.to_owned(),
3144            "triage".to_owned(),
3145            "Still needed?".to_owned(),
3146            String::new(),
3147            vec![
3148                "resume".to_owned(),
3149                "not yet".to_owned(),
3150                "discard".to_owned(),
3151            ],
3152        );
3153        questions.put(&mut triage_q).unwrap();
3154
3155        for decision in [
3156            Decision {
3157                id: t.id.clone(),
3158                question: Some("what now?".to_owned()),
3159                ..Decision::default()
3160            },
3161            Decision {
3162                id: t.id.clone(),
3163                recovery: Some(Recovery::Requeue),
3164                ..Decision::default()
3165            },
3166        ] {
3167            apply(
3168                &queue,
3169                &questions,
3170                &Verdict {
3171                    decisions: vec![decision],
3172                },
3173            )
3174            .unwrap();
3175        }
3176
3177        let after = queue.get(&t.id).unwrap();
3178        assert_eq!(
3179            after.status,
3180            TaskStatus::Held,
3181            "triage still owns this hold"
3182        );
3183        assert!(after.blocked_by.is_empty());
3184        assert_eq!(
3185            questions.list().len(),
3186            1,
3187            "no second, conductor-owned question was filed"
3188        );
3189    }
3190
3191    #[test]
3192    fn a_held_task_with_an_answered_but_unapplied_triage_question_is_still_left_alone() {
3193        // The race `crate::triage::pending_for` exists to close: the
3194        // operator has already answered the triage question (it is no
3195        // longer `open`), but `crate::triage::run_once` - which only runs on
3196        // a fully idle daemon tick, far less often than the conductor polls
3197        // - has not had a turn to apply it yet. `apply_one` must not treat
3198        // "not open" as "settled" here, or it would block the task out from
3199        // under an answer triage has not read back yet.
3200        let dir = tempdir().unwrap();
3201        let queue = Queue::at(dir.path().join("queue"));
3202        let questions = Questions::at(dir.path().join("questions"));
3203        let mut t = task("held, triage question answered but not yet applied");
3204        t.hold_machine(Some("cause unclear".to_owned()));
3205        queue.put(&mut t).unwrap();
3206
3207        let mut triage_q = Question::new(
3208            t.id.clone(),
3209            crate::triage::NODE.to_owned(),
3210            "triage".to_owned(),
3211            "Still needed?".to_owned(),
3212            String::new(),
3213            vec![
3214                "resume".to_owned(),
3215                "not yet".to_owned(),
3216                "discard".to_owned(),
3217            ],
3218        );
3219        questions.put(&mut triage_q).unwrap();
3220        triage_q
3221            .answer(Answer::Choice("not yet".to_owned()))
3222            .unwrap();
3223        questions.put(&mut triage_q).unwrap();
3224        assert!(!triage_q.status.open());
3225
3226        apply(
3227            &queue,
3228            &questions,
3229            &Verdict {
3230                decisions: vec![Decision {
3231                    id: t.id.clone(),
3232                    question: Some("what now?".to_owned()),
3233                    ..Decision::default()
3234                }],
3235            },
3236        )
3237        .unwrap();
3238
3239        let after = queue.get(&t.id).unwrap();
3240        assert_eq!(
3241            after.status,
3242            TaskStatus::Held,
3243            "triage's own answer is not yet applied - conduct must wait"
3244        );
3245        assert_eq!(
3246            questions.list().len(),
3247            1,
3248            "no conductor question was filed over the pending triage answer"
3249        );
3250    }
3251
3252    #[test]
3253    fn manual_hold_rejects_hostile_or_stale_conductor_recovery() {
3254        let dir = tempdir().unwrap();
3255        let queue = Queue::at(dir.path().join("queue"));
3256        let questions = Questions::at(dir.path().join("questions"));
3257        let mut held = task("manual recovery");
3258        held.priority = 300;
3259        held.runs.push("run20260912-224242-daf5".to_owned());
3260        held.hold_manual(Some(
3261            "active manual recovery run20260912-224242-daf5".to_owned(),
3262        ));
3263        queue.put(&mut held).unwrap();
3264
3265        // Every field a conductor may use to alter lifecycle state is ignored:
3266        // requeue/review would dispatch duplicate work, hold could overwrite
3267        // evidence, and a question would turn the hold into `blocked`.
3268        for decision in [
3269            Decision {
3270                id: held.id.clone(),
3271                recovery: Some(Recovery::Requeue),
3272                ..Decision::default()
3273            },
3274            Decision {
3275                id: held.id.clone(),
3276                recovery: Some(Recovery::Hold),
3277                reason: Some("stale replacement reason".to_owned()),
3278                ..Decision::default()
3279            },
3280            Decision {
3281                id: held.id.clone(),
3282                recovery: Some(Recovery::Review),
3283                ..Decision::default()
3284            },
3285            Decision {
3286                id: held.id.clone(),
3287                blocked_by: vec!["other-task".to_owned()],
3288                question: Some("retry now?".to_owned()),
3289                ..Decision::default()
3290            },
3291        ] {
3292            apply(
3293                &queue,
3294                &questions,
3295                &Verdict {
3296                    decisions: vec![decision],
3297                },
3298            )
3299            .unwrap();
3300        }
3301
3302        let after = queue.get(&held.id).unwrap();
3303        assert_eq!(after.status, TaskStatus::Held);
3304        assert!(after.operator_held());
3305        assert_eq!(after.priority, 300);
3306        assert_eq!(after.runs, ["run20260912-224242-daf5"]);
3307        assert_eq!(
3308            after.hold_reason.as_deref(),
3309            Some("active manual recovery run20260912-224242-daf5")
3310        );
3311        assert!(after.blocked_by.is_empty());
3312        assert!(questions.list().is_empty());
3313        assert!(
3314            queue.next_runnable().is_none(),
3315            "must not dispatch a duplicate"
3316        );
3317    }
3318
3319    #[test]
3320    fn machine_holds_remain_recoverable_and_manual_release_is_authorization() {
3321        let dir = tempdir().unwrap();
3322        let queue = Queue::at(dir.path().join("queue"));
3323        let questions = Questions::at(dir.path().join("questions"));
3324
3325        let mut automatic = task("disk gate");
3326        automatic.hold_machine(Some("disk full".to_owned()));
3327        queue.put(&mut automatic).unwrap();
3328        let requeue = || Verdict {
3329            decisions: vec![Decision {
3330                id: automatic.id.clone(),
3331                recovery: Some(Recovery::Requeue),
3332                ..Decision::default()
3333            }],
3334        };
3335        apply(&queue, &questions, &requeue()).unwrap();
3336        assert_eq!(queue.get(&automatic.id).unwrap().status, TaskStatus::Queued);
3337
3338        let mut manual = task("operator gate");
3339        manual.hold_manual(Some("wait for operator".to_owned()));
3340        queue.put(&mut manual).unwrap();
3341        apply(
3342            &queue,
3343            &questions,
3344            &Verdict {
3345                decisions: vec![Decision {
3346                    id: manual.id.clone(),
3347                    recovery: Some(Recovery::Requeue),
3348                    ..Decision::default()
3349                }],
3350            },
3351        )
3352        .unwrap();
3353        assert_eq!(queue.get(&manual.id).unwrap().status, TaskStatus::Held);
3354
3355        // This mirrors the CLI and web release routes: only an explicit
3356        // operator action clears the manual boundary.
3357        let mut released = queue.get(&manual.id).unwrap();
3358        released.release();
3359        queue.put(&mut released).unwrap();
3360        assert_eq!(queue.get(&manual.id).unwrap().status, TaskStatus::Queued);
3361    }
3362
3363    #[test]
3364    fn legacy_reasoned_hold_is_protected_without_losing_its_metadata() {
3365        let dir = tempdir().unwrap();
3366        let queue = Queue::at(dir.path().join("queue"));
3367        let questions = Questions::at(dir.path().join("questions"));
3368        let mut legacy = task("old explicit hold");
3369        legacy.status = TaskStatus::Held;
3370        legacy.hold_reason = Some("manual recovery already active".to_owned());
3371        legacy.hold_source = None;
3372        legacy.blocked_by = vec!["dependency".to_owned()];
3373        queue.put(&mut legacy).unwrap();
3374
3375        apply(
3376            &queue,
3377            &questions,
3378            &Verdict {
3379                decisions: vec![Decision {
3380                    id: legacy.id.clone(),
3381                    recovery: Some(Recovery::Requeue),
3382                    ..Decision::default()
3383                }],
3384            },
3385        )
3386        .unwrap();
3387
3388        let after = queue.get(&legacy.id).unwrap();
3389        assert_eq!(after.status, TaskStatus::Held);
3390        assert_eq!(after.hold_source, None);
3391        assert_eq!(after.hold_reason, legacy.hold_reason);
3392        assert_eq!(after.blocked_by, legacy.blocked_by);
3393    }
3394
3395    #[test]
3396    fn review_recovery_is_a_no_op_without_a_survivable_branch() {
3397        // `surviving_branch` reaches `RunState::load`, which reaches the
3398        // process-global `run::home()` - a `OnceLock`, so this only wins the
3399        // race the first time it runs in the binary; every other test still
3400        // reaches the same directory whichever call won, and this test's own
3401        // run id never collides with another test's.
3402        crate::run::pin_test_home();
3403        let dir = tempdir().unwrap();
3404        let queue = Queue::at(dir.path().join("queue"));
3405        let questions = Questions::at(dir.path().join("questions"));
3406        let mut t = task("blocked with no readable run");
3407        t.start("20260101-000000-dead".to_owned()); // no such run on disk
3408        t.fail("blocked", 5);
3409        queue.put(&mut t).unwrap();
3410
3411        apply(
3412            &queue,
3413            &questions,
3414            &Verdict {
3415                decisions: vec![Decision {
3416                    id: t.id.clone(),
3417                    recovery: Some(Recovery::Review),
3418                    ..Decision::default()
3419                }],
3420            },
3421        )
3422        .unwrap();
3423
3424        let after = queue.get(&t.id).unwrap();
3425        assert_eq!(
3426            after.status,
3427            TaskStatus::Failed,
3428            "with nothing to reopen, the decision is dropped rather than guessed at"
3429        );
3430        assert!(after.review_branch.is_none());
3431    }
3432
3433    #[test]
3434    fn requeue_and_review_recovery_are_ignored_for_a_runnable_task() {
3435        // `Hold` is the one exception - see `Decision::recovery`'s doc and
3436        // `a_runnable_task_can_be_held_directly_without_a_question` - but a
3437        // task already in line has nothing for `requeue` or `review` to do.
3438        let dir = tempdir().unwrap();
3439        let queue = Queue::at(dir.path().join("queue"));
3440        let questions = Questions::at(dir.path().join("questions"));
3441
3442        for recovery in [Recovery::Requeue, Recovery::Review] {
3443            let mut t = task("ordinary");
3444            queue.put(&mut t).unwrap();
3445
3446            apply(
3447                &queue,
3448                &questions,
3449                &Verdict {
3450                    decisions: vec![Decision {
3451                        id: t.id.clone(),
3452                        recovery: Some(recovery),
3453                        ..Decision::default()
3454                    }],
3455                },
3456            )
3457            .unwrap();
3458
3459            assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
3460        }
3461    }
3462
3463    #[tokio::test]
3464    async fn a_broken_agent_leaves_the_queue_untouched_and_does_not_error() {
3465        let dir = tempdir().unwrap();
3466        let cfg = config(mock_agent(dir.path(), BROKEN, BTreeMap::new()));
3467        let queue = Queue::at(dir.path().join("queue"));
3468        let questions = Questions::at(dir.path().join("questions"));
3469        let mut t = task("normal");
3470        queue.put(&mut t).unwrap();
3471
3472        let mut conductor = Conductor::new();
3473        conductor
3474            .maybe_run(
3475                &cfg,
3476                dir.path(),
3477                &queue,
3478                &questions,
3479                dir.path(),
3480                &[t.clone()],
3481                &[],
3482                &[],
3483                2,
3484            )
3485            .await;
3486
3487        assert_eq!(
3488            queue.get(&t.id).unwrap().status,
3489            TaskStatus::Queued,
3490            "a failed invocation must change nothing"
3491        );
3492        assert!(
3493            queue.next_runnable().is_some(),
3494            "the loop must still be able to take the next task"
3495        );
3496    }
3497
3498    #[tokio::test]
3499    async fn a_reply_with_no_json_leaves_the_queue_untouched() {
3500        let dir = tempdir().unwrap();
3501        let cfg = config(mock_agent(dir.path(), GARBAGE, BTreeMap::new()));
3502        let queue = Queue::at(dir.path().join("queue"));
3503        let questions = Questions::at(dir.path().join("questions"));
3504        let mut t = task("normal");
3505        queue.put(&mut t).unwrap();
3506
3507        let mut conductor = Conductor::new();
3508        conductor
3509            .maybe_run(
3510                &cfg,
3511                dir.path(),
3512                &queue,
3513                &questions,
3514                dir.path(),
3515                &[t.clone()],
3516                &[],
3517                &[],
3518                2,
3519            )
3520            .await;
3521
3522        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
3523    }
3524
3525    #[tokio::test]
3526    async fn a_conductor_chain_falls_to_the_next_agent_once_each() {
3527        let dir = tempdir().unwrap();
3528        let mut t = task("chained");
3529        let reply = format!(
3530            "{{\"decisions\":[{{\"id\":\"{}\",\"blocked_by\":[\"x\"],\"reason\":\"why\"}}]}}",
3531            t.id
3532        );
3533        let counted = |id: &str, tail: &str| {
3534            let calls = dir.path().join(format!("{id}.calls"));
3535            let path = dir.path().join(format!("{id}.sh"));
3536            std::fs::write(
3537                &path,
3538                format!(
3539                    "#!/bin/sh\ncat >/dev/null\necho x >> '{}'\n{tail}\n",
3540                    calls.display()
3541                ),
3542            )
3543            .unwrap();
3544            AgentSpec {
3545                id: id.to_owned(),
3546                kind: AgentKind::Command,
3547                model: None,
3548                command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
3549                extra_args: Vec::new(),
3550                env: BTreeMap::new(),
3551                prompt_delivery: None,
3552            }
3553        };
3554        let n_calls = |id: &str| {
3555            std::fs::read_to_string(dir.path().join(format!("{id}.calls")))
3556                .map_or(0, |s| s.lines().count())
3557        };
3558        let mut cfg = config(counted("a", "exit 3"));
3559        cfg.agents = vec![
3560            counted("a", "exit 3"),
3561            counted("b", &format!("printf '%s' '{reply}'")),
3562        ];
3563        cfg.roles.conductor = Some(crate::config::AgentChoice::Chain(vec![
3564            "a".into(),
3565            "b".into(),
3566            "a".into(),
3567        ]));
3568        let queue = Queue::at(dir.path().join("queue"));
3569        let questions = Questions::at(dir.path().join("questions"));
3570        queue.put(&mut t).unwrap();
3571
3572        let mut conductor = Conductor::new();
3573        conductor
3574            .maybe_run(
3575                &cfg,
3576                dir.path(),
3577                &queue,
3578                &questions,
3579                dir.path(),
3580                &[t.clone()],
3581                &[],
3582                &[],
3583                2,
3584            )
3585            .await;
3586
3587        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
3588        assert_eq!(n_calls("a"), 1, "each id is tried once");
3589        assert_eq!(n_calls("b"), 1);
3590        let saved = std::fs::read_to_string(seat_path(dir.path())).unwrap();
3591        assert!(
3592            saved.contains("\"b\""),
3593            "the seat on disk is the one that ran: {saved}"
3594        );
3595    }
3596
3597    #[tokio::test]
3598    async fn json_survives_code_fences_and_a_preamble() {
3599        let dir = tempdir().unwrap();
3600        let mut t = task("fenced");
3601        let reply = format!(
3602            "Sure, here is my decision.\n\n```json\n{{\"decisions\":[{{\"id\":\"{}\",\
3603             \"blocked_by\":[\"x\"],\"reason\":\"why\"}}]}}\n```\n",
3604            t.id
3605        );
3606        let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
3607        let queue = Queue::at(dir.path().join("queue"));
3608        let questions = Questions::at(dir.path().join("questions"));
3609        queue.put(&mut t).unwrap();
3610
3611        let mut conductor = Conductor::new();
3612        conductor
3613            .maybe_run(
3614                &cfg,
3615                dir.path(),
3616                &queue,
3617                &questions,
3618                dir.path(),
3619                &[t.clone()],
3620                &[],
3621                &[],
3622                2,
3623            )
3624            .await;
3625
3626        let back = queue.get(&t.id).unwrap();
3627        assert_eq!(back.status, TaskStatus::Blocked);
3628        assert_eq!(back.blocked_by, ["x"]);
3629    }
3630
3631    #[tokio::test]
3632    async fn the_conductor_is_not_called_again_when_nothing_worth_looking_at_has_changed() {
3633        // Each real invocation writes its own artifact stem, `turn-<n>`, so
3634        // whether a second one happened is read off the artifacts directory.
3635        let dir = tempdir().unwrap();
3636        let cfg = config(mock_agent(dir.path(), REPLY, env("{\"decisions\":[]}")));
3637        let queue = Queue::at(dir.path().join("queue"));
3638        let questions = Questions::at(dir.path().join("questions"));
3639        let mut t = task("stable");
3640        queue.put(&mut t).unwrap();
3641        let artifacts = dir.path().join("conduct").join("artifacts");
3642        let turn = |n: usize| artifacts.join(format!("turn-{n}.out"));
3643
3644        let mut conductor = Conductor::new();
3645        conductor
3646            .maybe_run(
3647                &cfg,
3648                dir.path(),
3649                &queue,
3650                &questions,
3651                dir.path(),
3652                &[t.clone()],
3653                &[],
3654                &[],
3655                2,
3656            )
3657            .await;
3658        assert!(turn(1).is_file(), "the first cycle must call the conductor");
3659
3660        conductor
3661            .maybe_run(
3662                &cfg,
3663                dir.path(),
3664                &queue,
3665                &questions,
3666                dir.path(),
3667                &[t.clone()],
3668                &[],
3669                &[],
3670                2,
3671            )
3672            .await;
3673        assert!(
3674            !turn(2).is_file(),
3675            "an unchanged revision and an unchanged stalled/finished set must not call the \
3676             conductor twice"
3677        );
3678
3679        // Once the queue actually changes, the next `maybe_run` calls again.
3680        t.priority = 1;
3681        queue.put(&mut t).unwrap();
3682        conductor
3683            .maybe_run(
3684                &cfg,
3685                dir.path(),
3686                &queue,
3687                &questions,
3688                dir.path(),
3689                &[t.clone()],
3690                &[],
3691                &[],
3692                2,
3693            )
3694            .await;
3695        assert!(turn(2).is_file(), "a moved revision calls it again");
3696    }
3697
3698    #[tokio::test]
3699    async fn a_task_turning_stalled_calls_the_conductor_again_despite_an_unchanged_revision() {
3700        // The queue's own revision has not moved - nothing wrote to it - but
3701        // a task now looks stalled, purely because time passed. Calling
3702        // again here, and never again once this exact set has been shown
3703        // once, is the whole point of keying `worth_a_look` on the id set
3704        // rather than on "is it non-empty".
3705        let dir = tempdir().unwrap();
3706        let cfg = config(mock_agent(dir.path(), REPLY, env("{\"decisions\":[]}")));
3707        let queue = Queue::at(dir.path().join("queue"));
3708        let questions = Questions::at(dir.path().join("questions"));
3709        let mut t = task("quiet");
3710        queue.put(&mut t).unwrap();
3711        let artifacts = dir.path().join("conduct").join("artifacts");
3712        let turn = |n: usize| artifacts.join(format!("turn-{n}.out"));
3713
3714        let mut conductor = Conductor::new();
3715        conductor
3716            .maybe_run(
3717                &cfg,
3718                dir.path(),
3719                &queue,
3720                &questions,
3721                dir.path(),
3722                &[t.clone()],
3723                &[],
3724                &[],
3725                2,
3726            )
3727            .await;
3728        assert!(turn(1).is_file());
3729
3730        conductor
3731            .maybe_run(
3732                &cfg,
3733                dir.path(),
3734                &queue,
3735                &questions,
3736                dir.path(),
3737                &[],
3738                &[t.clone()],
3739                &[],
3740                2,
3741            )
3742            .await;
3743        assert!(
3744            turn(2).is_file(),
3745            "a task turning stalled must call the conductor again"
3746        );
3747
3748        // But once shown at this exact revision, showing the *same* stalled
3749        // set again must not call a third time.
3750        conductor
3751            .maybe_run(
3752                &cfg,
3753                dir.path(),
3754                &queue,
3755                &questions,
3756                dir.path(),
3757                &[],
3758                &[t.clone()],
3759                &[],
3760                2,
3761            )
3762            .await;
3763        assert!(
3764            !turn(3).is_file(),
3765            "the same stalled task lingering must not call the conductor every cycle"
3766        );
3767    }
3768
3769    fn rehold(queue: &Queue, questions: &Questions, id: &str, note: &str) {
3770        apply(
3771            queue,
3772            questions,
3773            &Verdict {
3774                decisions: vec![Decision {
3775                    id: id.to_owned(),
3776                    reason: Some(note.to_owned()),
3777                    recovery: Some(Recovery::Hold),
3778                    ..Decision::default()
3779                }],
3780            },
3781        )
3782        .unwrap();
3783    }
3784
3785    #[test]
3786    fn reholding_with_an_identical_note_changes_nothing() {
3787        let dir = tempdir().unwrap();
3788        let queue = Queue::at(dir.path().join("queue"));
3789        let questions = Questions::at(dir.path().join("questions"));
3790        let mut t = task("held");
3791        t.hold_machine(Some("waiting on a human".to_owned()));
3792        queue.put(&mut t).unwrap();
3793
3794        rehold(&queue, &questions, &t.id, "owner said keep it held");
3795        let first = queue.get(&t.id).unwrap();
3796        let rev = queue.revision();
3797        for _ in 0..5 {
3798            rehold(&queue, &questions, &t.id, "owner said keep it held");
3799        }
3800        let after = queue.get(&t.id).unwrap();
3801        assert_eq!(after.hold_reason, first.hold_reason);
3802        assert_eq!(after.updated_at, first.updated_at);
3803        assert_eq!(queue.revision(), rev, "nothing was written");
3804    }
3805
3806    #[test]
3807    fn reholding_with_different_notes_keeps_one_previously_level() {
3808        let dir = tempdir().unwrap();
3809        let queue = Queue::at(dir.path().join("queue"));
3810        let questions = Questions::at(dir.path().join("questions"));
3811        let mut t = task("held");
3812        t.hold_machine(Some("original".to_owned()));
3813        queue.put(&mut t).unwrap();
3814
3815        for i in 0..20 {
3816            rehold(&queue, &questions, &t.id, &format!("note {}", i % 2));
3817        }
3818        let reason = queue.get(&t.id).unwrap().hold_reason.unwrap();
3819        assert_eq!(reason.matches("(previously:").count(), 1, "{reason}");
3820        assert!(reason.starts_with("note "), "the new note leads: {reason}");
3821    }
3822
3823    #[test]
3824    fn an_already_nested_reason_is_matched_by_its_outermost_note() {
3825        let mut t = task("deep");
3826        let nested = "same\n\n(previously: same\n\n(previously: same))";
3827        t.hold_machine(Some(nested.to_owned()));
3828        let d = Decision {
3829            id: t.id.clone(),
3830            reason: Some("same".to_owned()),
3831            ..Decision::default()
3832        };
3833        assert_eq!(reaffirmed_hold_reason(&t, &d), None);
3834        let d2 = Decision {
3835            reason: Some("other".to_owned()),
3836            ..d
3837        };
3838        assert_eq!(
3839            reaffirmed_hold_reason(&t, &d2).as_deref(),
3840            Some("other\n\n(previously: same)")
3841        );
3842    }
3843
3844    #[tokio::test]
3845    async fn a_repeated_hold_decision_settles_instead_of_looping() {
3846        let dir = tempdir().unwrap();
3847        let queue = Queue::at(dir.path().join("queue"));
3848        let questions = Questions::at(dir.path().join("questions"));
3849        let reply = r#"{"decisions":[{"id":"ID","reason":"keep held","recovery":"hold"}]}"#;
3850        let mut t = task("held");
3851        t.hold_machine(Some("first".to_owned()));
3852        queue.put(&mut t).unwrap();
3853        let cfg = config(mock_agent(
3854            dir.path(),
3855            REPLY,
3856            env(&reply.replace("ID", &t.id)),
3857        ));
3858        let mut conductor = Conductor::new();
3859        // Cycle 1 writes the new note; cycle 2 repeats it and writes nothing.
3860        for _ in 0..2 {
3861            let held = queue.get(&t.id).unwrap();
3862            conductor
3863                .maybe_run(
3864                    &cfg,
3865                    dir.path(),
3866                    &queue,
3867                    &questions,
3868                    dir.path(),
3869                    &[],
3870                    &[],
3871                    &[held],
3872                    2,
3873                )
3874                .await;
3875        }
3876        let held = queue.get(&t.id).unwrap();
3877        assert!(
3878            held.hold_reason
3879                .as_deref()
3880                .unwrap()
3881                .starts_with("keep held")
3882        );
3883        assert!(
3884            !conductor.worth_a_look(&queue, &[], &[held]),
3885            "an unchanged held task must stop being reconsidered"
3886        );
3887    }
3888
3889    #[tokio::test]
3890    async fn alternating_wording_does_not_keep_a_held_task_in_the_loop() {
3891        let dir = tempdir().unwrap();
3892        let queue = Queue::at(dir.path().join("queue"));
3893        let questions = Questions::at(dir.path().join("questions"));
3894        let mut t = task("held");
3895        t.hold_machine(Some("first".to_owned()));
3896        queue.put(&mut t).unwrap();
3897        let reply = |note: &str| {
3898            format!(
3899                r#"{{"decisions":[{{"id":"{}","reason":"{note}","recovery":"hold"}}]}}"#,
3900                t.id
3901            )
3902        };
3903        let cfg_a = config(mock_agent(dir.path(), REPLY, env(&reply("keep held"))));
3904        let cfg_b = config(mock_agent(dir.path(), REPLY, env(&reply("leave held"))));
3905        let mut conductor = Conductor::new();
3906        let mut writes = 0;
3907        for i in 0..6 {
3908            let held = queue.get(&t.id).unwrap();
3909            let before = queue.revision();
3910            conductor
3911                .maybe_run(
3912                    if i % 2 == 0 { &cfg_a } else { &cfg_b },
3913                    dir.path(),
3914                    &queue,
3915                    &questions,
3916                    dir.path(),
3917                    &[],
3918                    &[],
3919                    &[held],
3920                    2,
3921                )
3922                .await;
3923            if queue.revision() != before {
3924                writes += 1;
3925            }
3926        }
3927        assert!(writes <= 2, "the loop must settle, saw {writes} writes");
3928        let mut held = queue.get(&t.id).unwrap();
3929        held.instruction.push_str(" (edited)");
3930        queue.put(&mut held).unwrap();
3931        let held = queue.get(&t.id).unwrap();
3932        assert!(conductor.worth_a_look(&queue, &[], &[held]));
3933    }
3934
3935    #[test]
3936    fn worth_a_look_is_config_free_and_matches_maybe_runs_own_gate() {
3937        let dir = tempdir().unwrap();
3938        let queue = Queue::at(dir.path().join("queue"));
3939        let mut t = task("t");
3940        queue.put(&mut t).unwrap();
3941
3942        let mut conductor = Conductor::new();
3943        assert!(
3944            conductor.worth_a_look(&queue, &[], &[]),
3945            "a conductor that has never run has something to look at"
3946        );
3947
3948        conductor.last_seen = Some(conductor.snapshot(&queue, &[], &[]));
3949        assert!(
3950            !conductor.worth_a_look(&queue, &[], &[]),
3951            "nothing changed and nothing is stalled or finished"
3952        );
3953        assert!(
3954            conductor.worth_a_look(&queue, &[t.clone()], &[]),
3955            "a stalled task is worth a look even at the same revision"
3956        );
3957        assert!(
3958            conductor.worth_a_look(&queue, &[], &[t.clone()]),
3959            "a finished task is worth a look even at the same revision"
3960        );
3961    }
3962
3963    #[tokio::test]
3964    async fn the_conduct_path_never_calls_ask_and_wait() {
3965        // Structural: grepping this module and `daemon.rs` for
3966        // `ask_and_wait` is the actual assertion this module's own doc
3967        // promises; this test exists so the promise has a name in the test
3968        // output too. `apply_one`'s question path uses `Questions::put`
3969        // exclusively.
3970        let dir = tempdir().unwrap();
3971        let queue = Queue::at(dir.path().join("queue"));
3972        let questions = Questions::at(dir.path().join("questions"));
3973        let mut t = task("asks without blocking");
3974        queue.put(&mut t).unwrap();
3975
3976        apply(
3977            &queue,
3978            &questions,
3979            &Verdict {
3980                decisions: vec![Decision {
3981                    id: t.id.clone(),
3982                    question: Some("ok?".to_owned()),
3983                    ..Decision::default()
3984                }],
3985            },
3986        )
3987        .unwrap();
3988        // Reaching here at all (no hang) is the assertion.
3989        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
3990    }
3991
3992    #[test]
3993    fn a_pinned_resume_is_not_held_or_requeued_by_the_conductor() {
3994        let mut t = Task::new("t".into(), "t".into(), PathBuf::new(), Source::Human);
3995        t.resume_override = Some(crate::queue::OperatorResume {
3996            question_id: "q".into(),
3997            at: jiff::Timestamp::now(),
3998            conductor_rehold: None,
3999            forced: false,
4000            pinned_run: Some("run-1".into()),
4001        });
4002        assert!(pinned_resume(&t));
4003        assert!(!may_hold(&mut t, "waiting for magi resume"));
4004        assert!(
4005            t.resume_override
4006                .as_ref()
4007                .unwrap()
4008                .conductor_rehold
4009                .is_none(),
4010            "a refused hold is not recorded as an override"
4011        );
4012        t.resume_override = None;
4013        assert!(!pinned_resume(&t));
4014        assert!(may_hold(&mut t, "no override, so a hold is allowed"));
4015    }
4016
4017    #[test]
4018    fn a_pinned_resume_is_not_blocked_by_a_conductor_question_or_dependency() {
4019        let dir = tempdir().unwrap();
4020        let queue = Queue::at(dir.path().join("queue"));
4021        let questions = Questions::at(dir.path().join("questions"));
4022        let mut t = task("resume me");
4023        t.resume_override = Some(crate::queue::OperatorResume {
4024            question_id: "q".into(),
4025            at: jiff::Timestamp::now(),
4026            conductor_rehold: None,
4027            forced: true,
4028            pinned_run: Some("run-1".into()),
4029        });
4030        queue.put(&mut t).unwrap();
4031
4032        apply(
4033            &queue,
4034            &questions,
4035            &Verdict {
4036                decisions: vec![
4037                    Decision {
4038                        id: t.id.clone(),
4039                        question: Some("really?".to_owned()),
4040                        ..Decision::default()
4041                    },
4042                    Decision {
4043                        id: t.id.clone(),
4044                        blocked_by: vec!["other".to_owned()],
4045                        ..Decision::default()
4046                    },
4047                ],
4048            },
4049        )
4050        .unwrap();
4051
4052        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
4053        assert!(questions.list().is_empty());
4054    }
4055}