Skip to main content

magi/
advise.rs

1//! The headless design-deliberation stage between `magi plan`'s interview and
2//! the task file it files.
3//!
4//! [`crate::graph`]'s judge / vote / deliberate machinery only runs when more
5//! than one candidate survives - and `[graph] candidates` defaults to 1 now
6//! (see [`crate::config::Graph::candidates`]'s doc for the cost numbers
7//! behind that default), so on an ordinary task none of that machinery ever
8//! fires any more. Diversity did not stop paying for itself; it moved. A
9//! design sketch is a few paragraphs an agent can write without touching the
10//! repository, where a full implementation is a hundred-plus-turn tool loop
11//! that re-reads the codebase on every turn - so three sketches, gathered
12//! once between the interview and the file, cost a fraction of a third
13//! implementation and buy back the same disagreement the judges used to
14//! surface, on every task rather than only the ones run with `--candidates`.
15//!
16//! Three independent, read-only advisors ([`gather`]) each propose one
17//! design. The planner seat (`[roles] planner`) then reads all three and
18//! [`prompt::synthesize`]s them into the task file's `## Context` and
19//! `## Change`, naming which advisor's idea it kept where - it is told, in so
20//! many words, not to pick a winner. Nothing here talks to the operator: the
21//! interview already did that, and turning this stage into three more
22//! conversations would be exactly the cost this module exists to avoid.
23//!
24//! # Disposability narrows the blast radius, it does not forbid writing
25//!
26//! `allow_write: false` alone is not a guarantee: opencode has no read-only
27//! mode at all, and Claude's `--disallowed-tools` stops its edit tools but not
28//! a `rm` or a redirect run through its Bash tool. That is the existing,
29//! accepted risk model for every read-only seat in this codebase - judge and
30//! reviewer seats in [`crate::graph`] carry exactly the same weak guarantee,
31//! and get away with it because their `cwd` is already a worktree the run
32//! treats as disposable. This stage runs before any run exists, so it was
33//! pointed at the operator's own checkout until [`checkout_worktrees`] gave
34//! every advisor seat, and the planner's synthesis, a `git worktree add
35//! --detach` checkout at `HEAD` of their own, thrown away when [`run`]
36//! returns - the same protection judges and reviewers already had, closing
37//! the one gap unique to this stage rather than inventing a stronger
38//! guarantee nothing else here provides.
39//!
40//! What this buys: a relative-path write from a seat that ignores its
41//! instructions lands in that seat's own disposable checkout, not in the
42//! operator's repository and not in another seat's. What it does not buy: an
43//! absolute-path write, or an edit to the shared `.git` metadata a linked
44//! worktree does not copy (its own config extension aside), can still reach
45//! outside the checkout - the same as it always could for a judge or a
46//! reviewer. Closing that would mean sandboxing the process itself (a
47//! container, a chroot, an OS-level read-only mount), which no seat of any
48//! kind in this codebase has today; adding one is a different, much larger
49//! change than a design-deliberation stage, not something this module can
50//! give an advisor seat on its own.
51//!
52//! # The draft survives every failure short of success
53//!
54//! [`run`] never writes to `draft` until it holds a complete, synthesized
55//! replacement. Every early return - an advisor roster that produced nothing
56//! usable, a planner seat that crashed or answered with no fenced `task`
57//! block - leaves the interview's own draft exactly as the leader wrote it,
58//! and the error names its path, the same contract [`crate::plan::vet`]
59//! keeps for a validation failure. The raw advisor records are written to
60//! disk unconditionally, before that check even runs, so a total failure
61//! still leaves something for the operator to read.
62use std::path::{Path, PathBuf};
63use std::sync::Arc;
64use std::time::{Duration, Instant};
65
66use anyhow::{Context as _, Result, bail};
67use serde::{Deserialize, Serialize};
68use tokio::sync::Semaphore;
69
70use crate::agent::{self, Invocation, SeatState};
71use crate::chat;
72use crate::config::{AgentSpec, Config};
73use crate::git;
74use crate::plan;
75use crate::prompt;
76use crate::verdict::{self, Proposal};
77
78/// One advisor seat's outcome, kept even on failure so a synthesis that only
79/// had two of three proposals to work with is not a mystery later - see
80/// [`run`]'s doc.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct AdvisorRecord {
83    /// Seat name, e.g. `advisor-1`.
84    pub seat: String,
85    /// Agent id occupying the seat.
86    pub agent: String,
87    /// The proposal, when the seat produced a usable one.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub proposal: Option<Proposal>,
90    /// Why there is no proposal, when there is not one.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub error: Option<String>,
93    /// Wall-clock duration.
94    pub duration_ms: u64,
95}
96
97/// The whole deliberation: one record per advisor seat, written to
98/// `<id>.advisors.json` next to the draft so the operator can read every
99/// seat's reasoning - including a seat that failed - not only whichever parts
100/// synthesis kept.
101#[derive(Debug, Clone, Serialize, Deserialize, Default)]
102pub struct Advice {
103    /// One record per advisor seat asked.
104    pub records: Vec<AdvisorRecord>,
105    /// Did this deliberation reach a synthesized task file?
106    ///
107    /// [`deliberate`] writes this record to disk unconditionally, before any
108    /// of its own failure checks run (see the module doc's "The draft
109    /// survives every failure short of success") - so a total advisor
110    /// failure, a planner crash, or a synthesis `vet` rejects all leave an
111    /// `<id>.advisors.json` on disk whose `<id>.md` is still the raw,
112    /// un-synthesized interview draft, not the deliberation's output. This
113    /// field is `false` on every write except the very last one, made only
114    /// after `draft` itself has been overwritten with the synthesis - so a
115    /// reader (the web plan surface, `DraftAdvisorsView` in `src/web.rs`) can
116    /// tell those two situations apart without guessing from whether the
117    /// file happens to exist.
118    ///
119    /// `#[serde(default)]` so a record written before this field existed
120    /// deserializes as `false` - the safe reading, since a pre-existing
121    /// record's own age is exactly the information "was this ever
122    /// synthesized" would otherwise lose.
123    #[serde(default)]
124    pub synthesized: bool,
125}
126
127impl Advice {
128    /// The seats that produced a usable proposal, in seat order.
129    pub fn proposals(&self) -> Vec<(&str, &Proposal)> {
130        self.records
131            .iter()
132            .filter_map(|r| r.proposal.as_ref().map(|p| (r.seat.as_str(), p)))
133            .collect()
134    }
135}
136
137/// Run the design-deliberation stage: gather independent proposals, write the
138/// raw record, synthesize them under the planner seat, and overwrite `draft`
139/// with the result.
140///
141/// `dir` is the drafts directory the raw record and the seats' artifacts are
142/// written under; `id` names this interview's draft, so its siblings
143/// (`<id>.advisors.json`, `<id>.advisors/`) sit next to `<id>.md` the same way
144/// `<id>.briefing.md` already does.
145pub async fn run(
146    config: &Config,
147    repo: &Path,
148    draft: &Path,
149    dir: &Path,
150    id: &str,
151) -> Result<Advice> {
152    let requirements = std::fs::read_to_string(draft).with_context(|| {
153        format!(
154            "no task file at {} - the leader was asked to write one there",
155            draft.display()
156        )
157    })?;
158
159    let seats = config.advisors().with_context(|| {
160        format!(
161            "resolving advisor seats; the interview draft is unchanged at \
162             {0} - file it as-is with `magi task add --file {0}`, or fix \
163             `[roles] advisors` and retry `magi plan`.",
164            draft.display(),
165        )
166    })?;
167    if seats.is_empty() {
168        bail!(
169            "`[graph] advisors` is 0, so there is nobody to deliberate with; \
170             the interview draft is unchanged at {0} - file it as-is with \
171             `magi task add --file {0}`, or set `[graph] advisors` above 0 \
172             and re-run `magi plan`.",
173            draft.display()
174        );
175    }
176
177    let worktrees = checkout_worktrees(repo, dir, id, seats.len())
178        .await
179        .with_context(|| {
180            format!(
181                "could not prepare a disposable checkout for the advisor \
182                 seats; the interview draft is unchanged at {d} - file it \
183                 as-is with `magi task add --file {d}`, or retry `magi plan`.",
184                d = draft.display(),
185            )
186        })?;
187
188    // Split out so the worktrees are removed on every path out of here,
189    // success or failure - Rust has no `try`/`finally` to hang this off of.
190    let outcome = deliberate(
191        &requirements,
192        &seats,
193        &worktrees,
194        &DeliberationCtx {
195            config,
196            draft,
197            dir,
198            id,
199            language: &config.graph.language,
200            // Read + reason, no write: the same shape of work `[graph]
201            // timeout_judge` already budgets for judges, so a stage-specific
202            // timeout nobody asked for would be one more number to tune for
203            // no benefit.
204            timeout: Duration::from_secs(config.graph.timeout_judge.max(1)),
205            seed: crate::rng::entropy(),
206        },
207    )
208    .await;
209
210    remove_worktrees(repo, &worktrees).await;
211
212    outcome
213}
214
215/// Disposable, detached worktrees at `HEAD`, one per advisor seat - see the
216/// module doc's "Disposability narrows the blast radius" section for why a
217/// seat needs one of these rather than the operator's own checkout, and for
218/// what it does and does not protect against.
219///
220/// Sequential, not parallel: `git worktree add` takes a lock on the
221/// repository's own `.git` metadata, and setup is a one-time cost paid once
222/// per `magi plan` invocation, not on the hot path a parallel seat wave
223/// exists to keep cheap.
224async fn checkout_worktrees(repo: &Path, dir: &Path, id: &str, n: usize) -> Result<Vec<PathBuf>> {
225    let root = dir.join(format!("{id}.repo"));
226    let mut paths = Vec::with_capacity(n);
227    for i in 0..n {
228        let wt = root.join(format!("advisor-{}", i + 1));
229        if let Err(e) = git::worktree_add_detached(repo, &wt, "HEAD").await {
230            // Partial setup must not leak the worktrees it did manage to
231            // register before the failure that stopped it.
232            remove_worktrees(repo, &paths).await;
233            return Err(e);
234        }
235        paths.push(wt);
236    }
237    Ok(paths)
238}
239
240/// Best-effort teardown. A worktree `magi plan` fails to remove costs the
241/// operator disk, not correctness - the advisor stage already answered or
242/// already failed by the time this runs - so a removal error is logged and
243/// moved past rather than turned into a second error on top of whatever
244/// [`run`] is already returning.
245async fn remove_worktrees(repo: &Path, worktrees: &[PathBuf]) {
246    for wt in worktrees {
247        if let Err(e) = git::worktree_remove(repo, wt).await {
248            tracing::warn!(
249                "could not remove disposable advisor worktree {}: {e:#}",
250                wt.display()
251            );
252        }
253    }
254    // Only succeeds once every child above is gone; harmless otherwise.
255    if let Some(root) = worktrees.first().and_then(|w| w.parent()) {
256        let _ = std::fs::remove_dir(root);
257    }
258}
259
260/// Everything [`deliberate`] needs once a disposable checkout exists per
261/// seat, bundled so the function takes one borrow instead of a parameter per
262/// field - the same reason [`crate::graph`]'s wave takes a `WaveCtx`.
263struct DeliberationCtx<'a> {
264    config: &'a Config,
265    draft: &'a Path,
266    dir: &'a Path,
267    id: &'a str,
268    language: &'a str,
269    timeout: Duration,
270    seed: u64,
271}
272
273/// The body of [`run`]: gather, record, synthesize, validate, write. Split out
274/// only so [`run`] can guarantee `worktrees` are removed on every exit from
275/// this, not so it can be called independently of a checkout existing.
276async fn deliberate(
277    requirements: &str,
278    seats: &[AgentSpec],
279    worktrees: &[PathBuf],
280    ctx: &DeliberationCtx<'_>,
281) -> Result<Advice> {
282    let draft = ctx.draft;
283    let artifacts = ctx.dir.join(format!("{}.advisors", ctx.id));
284
285    let mut advice = gather(
286        seats,
287        requirements,
288        worktrees,
289        &GatherCtx {
290            artifacts: &artifacts,
291            run: ctx.id,
292            language: ctx.language,
293            timeout: ctx.timeout,
294            seed: ctx.seed,
295            max_parallel: ctx.config.graph.max_parallel.max(1),
296        },
297    )
298    .await;
299
300    // Written before the checks below can bail: a total failure must still
301    // leave the raw attempts on disk, or "nobody produced a proposal" is a
302    // claim the operator has no way to check.
303    let advice_path = ctx.dir.join(format!("{}.advisors.json", ctx.id));
304    std::fs::write(
305        &advice_path,
306        serde_json::to_string_pretty(&advice).context("serialize the advisor records")?,
307    )
308    .with_context(|| format!("write {}", advice_path.display()))?;
309
310    let proposals = advice.proposals();
311    if proposals.is_empty() {
312        bail!(
313            "none of {n} advisor seat(s) produced a usable design proposal \
314             (see {record}); the interview draft is unchanged at {d} - file \
315             it as-is with `magi task add --file {d}`, or retry `magi plan`.",
316            n = seats.len(),
317            record = advice_path.display(),
318            d = draft.display(),
319        );
320    }
321
322    let planner = plan::pick(
323        &ctx.config.agents,
324        ctx.config.roles.planner.as_deref(),
325        &plan::installed,
326    )
327    .context("resolving the planner seat for design synthesis")?;
328    let mut seat = SeatState::new("plan-synthesis", &planner.id, ctx.seed);
329    let synth_prompt = prompt::synthesize(requirements, &proposals, ctx.language);
330    let out = agent::invoke(
331        &planner,
332        &mut seat,
333        &Invocation {
334            // The first advisor's disposable checkout, reused: every advisor
335            // task has already finished by this point (`gather` awaited them
336            // all), so there is nothing left to race with, and a fourth
337            // checkout just for synthesis would buy nothing this one does
338            // not already give it - a read-only view of the repository that
339            // is not the operator's own.
340            cwd: &worktrees[0],
341            prompt: &synth_prompt,
342            timeout: ctx.timeout,
343            allow_write: false,
344            sessions: false,
345            artifacts: &artifacts,
346            stem: "synthesis",
347            run: ctx.id,
348            node: "plan-advise",
349            cache_dir: None,
350        },
351    )
352    .await
353    .with_context(|| {
354        format!(
355            "the planner seat could not synthesize the design proposals; the \
356             interview draft is unchanged at {0} - file it as-is with `magi \
357             task add --file {0}`, or retry `magi plan`.",
358            draft.display()
359        )
360    })?;
361
362    if !out.usable() {
363        bail!(
364            "the planner seat produced nothing usable while synthesizing the \
365             design proposals; the interview draft is unchanged at {0} - file \
366             it as-is with `magi task add --file {0}`, or retry `magi plan`.",
367            draft.display()
368        );
369    }
370
371    let synthesized = chat::extract_draft(&out.text).with_context(|| {
372        format!(
373            "the planner seat's reply had no fenced ```task block; the \
374             interview draft is unchanged at {0} - file it as-is with `magi \
375             task add --file {0}`, or retry `magi plan`.",
376            draft.display()
377        )
378    })?;
379
380    // Checked before a single byte reaches `draft`: `extract_draft` accepts an
381    // unclosed fence as "whatever came before end of stream", so a synthesis
382    // that stopped mid-sentence - a truncated reply, not a system timeout -
383    // would otherwise overwrite a perfectly good interview draft with
384    // something `vet` rejects one call later, at which point the operator's
385    // requirements are already gone. The same shape `vet` itself uses: length
386    // alone warns rather than refuses, everything else must hold.
387    if let Err(problems) = plan::review_draft(&synthesized) {
388        let hard: Vec<&String> = problems
389            .iter()
390            .filter(|p| p.as_str() != plan::SHORT_DRAFT)
391            .collect();
392        if !hard.is_empty() {
393            let list = hard
394                .iter()
395                .map(|p| format!("  - {p}"))
396                .collect::<Vec<_>>()
397                .join("\n");
398            bail!(
399                "the planner seat's synthesis is not a usable task file:\n{list}\n\n\
400                 the interview draft is unchanged at {d} - file it as-is with \
401                 `magi task add --file {d}`, or retry `magi plan`.",
402                d = draft.display(),
403            );
404        }
405    }
406
407    std::fs::write(draft, &synthesized).with_context(|| format!("write {}", draft.display()))?;
408
409    // Only reached once `draft` itself already holds the synthesis: every
410    // path above this line that bails leaves `synthesized` at its default
411    // `false`, which is what tells a reader (`DraftAdvisorsView` in
412    // `src/web.rs`) that `<id>.md` is still the raw interview draft, not this
413    // deliberation's output.
414    //
415    // Best-effort from here: the deliberation has already succeeded (`draft`
416    // is written), so a failure re-persisting the record must not turn a
417    // completed run into a reported one - the same trade-off
418    // `remove_worktrees` makes for cleanup. Worst case, `synthesized` stays
419    // `false` on disk a little longer than it should and `DraftAdvisorsView`
420    // hides a task file that is in fact done, which is a stale read, not the
421    // data loss a `?` here would risk turning this into.
422    advice.synthesized = true;
423    match serde_json::to_string_pretty(&advice) {
424        Ok(json) => {
425            if let Err(e) = std::fs::write(&advice_path, json) {
426                tracing::warn!(
427                    "could not record deliberation {} as synthesized in {}: {e:#}",
428                    ctx.id,
429                    advice_path.display()
430                );
431            }
432        }
433        Err(e) => tracing::warn!(
434            "could not serialize the advisor record for {}: {e:#}",
435            advice_path.display()
436        ),
437    }
438
439    Ok(advice)
440}
441
442/// The parts of [`deliberate`]'s setup every advisor seat needs, bundled so
443/// [`gather`] takes one borrow instead of a parameter per field - the same
444/// reason [`crate::graph`]'s wave takes a `WaveCtx`.
445struct GatherCtx<'a> {
446    artifacts: &'a Path,
447    run: &'a str,
448    language: &'a str,
449    timeout: Duration,
450    seed: u64,
451    /// `[graph] max_parallel`, at least 1. Caps how many advisor seats may be
452    /// mid-invocation at once, the same budget `graph::ask_json_wave` enforces
453    /// with its own `Semaphore` for judges and reviewers - a roster with more
454    /// advisor seats than that would otherwise start every one of them at
455    /// once, unbounded, since a bare `JoinSet` imposes no limit of its own.
456    max_parallel: usize,
457}
458
459/// Ask every seat for a design proposal, in parallel (bounded by
460/// `ctx.max_parallel`), headless and read-only, each in its own disposable
461/// worktree (`worktrees[i]` for `seats[i]`).
462///
463/// Failures are per-seat, not fatal to the wave: a seat that crashes or
464/// answers unparsably still produces an [`AdvisorRecord`], so one bad seat
465/// does not cost the operator the other two.
466async fn gather(
467    seats: &[AgentSpec],
468    requirements: &str,
469    worktrees: &[PathBuf],
470    ctx: &GatherCtx<'_>,
471) -> Advice {
472    let n = seats.len();
473    let sem = Arc::new(Semaphore::new(ctx.max_parallel.max(1)));
474    let mut set = tokio::task::JoinSet::new();
475    for (i, spec) in seats.iter().cloned().enumerate() {
476        let cwd = worktrees[i].clone();
477        let requirements = requirements.to_owned();
478        let artifacts = ctx.artifacts.to_owned();
479        let run = ctx.run.to_owned();
480        let language = ctx.language.to_owned();
481        let timeout = ctx.timeout;
482        let seed = ctx.seed;
483        let sem = Arc::clone(&sem);
484        let key = format!("advisor-{}", i + 1);
485        set.spawn(async move {
486            let _permit = sem.acquire().await;
487            let mut seat = SeatState::new(&key, &spec.id, seed ^ (i as u64 + 1));
488            let prompt = prompt::advisor(&requirements, i + 1, n, &language);
489            let started = Instant::now();
490            let outcome = agent::invoke(
491                &spec,
492                &mut seat,
493                &Invocation {
494                    cwd: &cwd,
495                    prompt: &prompt,
496                    timeout,
497                    allow_write: false,
498                    sessions: false,
499                    artifacts: &artifacts,
500                    stem: &key,
501                    run: &run,
502                    node: "plan-advise",
503                    cache_dir: None,
504                },
505            )
506            .await;
507            to_record(key, spec.id, started.elapsed(), outcome)
508        });
509    }
510    let mut records = Vec::with_capacity(n);
511    while let Some(res) = set.join_next().await {
512        records.push(match res {
513            Ok(rec) => rec,
514            Err(e) => AdvisorRecord {
515                seat: "?".to_owned(),
516                agent: "?".to_owned(),
517                proposal: None,
518                error: Some(format!("advisor task panicked: {e}")),
519                duration_ms: 0,
520            },
521        });
522    }
523    // Stable seat order for a readable record: a `JoinSet` completes in
524    // whichever order the seats actually answered, not seat 1, 2, 3.
525    records.sort_by(|a, b| a.seat.cmp(&b.seat));
526    Advice {
527        records,
528        synthesized: false,
529    }
530}
531
532fn to_record(
533    seat: String,
534    agent_id: String,
535    elapsed: Duration,
536    outcome: Result<agent::AgentOutput>,
537) -> AdvisorRecord {
538    match outcome {
539        Ok(out) if out.usable() => {
540            match verdict::extract_json::<Proposal>(&out.text)
541                .and_then(|p| p.validate().map(|()| p))
542            {
543                Ok(proposal) => AdvisorRecord {
544                    seat,
545                    agent: agent_id,
546                    proposal: Some(proposal),
547                    error: None,
548                    duration_ms: out.duration_ms,
549                },
550                Err(e) => AdvisorRecord {
551                    seat,
552                    agent: agent_id,
553                    proposal: None,
554                    error: Some(e.to_string()),
555                    duration_ms: out.duration_ms,
556                },
557            }
558        }
559        Ok(out) => AdvisorRecord {
560            seat,
561            agent: agent_id,
562            proposal: None,
563            error: Some(if out.timed_out {
564                "timed out".to_owned()
565            } else {
566                format!("exit {:?}: {}", out.exit_code, out.text.trim())
567            }),
568            duration_ms: out.duration_ms,
569        },
570        Err(e) => AdvisorRecord {
571            seat,
572            agent: agent_id,
573            proposal: None,
574            error: Some(e.to_string()),
575            duration_ms: elapsed.as_millis() as u64,
576        },
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::config::{AgentKind, Graph, Roles};
584
585    /// A `kind = "command"` agent that discards its prompt and prints `output`
586    /// verbatim: `cat` drains stdin so `invoke`'s writer never blocks, and the
587    /// heredoc's quoted delimiter keeps `sh` from expanding anything inside
588    /// `output` - the same trick a JSON block full of `{`/`}` and a `task`
589    /// fence full of markdown both need.
590    fn command(id: &str, output: &str) -> AgentSpec {
591        AgentSpec {
592            id: id.to_owned(),
593            kind: AgentKind::Command,
594            model: None,
595            command: vec![
596                "sh".to_owned(),
597                "-c".to_owned(),
598                format!("cat >/dev/null && cat <<'EOF'\n{output}\nEOF"),
599            ],
600            extra_args: Vec::new(),
601            env: Default::default(),
602            prompt_delivery: None,
603        }
604    }
605
606    fn proposal_json(approach: &str) -> String {
607        format!(
608            "```json\n{{\"approach\":\"{approach}\",\"key_tradeoff\":\"t\",\
609             \"risks\":[\"r\"],\"touches\":[\"src/a.rs\"],\
610             \"why_not_naive\":\"w\"}}\n```"
611        )
612    }
613
614    fn good_draft() -> String {
615        "# Rework the config loader\n\
616         \n\
617         ## Context\n\
618         \n\
619         placeholder context.\n\
620         \n\
621         ## Change\n\
622         \n\
623         placeholder change.\n\
624         \n\
625         ## Constraints\n\
626         \n\
627         No new dependencies.\n\
628         \n\
629         ## Completion criteria\n\
630         \n\
631         - [ ] it works\n\
632         \n\
633         ## Out of scope\n\
634         \n\
635         nothing\n"
636            .to_owned()
637    }
638
639    fn synthesized_task_block() -> String {
640        format!(
641            "```task\n{}```",
642            good_draft().replace("placeholder", "synthesized")
643        )
644    }
645
646    /// A real git repository with one commit, so `checkout_worktrees` has a
647    /// `HEAD` to detach from. A plain temp directory is enough for the tests
648    /// that fail before that point (no draft, zero advisors); only the ones
649    /// that reach the disposable checkout need this.
650    fn init_repo(dir: &Path) {
651        let run = |args: &[&str]| {
652            let out = std::process::Command::new("git")
653                .args(args)
654                .current_dir(dir)
655                .output()
656                .expect("spawn git");
657            assert!(
658                out.status.success(),
659                "git {args:?} failed: {}",
660                String::from_utf8_lossy(&out.stderr)
661            );
662        };
663        std::fs::create_dir_all(dir).unwrap();
664        run(&["init", "-b", "main"]);
665        run(&["config", "user.name", "magi test"]);
666        run(&["config", "user.email", "magi@example.com"]);
667        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
668        run(&["add", "-A"]);
669        run(&["commit", "-m", "init"]);
670    }
671
672    #[tokio::test]
673    async fn gather_records_every_seat_including_one_that_fails() {
674        let seats = vec![
675            command("sage-a", &proposal_json("do X")),
676            command("sage-b", "not json at all"),
677        ];
678        let dir = tempfile::tempdir().unwrap();
679        let worktrees = vec![dir.path().join("wt-1"), dir.path().join("wt-2")];
680        for wt in &worktrees {
681            std::fs::create_dir_all(wt).unwrap();
682        }
683        let advice = gather(
684            &seats,
685            "the requirements",
686            &worktrees,
687            &GatherCtx {
688                artifacts: &dir.path().join("artifacts"),
689                run: "test-run",
690                language: "en",
691                timeout: Duration::from_secs(30),
692                seed: 7,
693                max_parallel: 4,
694            },
695        )
696        .await;
697
698        assert_eq!(advice.records.len(), 2);
699        assert_eq!(advice.records[0].seat, "advisor-1");
700        assert_eq!(advice.records[1].seat, "advisor-2");
701        let ok = advice.records[0]
702            .proposal
703            .as_ref()
704            .expect("advisor-1 parses");
705        assert_eq!(ok.approach, "do X");
706        assert!(advice.records[1].proposal.is_none());
707        assert!(advice.records[1].error.is_some());
708    }
709
710    /// A path suitable for embedding in a `sh -c` command string on every
711    /// platform this runs on: `sh` on Windows is Git for Windows' MSYS build,
712    /// which understands drive-letter paths but not the backslashes
713    /// [`Path::display`] renders them with.
714    fn sh_path(p: &Path) -> String {
715        p.to_string_lossy().replace('\\', "/")
716    }
717
718    /// Reported: `gather` queued `seats.len()` jobs on a bare `JoinSet` with
719    /// nothing capping how many ran at once, unlike `graph::ask_json_wave`'s
720    /// `Semaphore`. Invisible at the roster's default width (3 advisors, 4
721    /// `max_parallel`) but a real budget breach for any roster wider than
722    /// that. Each seat marks itself active in a shared directory for the
723    /// length of its (fake) work, so the test can observe how many were
724    /// running at once from outside the wave.
725    #[tokio::test]
726    async fn gather_never_exceeds_max_parallel_seats_at_once() {
727        let dir = tempfile::tempdir().unwrap();
728        let active = dir.path().join("active");
729        std::fs::create_dir_all(&active).unwrap();
730
731        let n = 4usize;
732        let cap = 2usize;
733        let seats: Vec<AgentSpec> = (0..n)
734            .map(|i| {
735                let marker = sh_path(&active.join(format!("adv-{i}")));
736                AgentSpec {
737                    id: format!("sage-{i}"),
738                    kind: AgentKind::Command,
739                    model: None,
740                    command: vec![
741                        "sh".to_owned(),
742                        "-c".to_owned(),
743                        format!(
744                            "cat >/dev/null && touch '{marker}' && sleep 0.5 && \
745                             rm -f '{marker}' && cat <<'EOF'\n{}\nEOF",
746                            proposal_json("do X"),
747                        ),
748                    ],
749                    extra_args: Vec::new(),
750                    env: Default::default(),
751                    prompt_delivery: None,
752                }
753            })
754            .collect();
755
756        let worktrees: Vec<PathBuf> = (0..n).map(|i| dir.path().join(format!("wt-{i}"))).collect();
757        for wt in &worktrees {
758            std::fs::create_dir_all(wt).unwrap();
759        }
760
761        let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
762        let done_setter = Arc::clone(&done);
763        let artifacts = dir.path().join("artifacts");
764
765        let handle = tokio::spawn(async move {
766            let advice = gather(
767                &seats,
768                "the requirements",
769                &worktrees,
770                &GatherCtx {
771                    artifacts: &artifacts,
772                    run: "test-run",
773                    language: "en",
774                    timeout: Duration::from_secs(30),
775                    seed: 7,
776                    max_parallel: cap,
777                },
778            )
779            .await;
780            done_setter.store(true, std::sync::atomic::Ordering::SeqCst);
781            advice
782        });
783
784        let mut max_seen = 0usize;
785        for _ in 0..300 {
786            let count = std::fs::read_dir(&active).map(Iterator::count).unwrap_or(0);
787            max_seen = max_seen.max(count);
788            if done.load(std::sync::atomic::Ordering::SeqCst) {
789                break;
790            }
791            tokio::time::sleep(Duration::from_millis(20)).await;
792        }
793        let advice = handle.await.unwrap();
794
795        assert_eq!(advice.records.len(), n);
796        assert!(
797            max_seen <= cap,
798            "at most {cap} advisor seat(s) may be mid-invocation at once when \
799             `[graph] max_parallel` is {cap}, but saw {max_seen} active at once"
800        );
801    }
802
803    fn config(agents: Vec<AgentSpec>, advisors: usize) -> Config {
804        Config {
805            agents,
806            roles: Roles {
807                advisors: vec!["sage-a".to_owned(), "sage-b".to_owned()],
808                planner: Some("planner".to_owned()),
809                ..Roles::default()
810            },
811            graph: Graph {
812                advisors,
813                ..Graph::default()
814            },
815            ..Config::default()
816        }
817    }
818
819    /// This does not prove a seat *cannot* write - `allow_write: false` is
820    /// not enforced by every CLI kind (opencode has no read-only mode at
821    /// all), and nothing here forbids an absolute-path write either. What it
822    /// proves is the gap that was unique to this stage: a relative-path
823    /// write from a seat that ignores its instructions used to land in the
824    /// operator's own repository - the one directory this stage must never
825    /// touch - and now lands in that seat's own disposable checkout instead.
826    #[tokio::test]
827    async fn a_relative_path_write_from_an_advisor_lands_in_its_worktree_not_the_operators_repository()
828     {
829        let tmp = tempfile::tempdir().unwrap();
830        let repo = tmp.path().join("repo");
831        init_repo(&repo);
832        let dir = tmp.path().join("drafts");
833        std::fs::create_dir_all(&dir).unwrap();
834        let draft = dir.join("20260906-000000-kl12.md");
835        std::fs::write(&draft, good_draft()).unwrap();
836
837        // Ignores its own read-only instruction and writes a file anyway -
838        // standing in for a CLI kind (or a Bash tool) `allow_write: false`
839        // does not actually stop.
840        let writer = AgentSpec {
841            id: "sage-a".to_owned(),
842            kind: AgentKind::Command,
843            model: None,
844            command: vec![
845                "sh".to_owned(),
846                "-c".to_owned(),
847                format!(
848                    "cat >/dev/null && touch leaked-by-advisor.txt && cat <<'EOF'\n{}\nEOF",
849                    proposal_json("do X")
850                ),
851            ],
852            extra_args: Vec::new(),
853            env: Default::default(),
854            prompt_delivery: None,
855        };
856
857        let cfg = config(
858            vec![
859                writer,
860                command("sage-b", &proposal_json("do Y")),
861                command("planner", &synthesized_task_block()),
862            ],
863            2,
864        );
865
866        run(&cfg, &repo, &draft, &dir, "20260906-000000-kl12")
867            .await
868            .expect("deliberation still succeeds even though a seat wrote something");
869
870        assert!(
871            !repo.join("leaked-by-advisor.txt").exists(),
872            "an advisor's write must land in its disposable worktree, never in the operator's repository"
873        );
874    }
875
876    #[tokio::test]
877    async fn run_writes_the_raw_records_and_overwrites_the_draft_with_the_synthesis() {
878        let tmp = tempfile::tempdir().unwrap();
879        let repo = tmp.path().join("repo");
880        init_repo(&repo);
881        let dir = tmp.path().join("drafts");
882        std::fs::create_dir_all(&dir).unwrap();
883        let draft = dir.join("20260906-000000-ab12.md");
884        std::fs::write(&draft, good_draft()).unwrap();
885
886        let cfg = config(
887            vec![
888                command("sage-a", &proposal_json("do X")),
889                command("sage-b", &proposal_json("do Y")),
890                command("planner", &synthesized_task_block()),
891            ],
892            2,
893        );
894
895        let advice = run(&cfg, &repo, &draft, &dir, "20260906-000000-ab12")
896            .await
897            .expect("deliberation succeeds");
898        assert_eq!(advice.proposals().len(), 2);
899
900        let advice_path = dir.join("20260906-000000-ab12.advisors.json");
901        let raw = std::fs::read_to_string(&advice_path).expect("raw record on disk");
902        let reread: Advice = serde_json::from_str(&raw).expect("parses back");
903        assert_eq!(reread.records.len(), 2);
904        assert!(
905            reread.synthesized,
906            "a deliberation that overwrote the draft must record itself as synthesized on disk"
907        );
908        assert!(advice.synthesized);
909
910        let final_draft = std::fs::read_to_string(&draft).unwrap();
911        assert!(
912            final_draft.contains("synthesized context"),
913            "the draft must be overwritten with the synthesis: {final_draft}"
914        );
915        assert!(final_draft.contains("## Completion criteria"));
916
917        // The disposable checkouts must not survive a successful run - a
918        // seat's worktree left behind would be exactly the write surface this
919        // whole isolation exists to avoid leaving around.
920        assert!(
921            !dir.join("20260906-000000-ab12.repo").exists(),
922            "advisor worktrees must be cleaned up after the run"
923        );
924    }
925
926    #[tokio::test]
927    async fn run_leaves_the_draft_untouched_when_no_advisor_produces_a_proposal() {
928        let tmp = tempfile::tempdir().unwrap();
929        let repo = tmp.path().join("repo");
930        init_repo(&repo);
931        let dir = tmp.path().join("drafts");
932        std::fs::create_dir_all(&dir).unwrap();
933        let draft = dir.join("20260906-000000-cd34.md");
934        let original = good_draft();
935        std::fs::write(&draft, &original).unwrap();
936
937        let cfg = config(
938            vec![
939                command("sage-a", "garbage"),
940                command("sage-b", "also garbage"),
941                command("planner", &synthesized_task_block()),
942            ],
943            2,
944        );
945
946        let err = run(&cfg, &repo, &draft, &dir, "20260906-000000-cd34")
947            .await
948            .expect_err("no proposal must fail the stage");
949        let msg = err.to_string();
950        assert!(msg.contains(&draft.display().to_string()), "{msg}");
951        assert!(msg.contains("magi task add --file"), "{msg}");
952        assert_eq!(
953            std::fs::read_to_string(&draft).unwrap(),
954            original,
955            "the interview draft must survive a total advisor failure"
956        );
957        // The raw attempt is still on disk, garbage and all - the operator can
958        // read why every seat failed even though nothing was usable.
959        let advice_path = dir.join("20260906-000000-cd34.advisors.json");
960        assert!(advice_path.is_file());
961        let reread: Advice =
962            serde_json::from_str(&std::fs::read_to_string(&advice_path).unwrap()).unwrap();
963        assert!(
964            !reread.synthesized,
965            "a total advisor failure must not record this deliberation as synthesized"
966        );
967    }
968
969    #[tokio::test]
970    async fn run_leaves_the_draft_untouched_when_the_planner_replies_with_no_task_block() {
971        let tmp = tempfile::tempdir().unwrap();
972        let repo = tmp.path().join("repo");
973        init_repo(&repo);
974        let dir = tmp.path().join("drafts");
975        std::fs::create_dir_all(&dir).unwrap();
976        let draft = dir.join("20260906-000000-ef56.md");
977        let original = good_draft();
978        std::fs::write(&draft, &original).unwrap();
979
980        let cfg = config(
981            vec![
982                command("sage-a", &proposal_json("do X")),
983                command("sage-b", &proposal_json("do Y")),
984                command("planner", "sure, here is my answer with no fence"),
985            ],
986            2,
987        );
988
989        let err = run(&cfg, &repo, &draft, &dir, "20260906-000000-ef56")
990            .await
991            .expect_err("a synthesis with no task block must fail the stage");
992        let msg = err.to_string();
993        assert!(msg.contains(&draft.display().to_string()), "{msg}");
994        assert_eq!(std::fs::read_to_string(&draft).unwrap(), original);
995
996        let advice_path = dir.join("20260906-000000-ef56.advisors.json");
997        let reread: Advice =
998            serde_json::from_str(&std::fs::read_to_string(&advice_path).unwrap()).unwrap();
999        assert!(
1000            !reread.synthesized,
1001            "a planner reply with no task block must not record this deliberation as synthesized"
1002        );
1003    }
1004
1005    /// Reported: `chat::extract_draft` accepts an unclosed fence as whatever
1006    /// came before end of stream, so a planner reply that stops mid-sentence
1007    /// - not a timeout, `out.usable()` is still true - used to overwrite a
1008    /// perfectly good interview draft with a stub `vet` then rejected one
1009    /// call later, by which point the original requirements were gone.
1010    #[tokio::test]
1011    async fn run_leaves_the_draft_untouched_when_the_synthesis_fence_never_closes() {
1012        let tmp = tempfile::tempdir().unwrap();
1013        let repo = tmp.path().join("repo");
1014        init_repo(&repo);
1015        let dir = tmp.path().join("drafts");
1016        std::fs::create_dir_all(&dir).unwrap();
1017        let draft = dir.join("20260906-000000-ij90.md");
1018        let original = good_draft();
1019        std::fs::write(&draft, &original).unwrap();
1020
1021        let cfg = config(
1022            vec![
1023                command("sage-a", &proposal_json("do X")),
1024                command("sage-b", &proposal_json("do Y")),
1025                command("planner", "```task\n# incomplete"),
1026            ],
1027            2,
1028        );
1029
1030        let err = run(&cfg, &repo, &draft, &dir, "20260906-000000-ij90")
1031            .await
1032            .expect_err("an incomplete synthesis must not become the task file");
1033        let msg = err.to_string();
1034        assert!(msg.contains(&draft.display().to_string()), "{msg}");
1035        assert!(msg.contains("not a usable task file"), "{msg}");
1036        assert_eq!(
1037            std::fs::read_to_string(&draft).unwrap(),
1038            original,
1039            "the interview draft must survive an incomplete synthesis"
1040        );
1041
1042        let advice_path = dir.join("20260906-000000-ij90.advisors.json");
1043        let reread: Advice =
1044            serde_json::from_str(&std::fs::read_to_string(&advice_path).unwrap()).unwrap();
1045        assert!(
1046            !reread.synthesized,
1047            "a rejected synthesis must not record this deliberation as synthesized"
1048        );
1049    }
1050
1051    #[tokio::test]
1052    async fn run_reports_a_missing_draft_against_the_path_the_leader_was_given() {
1053        let tmp = tempfile::tempdir().unwrap();
1054        let dir = tmp.path().join("drafts");
1055        std::fs::create_dir_all(&dir).unwrap();
1056        let draft = dir.join("never-written.md");
1057
1058        let cfg = config(vec![command("sage-a", &proposal_json("x"))], 1);
1059        let msg = run(&cfg, tmp.path(), &draft, &dir, "never-written")
1060            .await
1061            .expect_err("nothing to deliberate over")
1062            .to_string();
1063        assert!(msg.contains(&draft.display().to_string()), "{msg}");
1064    }
1065
1066    #[tokio::test]
1067    async fn zero_advisors_is_an_error_that_still_names_the_draft() {
1068        let tmp = tempfile::tempdir().unwrap();
1069        let dir = tmp.path().join("drafts");
1070        std::fs::create_dir_all(&dir).unwrap();
1071        let draft = dir.join("20260906-000000-gh78.md");
1072        std::fs::write(&draft, good_draft()).unwrap();
1073
1074        let cfg = config(vec![command("sage-a", &proposal_json("x"))], 0);
1075        let msg = run(&cfg, tmp.path(), &draft, &dir, "20260906-000000-gh78")
1076            .await
1077            .expect_err("nobody to deliberate with")
1078            .to_string();
1079        assert!(msg.contains("advisors` is 0"), "{msg}");
1080        assert!(msg.contains(&draft.display().to_string()), "{msg}");
1081    }
1082
1083    /// Reported: `config.advisors()` failing (e.g. a `[roles] advisors` id
1084    /// that is not in the roster) surfaced only "resolving advisor seats:
1085    /// no agent with id `nope` in the roster", with no mention of where the
1086    /// interview draft the operator still has to salvage actually lives -
1087    /// every other failure path in this module names it.
1088    #[tokio::test]
1089    async fn an_unresolvable_advisor_seat_still_names_the_draft() {
1090        let tmp = tempfile::tempdir().unwrap();
1091        let dir = tmp.path().join("drafts");
1092        std::fs::create_dir_all(&dir).unwrap();
1093        let draft = dir.join("20260906-000000-jk90.md");
1094        let original = good_draft();
1095        std::fs::write(&draft, &original).unwrap();
1096
1097        let cfg = Config {
1098            agents: vec![command("sage-a", &proposal_json("x"))],
1099            roles: Roles {
1100                advisors: vec!["nope".to_owned()],
1101                planner: Some("planner".to_owned()),
1102                ..Roles::default()
1103            },
1104            graph: Graph {
1105                advisors: 1,
1106                ..Graph::default()
1107            },
1108            ..Config::default()
1109        };
1110
1111        let err = run(&cfg, tmp.path(), &draft, &dir, "20260906-000000-jk90")
1112            .await
1113            .expect_err("an advisor id absent from the roster must not resolve");
1114        let msg = format!("{err:#}");
1115        assert!(msg.contains("nope"), "{msg}");
1116        assert!(msg.contains(&draft.display().to_string()), "{msg}");
1117        assert_eq!(
1118            std::fs::read_to_string(&draft).unwrap(),
1119            original,
1120            "a seat-resolution failure must leave the interview draft untouched"
1121        );
1122    }
1123}