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::time::{Duration, Instant};
64
65use anyhow::{Context as _, Result, bail};
66use serde::{Deserialize, Serialize};
67
68use crate::agent::{self, Invocation, SeatState};
69use crate::chat;
70use crate::config::{AgentSpec, Config};
71use crate::git;
72use crate::plan;
73use crate::prompt;
74use crate::verdict::{self, Proposal};
75
76/// One advisor seat's outcome, kept even on failure so a synthesis that only
77/// had two of three proposals to work with is not a mystery later - see
78/// [`run`]'s doc.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct AdvisorRecord {
81    /// Seat name, e.g. `advisor-1`.
82    pub seat: String,
83    /// Agent id occupying the seat.
84    pub agent: String,
85    /// The proposal, when the seat produced a usable one.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub proposal: Option<Proposal>,
88    /// Why there is no proposal, when there is not one.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub error: Option<String>,
91    /// Wall-clock duration.
92    pub duration_ms: u64,
93}
94
95/// The whole deliberation: one record per advisor seat, written to
96/// `<id>.advisors.json` next to the draft so the operator can read every
97/// seat's reasoning - including a seat that failed - not only whichever parts
98/// synthesis kept.
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct Advice {
101    /// One record per advisor seat asked.
102    pub records: Vec<AdvisorRecord>,
103}
104
105impl Advice {
106    /// The seats that produced a usable proposal, in seat order.
107    pub fn proposals(&self) -> Vec<(&str, &Proposal)> {
108        self.records
109            .iter()
110            .filter_map(|r| r.proposal.as_ref().map(|p| (r.seat.as_str(), p)))
111            .collect()
112    }
113}
114
115/// Run the design-deliberation stage: gather independent proposals, write the
116/// raw record, synthesize them under the planner seat, and overwrite `draft`
117/// with the result.
118///
119/// `dir` is the drafts directory the raw record and the seats' artifacts are
120/// written under; `id` names this interview's draft, so its siblings
121/// (`<id>.advisors.json`, `<id>.advisors/`) sit next to `<id>.md` the same way
122/// `<id>.briefing.md` already does.
123pub async fn run(
124    config: &Config,
125    repo: &Path,
126    draft: &Path,
127    dir: &Path,
128    id: &str,
129) -> Result<Advice> {
130    let requirements = std::fs::read_to_string(draft).with_context(|| {
131        format!(
132            "no task file at {} - the leader was asked to write one there",
133            draft.display()
134        )
135    })?;
136
137    let seats = config.advisors().context("resolving advisor seats")?;
138    if seats.is_empty() {
139        bail!(
140            "`[graph] advisors` is 0, so there is nobody to deliberate with; \
141             the interview draft is unchanged at {0} - file it as-is with \
142             `magi task add --file {0}`, or set `[graph] advisors` above 0 \
143             and re-run `magi plan`.",
144            draft.display()
145        );
146    }
147
148    let worktrees = checkout_worktrees(repo, dir, id, seats.len())
149        .await
150        .with_context(|| {
151            format!(
152                "could not prepare a disposable checkout for the advisor \
153                 seats; the interview draft is unchanged at {d} - file it \
154                 as-is with `magi task add --file {d}`, or retry `magi plan`.",
155                d = draft.display(),
156            )
157        })?;
158
159    // Split out so the worktrees are removed on every path out of here,
160    // success or failure - Rust has no `try`/`finally` to hang this off of.
161    let outcome = deliberate(
162        &requirements,
163        &seats,
164        &worktrees,
165        &DeliberationCtx {
166            config,
167            draft,
168            dir,
169            id,
170            language: &config.graph.language,
171            // Read + reason, no write: the same shape of work `[graph]
172            // timeout_judge` already budgets for judges, so a stage-specific
173            // timeout nobody asked for would be one more number to tune for
174            // no benefit.
175            timeout: Duration::from_secs(config.graph.timeout_judge.max(1)),
176            seed: crate::rng::entropy(),
177        },
178    )
179    .await;
180
181    remove_worktrees(repo, &worktrees).await;
182
183    outcome
184}
185
186/// Disposable, detached worktrees at `HEAD`, one per advisor seat - see the
187/// module doc's "Disposability narrows the blast radius" section for why a
188/// seat needs one of these rather than the operator's own checkout, and for
189/// what it does and does not protect against.
190///
191/// Sequential, not parallel: `git worktree add` takes a lock on the
192/// repository's own `.git` metadata, and setup is a one-time cost paid once
193/// per `magi plan` invocation, not on the hot path a parallel seat wave
194/// exists to keep cheap.
195async fn checkout_worktrees(repo: &Path, dir: &Path, id: &str, n: usize) -> Result<Vec<PathBuf>> {
196    let root = dir.join(format!("{id}.repo"));
197    let mut paths = Vec::with_capacity(n);
198    for i in 0..n {
199        let wt = root.join(format!("advisor-{}", i + 1));
200        if let Err(e) = git::worktree_add_detached(repo, &wt, "HEAD").await {
201            // Partial setup must not leak the worktrees it did manage to
202            // register before the failure that stopped it.
203            remove_worktrees(repo, &paths).await;
204            return Err(e);
205        }
206        paths.push(wt);
207    }
208    Ok(paths)
209}
210
211/// Best-effort teardown. A worktree `magi plan` fails to remove costs the
212/// operator disk, not correctness - the advisor stage already answered or
213/// already failed by the time this runs - so a removal error is logged and
214/// moved past rather than turned into a second error on top of whatever
215/// [`run`] is already returning.
216async fn remove_worktrees(repo: &Path, worktrees: &[PathBuf]) {
217    for wt in worktrees {
218        if let Err(e) = git::worktree_remove(repo, wt).await {
219            tracing::warn!(
220                "could not remove disposable advisor worktree {}: {e:#}",
221                wt.display()
222            );
223        }
224    }
225    // Only succeeds once every child above is gone; harmless otherwise.
226    if let Some(root) = worktrees.first().and_then(|w| w.parent()) {
227        let _ = std::fs::remove_dir(root);
228    }
229}
230
231/// Everything [`deliberate`] needs once a disposable checkout exists per
232/// seat, bundled so the function takes one borrow instead of a parameter per
233/// field - the same reason [`crate::graph`]'s wave takes a `WaveCtx`.
234struct DeliberationCtx<'a> {
235    config: &'a Config,
236    draft: &'a Path,
237    dir: &'a Path,
238    id: &'a str,
239    language: &'a str,
240    timeout: Duration,
241    seed: u64,
242}
243
244/// The body of [`run`]: gather, record, synthesize, validate, write. Split out
245/// only so [`run`] can guarantee `worktrees` are removed on every exit from
246/// this, not so it can be called independently of a checkout existing.
247async fn deliberate(
248    requirements: &str,
249    seats: &[AgentSpec],
250    worktrees: &[PathBuf],
251    ctx: &DeliberationCtx<'_>,
252) -> Result<Advice> {
253    let draft = ctx.draft;
254    let artifacts = ctx.dir.join(format!("{}.advisors", ctx.id));
255
256    let advice = gather(
257        seats,
258        requirements,
259        worktrees,
260        &GatherCtx {
261            artifacts: &artifacts,
262            run: ctx.id,
263            language: ctx.language,
264            timeout: ctx.timeout,
265            seed: ctx.seed,
266        },
267    )
268    .await;
269
270    // Written before the checks below can bail: a total failure must still
271    // leave the raw attempts on disk, or "nobody produced a proposal" is a
272    // claim the operator has no way to check.
273    let advice_path = ctx.dir.join(format!("{}.advisors.json", ctx.id));
274    std::fs::write(
275        &advice_path,
276        serde_json::to_string_pretty(&advice).context("serialize the advisor records")?,
277    )
278    .with_context(|| format!("write {}", advice_path.display()))?;
279
280    let proposals = advice.proposals();
281    if proposals.is_empty() {
282        bail!(
283            "none of {n} advisor seat(s) produced a usable design proposal \
284             (see {record}); the interview draft is unchanged at {d} - file \
285             it as-is with `magi task add --file {d}`, or retry `magi plan`.",
286            n = seats.len(),
287            record = advice_path.display(),
288            d = draft.display(),
289        );
290    }
291
292    let planner = plan::pick(
293        &ctx.config.agents,
294        ctx.config.roles.planner.as_deref(),
295        &plan::installed,
296    )
297    .context("resolving the planner seat for design synthesis")?;
298    let mut seat = SeatState::new("plan-synthesis", &planner.id, ctx.seed);
299    let synth_prompt = prompt::synthesize(requirements, &proposals, ctx.language);
300    let out = agent::invoke(
301        &planner,
302        &mut seat,
303        &Invocation {
304            // The first advisor's disposable checkout, reused: every advisor
305            // task has already finished by this point (`gather` awaited them
306            // all), so there is nothing left to race with, and a fourth
307            // checkout just for synthesis would buy nothing this one does
308            // not already give it - a read-only view of the repository that
309            // is not the operator's own.
310            cwd: &worktrees[0],
311            prompt: &synth_prompt,
312            timeout: ctx.timeout,
313            allow_write: false,
314            sessions: false,
315            artifacts: &artifacts,
316            stem: "synthesis",
317            run: ctx.id,
318            node: "plan-advise",
319            cache_dir: None,
320        },
321    )
322    .await
323    .with_context(|| {
324        format!(
325            "the planner seat could not synthesize the design proposals; the \
326             interview draft is unchanged at {0} - file it as-is with `magi \
327             task add --file {0}`, or retry `magi plan`.",
328            draft.display()
329        )
330    })?;
331
332    if !out.usable() {
333        bail!(
334            "the planner seat produced nothing usable while synthesizing the \
335             design proposals; the interview draft is unchanged at {0} - file \
336             it as-is with `magi task add --file {0}`, or retry `magi plan`.",
337            draft.display()
338        );
339    }
340
341    let synthesized = chat::extract_draft(&out.text).with_context(|| {
342        format!(
343            "the planner seat's reply had no fenced ```task block; the \
344             interview draft is unchanged at {0} - file it as-is with `magi \
345             task add --file {0}`, or retry `magi plan`.",
346            draft.display()
347        )
348    })?;
349
350    // Checked before a single byte reaches `draft`: `extract_draft` accepts an
351    // unclosed fence as "whatever came before end of stream", so a synthesis
352    // that stopped mid-sentence - a truncated reply, not a system timeout -
353    // would otherwise overwrite a perfectly good interview draft with
354    // something `vet` rejects one call later, at which point the operator's
355    // requirements are already gone. The same shape `vet` itself uses: length
356    // alone warns rather than refuses, everything else must hold.
357    if let Err(problems) = plan::review_draft(&synthesized) {
358        let hard: Vec<&String> = problems
359            .iter()
360            .filter(|p| p.as_str() != plan::SHORT_DRAFT)
361            .collect();
362        if !hard.is_empty() {
363            let list = hard
364                .iter()
365                .map(|p| format!("  - {p}"))
366                .collect::<Vec<_>>()
367                .join("\n");
368            bail!(
369                "the planner seat's synthesis is not a usable task file:\n{list}\n\n\
370                 the interview draft is unchanged at {d} - file it as-is with \
371                 `magi task add --file {d}`, or retry `magi plan`.",
372                d = draft.display(),
373            );
374        }
375    }
376
377    std::fs::write(draft, &synthesized).with_context(|| format!("write {}", draft.display()))?;
378
379    Ok(advice)
380}
381
382/// The parts of [`deliberate`]'s setup every advisor seat needs, bundled so
383/// [`gather`] takes one borrow instead of a parameter per field - the same
384/// reason [`crate::graph`]'s wave takes a `WaveCtx`.
385struct GatherCtx<'a> {
386    artifacts: &'a Path,
387    run: &'a str,
388    language: &'a str,
389    timeout: Duration,
390    seed: u64,
391}
392
393/// Ask every seat for a design proposal, in parallel, headless and read-only,
394/// each in its own disposable worktree (`worktrees[i]` for `seats[i]`).
395///
396/// Failures are per-seat, not fatal to the wave: a seat that crashes or
397/// answers unparsably still produces an [`AdvisorRecord`], so one bad seat
398/// does not cost the operator the other two.
399async fn gather(
400    seats: &[AgentSpec],
401    requirements: &str,
402    worktrees: &[PathBuf],
403    ctx: &GatherCtx<'_>,
404) -> Advice {
405    let n = seats.len();
406    let mut set = tokio::task::JoinSet::new();
407    for (i, spec) in seats.iter().cloned().enumerate() {
408        let cwd = worktrees[i].clone();
409        let requirements = requirements.to_owned();
410        let artifacts = ctx.artifacts.to_owned();
411        let run = ctx.run.to_owned();
412        let language = ctx.language.to_owned();
413        let timeout = ctx.timeout;
414        let seed = ctx.seed;
415        let key = format!("advisor-{}", i + 1);
416        set.spawn(async move {
417            let mut seat = SeatState::new(&key, &spec.id, seed ^ (i as u64 + 1));
418            let prompt = prompt::advisor(&requirements, i + 1, n, &language);
419            let started = Instant::now();
420            let outcome = agent::invoke(
421                &spec,
422                &mut seat,
423                &Invocation {
424                    cwd: &cwd,
425                    prompt: &prompt,
426                    timeout,
427                    allow_write: false,
428                    sessions: false,
429                    artifacts: &artifacts,
430                    stem: &key,
431                    run: &run,
432                    node: "plan-advise",
433                    cache_dir: None,
434                },
435            )
436            .await;
437            to_record(key, spec.id, started.elapsed(), outcome)
438        });
439    }
440    let mut records = Vec::with_capacity(n);
441    while let Some(res) = set.join_next().await {
442        records.push(match res {
443            Ok(rec) => rec,
444            Err(e) => AdvisorRecord {
445                seat: "?".to_owned(),
446                agent: "?".to_owned(),
447                proposal: None,
448                error: Some(format!("advisor task panicked: {e}")),
449                duration_ms: 0,
450            },
451        });
452    }
453    // Stable seat order for a readable record: a `JoinSet` completes in
454    // whichever order the seats actually answered, not seat 1, 2, 3.
455    records.sort_by(|a, b| a.seat.cmp(&b.seat));
456    Advice { records }
457}
458
459fn to_record(
460    seat: String,
461    agent_id: String,
462    elapsed: Duration,
463    outcome: Result<agent::AgentOutput>,
464) -> AdvisorRecord {
465    match outcome {
466        Ok(out) if out.usable() => {
467            match verdict::extract_json::<Proposal>(&out.text)
468                .and_then(|p| p.validate().map(|()| p))
469            {
470                Ok(proposal) => AdvisorRecord {
471                    seat,
472                    agent: agent_id,
473                    proposal: Some(proposal),
474                    error: None,
475                    duration_ms: out.duration_ms,
476                },
477                Err(e) => AdvisorRecord {
478                    seat,
479                    agent: agent_id,
480                    proposal: None,
481                    error: Some(e.to_string()),
482                    duration_ms: out.duration_ms,
483                },
484            }
485        }
486        Ok(out) => AdvisorRecord {
487            seat,
488            agent: agent_id,
489            proposal: None,
490            error: Some(if out.timed_out {
491                "timed out".to_owned()
492            } else {
493                format!("exit {:?}: {}", out.exit_code, out.text.trim())
494            }),
495            duration_ms: out.duration_ms,
496        },
497        Err(e) => AdvisorRecord {
498            seat,
499            agent: agent_id,
500            proposal: None,
501            error: Some(e.to_string()),
502            duration_ms: elapsed.as_millis() as u64,
503        },
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::config::{AgentKind, Graph, Roles};
511
512    /// A `kind = "command"` agent that discards its prompt and prints `output`
513    /// verbatim: `cat` drains stdin so `invoke`'s writer never blocks, and the
514    /// heredoc's quoted delimiter keeps `sh` from expanding anything inside
515    /// `output` - the same trick a JSON block full of `{`/`}` and a `task`
516    /// fence full of markdown both need.
517    fn command(id: &str, output: &str) -> AgentSpec {
518        AgentSpec {
519            id: id.to_owned(),
520            kind: AgentKind::Command,
521            model: None,
522            command: vec![
523                "sh".to_owned(),
524                "-c".to_owned(),
525                format!("cat >/dev/null && cat <<'EOF'\n{output}\nEOF"),
526            ],
527            extra_args: Vec::new(),
528            env: Default::default(),
529            prompt_delivery: None,
530        }
531    }
532
533    fn proposal_json(approach: &str) -> String {
534        format!(
535            "```json\n{{\"approach\":\"{approach}\",\"key_tradeoff\":\"t\",\
536             \"risks\":[\"r\"],\"touches\":[\"src/a.rs\"],\
537             \"why_not_naive\":\"w\"}}\n```"
538        )
539    }
540
541    fn good_draft() -> String {
542        "# Rework the config loader\n\
543         \n\
544         ## Context\n\
545         \n\
546         placeholder context.\n\
547         \n\
548         ## Change\n\
549         \n\
550         placeholder change.\n\
551         \n\
552         ## Constraints\n\
553         \n\
554         No new dependencies.\n\
555         \n\
556         ## Completion criteria\n\
557         \n\
558         - [ ] it works\n\
559         \n\
560         ## Out of scope\n\
561         \n\
562         nothing\n"
563            .to_owned()
564    }
565
566    fn synthesized_task_block() -> String {
567        format!(
568            "```task\n{}```",
569            good_draft().replace("placeholder", "synthesized")
570        )
571    }
572
573    /// A real git repository with one commit, so `checkout_worktrees` has a
574    /// `HEAD` to detach from. A plain temp directory is enough for the tests
575    /// that fail before that point (no draft, zero advisors); only the ones
576    /// that reach the disposable checkout need this.
577    fn init_repo(dir: &Path) {
578        let run = |args: &[&str]| {
579            let out = std::process::Command::new("git")
580                .args(args)
581                .current_dir(dir)
582                .output()
583                .expect("spawn git");
584            assert!(
585                out.status.success(),
586                "git {args:?} failed: {}",
587                String::from_utf8_lossy(&out.stderr)
588            );
589        };
590        std::fs::create_dir_all(dir).unwrap();
591        run(&["init", "-b", "main"]);
592        run(&["config", "user.name", "magi test"]);
593        run(&["config", "user.email", "magi@example.com"]);
594        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
595        run(&["add", "-A"]);
596        run(&["commit", "-m", "init"]);
597    }
598
599    #[tokio::test]
600    async fn gather_records_every_seat_including_one_that_fails() {
601        let seats = vec![
602            command("sage-a", &proposal_json("do X")),
603            command("sage-b", "not json at all"),
604        ];
605        let dir = tempfile::tempdir().unwrap();
606        let worktrees = vec![dir.path().join("wt-1"), dir.path().join("wt-2")];
607        for wt in &worktrees {
608            std::fs::create_dir_all(wt).unwrap();
609        }
610        let advice = gather(
611            &seats,
612            "the requirements",
613            &worktrees,
614            &GatherCtx {
615                artifacts: &dir.path().join("artifacts"),
616                run: "test-run",
617                language: "en",
618                timeout: Duration::from_secs(30),
619                seed: 7,
620            },
621        )
622        .await;
623
624        assert_eq!(advice.records.len(), 2);
625        assert_eq!(advice.records[0].seat, "advisor-1");
626        assert_eq!(advice.records[1].seat, "advisor-2");
627        let ok = advice.records[0]
628            .proposal
629            .as_ref()
630            .expect("advisor-1 parses");
631        assert_eq!(ok.approach, "do X");
632        assert!(advice.records[1].proposal.is_none());
633        assert!(advice.records[1].error.is_some());
634    }
635
636    fn config(agents: Vec<AgentSpec>, advisors: usize) -> Config {
637        Config {
638            agents,
639            roles: Roles {
640                advisors: vec!["sage-a".to_owned(), "sage-b".to_owned()],
641                planner: Some("planner".to_owned()),
642                ..Roles::default()
643            },
644            graph: Graph {
645                advisors,
646                ..Graph::default()
647            },
648            ..Config::default()
649        }
650    }
651
652    /// This does not prove a seat *cannot* write - `allow_write: false` is
653    /// not enforced by every CLI kind (opencode has no read-only mode at
654    /// all), and nothing here forbids an absolute-path write either. What it
655    /// proves is the gap that was unique to this stage: a relative-path
656    /// write from a seat that ignores its instructions used to land in the
657    /// operator's own repository - the one directory this stage must never
658    /// touch - and now lands in that seat's own disposable checkout instead.
659    #[tokio::test]
660    async fn a_relative_path_write_from_an_advisor_lands_in_its_worktree_not_the_operators_repository()
661     {
662        let tmp = tempfile::tempdir().unwrap();
663        let repo = tmp.path().join("repo");
664        init_repo(&repo);
665        let dir = tmp.path().join("drafts");
666        std::fs::create_dir_all(&dir).unwrap();
667        let draft = dir.join("20260906-000000-kl12.md");
668        std::fs::write(&draft, good_draft()).unwrap();
669
670        // Ignores its own read-only instruction and writes a file anyway -
671        // standing in for a CLI kind (or a Bash tool) `allow_write: false`
672        // does not actually stop.
673        let writer = AgentSpec {
674            id: "sage-a".to_owned(),
675            kind: AgentKind::Command,
676            model: None,
677            command: vec![
678                "sh".to_owned(),
679                "-c".to_owned(),
680                format!(
681                    "cat >/dev/null && touch leaked-by-advisor.txt && cat <<'EOF'\n{}\nEOF",
682                    proposal_json("do X")
683                ),
684            ],
685            extra_args: Vec::new(),
686            env: Default::default(),
687            prompt_delivery: None,
688        };
689
690        let cfg = config(
691            vec![
692                writer,
693                command("sage-b", &proposal_json("do Y")),
694                command("planner", &synthesized_task_block()),
695            ],
696            2,
697        );
698
699        run(&cfg, &repo, &draft, &dir, "20260906-000000-kl12")
700            .await
701            .expect("deliberation still succeeds even though a seat wrote something");
702
703        assert!(
704            !repo.join("leaked-by-advisor.txt").exists(),
705            "an advisor's write must land in its disposable worktree, never in the operator's repository"
706        );
707    }
708
709    #[tokio::test]
710    async fn run_writes_the_raw_records_and_overwrites_the_draft_with_the_synthesis() {
711        let tmp = tempfile::tempdir().unwrap();
712        let repo = tmp.path().join("repo");
713        init_repo(&repo);
714        let dir = tmp.path().join("drafts");
715        std::fs::create_dir_all(&dir).unwrap();
716        let draft = dir.join("20260906-000000-ab12.md");
717        std::fs::write(&draft, good_draft()).unwrap();
718
719        let cfg = config(
720            vec![
721                command("sage-a", &proposal_json("do X")),
722                command("sage-b", &proposal_json("do Y")),
723                command("planner", &synthesized_task_block()),
724            ],
725            2,
726        );
727
728        let advice = run(&cfg, &repo, &draft, &dir, "20260906-000000-ab12")
729            .await
730            .expect("deliberation succeeds");
731        assert_eq!(advice.proposals().len(), 2);
732
733        let advice_path = dir.join("20260906-000000-ab12.advisors.json");
734        let raw = std::fs::read_to_string(&advice_path).expect("raw record on disk");
735        let reread: Advice = serde_json::from_str(&raw).expect("parses back");
736        assert_eq!(reread.records.len(), 2);
737
738        let final_draft = std::fs::read_to_string(&draft).unwrap();
739        assert!(
740            final_draft.contains("synthesized context"),
741            "the draft must be overwritten with the synthesis: {final_draft}"
742        );
743        assert!(final_draft.contains("## Completion criteria"));
744
745        // The disposable checkouts must not survive a successful run - a
746        // seat's worktree left behind would be exactly the write surface this
747        // whole isolation exists to avoid leaving around.
748        assert!(
749            !dir.join("20260906-000000-ab12.repo").exists(),
750            "advisor worktrees must be cleaned up after the run"
751        );
752    }
753
754    #[tokio::test]
755    async fn run_leaves_the_draft_untouched_when_no_advisor_produces_a_proposal() {
756        let tmp = tempfile::tempdir().unwrap();
757        let repo = tmp.path().join("repo");
758        init_repo(&repo);
759        let dir = tmp.path().join("drafts");
760        std::fs::create_dir_all(&dir).unwrap();
761        let draft = dir.join("20260906-000000-cd34.md");
762        let original = good_draft();
763        std::fs::write(&draft, &original).unwrap();
764
765        let cfg = config(
766            vec![
767                command("sage-a", "garbage"),
768                command("sage-b", "also garbage"),
769                command("planner", &synthesized_task_block()),
770            ],
771            2,
772        );
773
774        let err = run(&cfg, &repo, &draft, &dir, "20260906-000000-cd34")
775            .await
776            .expect_err("no proposal must fail the stage");
777        let msg = err.to_string();
778        assert!(msg.contains(&draft.display().to_string()), "{msg}");
779        assert!(msg.contains("magi task add --file"), "{msg}");
780        assert_eq!(
781            std::fs::read_to_string(&draft).unwrap(),
782            original,
783            "the interview draft must survive a total advisor failure"
784        );
785        // The raw attempt is still on disk, garbage and all - the operator can
786        // read why every seat failed even though nothing was usable.
787        assert!(dir.join("20260906-000000-cd34.advisors.json").is_file());
788    }
789
790    #[tokio::test]
791    async fn run_leaves_the_draft_untouched_when_the_planner_replies_with_no_task_block() {
792        let tmp = tempfile::tempdir().unwrap();
793        let repo = tmp.path().join("repo");
794        init_repo(&repo);
795        let dir = tmp.path().join("drafts");
796        std::fs::create_dir_all(&dir).unwrap();
797        let draft = dir.join("20260906-000000-ef56.md");
798        let original = good_draft();
799        std::fs::write(&draft, &original).unwrap();
800
801        let cfg = config(
802            vec![
803                command("sage-a", &proposal_json("do X")),
804                command("sage-b", &proposal_json("do Y")),
805                command("planner", "sure, here is my answer with no fence"),
806            ],
807            2,
808        );
809
810        let err = run(&cfg, &repo, &draft, &dir, "20260906-000000-ef56")
811            .await
812            .expect_err("a synthesis with no task block must fail the stage");
813        let msg = err.to_string();
814        assert!(msg.contains(&draft.display().to_string()), "{msg}");
815        assert_eq!(std::fs::read_to_string(&draft).unwrap(), original);
816    }
817
818    /// Reported: `chat::extract_draft` accepts an unclosed fence as whatever
819    /// came before end of stream, so a planner reply that stops mid-sentence
820    /// - not a timeout, `out.usable()` is still true - used to overwrite a
821    /// perfectly good interview draft with a stub `vet` then rejected one
822    /// call later, by which point the original requirements were gone.
823    #[tokio::test]
824    async fn run_leaves_the_draft_untouched_when_the_synthesis_fence_never_closes() {
825        let tmp = tempfile::tempdir().unwrap();
826        let repo = tmp.path().join("repo");
827        init_repo(&repo);
828        let dir = tmp.path().join("drafts");
829        std::fs::create_dir_all(&dir).unwrap();
830        let draft = dir.join("20260906-000000-ij90.md");
831        let original = good_draft();
832        std::fs::write(&draft, &original).unwrap();
833
834        let cfg = config(
835            vec![
836                command("sage-a", &proposal_json("do X")),
837                command("sage-b", &proposal_json("do Y")),
838                command("planner", "```task\n# incomplete"),
839            ],
840            2,
841        );
842
843        let err = run(&cfg, &repo, &draft, &dir, "20260906-000000-ij90")
844            .await
845            .expect_err("an incomplete synthesis must not become the task file");
846        let msg = err.to_string();
847        assert!(msg.contains(&draft.display().to_string()), "{msg}");
848        assert!(msg.contains("not a usable task file"), "{msg}");
849        assert_eq!(
850            std::fs::read_to_string(&draft).unwrap(),
851            original,
852            "the interview draft must survive an incomplete synthesis"
853        );
854    }
855
856    #[tokio::test]
857    async fn run_reports_a_missing_draft_against_the_path_the_leader_was_given() {
858        let tmp = tempfile::tempdir().unwrap();
859        let dir = tmp.path().join("drafts");
860        std::fs::create_dir_all(&dir).unwrap();
861        let draft = dir.join("never-written.md");
862
863        let cfg = config(vec![command("sage-a", &proposal_json("x"))], 1);
864        let msg = run(&cfg, tmp.path(), &draft, &dir, "never-written")
865            .await
866            .expect_err("nothing to deliberate over")
867            .to_string();
868        assert!(msg.contains(&draft.display().to_string()), "{msg}");
869    }
870
871    #[tokio::test]
872    async fn zero_advisors_is_an_error_that_still_names_the_draft() {
873        let tmp = tempfile::tempdir().unwrap();
874        let dir = tmp.path().join("drafts");
875        std::fs::create_dir_all(&dir).unwrap();
876        let draft = dir.join("20260906-000000-gh78.md");
877        std::fs::write(&draft, good_draft()).unwrap();
878
879        let cfg = config(vec![command("sage-a", &proposal_json("x"))], 0);
880        let msg = run(&cfg, tmp.path(), &draft, &dir, "20260906-000000-gh78")
881            .await
882            .expect_err("nobody to deliberate with")
883            .to_string();
884        assert!(msg.contains("advisors` is 0"), "{msg}");
885        assert!(msg.contains(&draft.display().to_string()), "{msg}");
886    }
887}