1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct AdvisorRecord {
83 pub seat: String,
85 pub agent: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub proposal: Option<Proposal>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub error: Option<String>,
93 pub duration_ms: u64,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, Default)]
102pub struct Advice {
103 pub records: Vec<AdvisorRecord>,
105 #[serde(default)]
124 pub synthesized: bool,
125}
126
127impl Advice {
128 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
137pub 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 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 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
215async 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 remove_worktrees(repo, &paths).await;
233 return Err(e);
234 }
235 paths.push(wt);
236 }
237 Ok(paths)
238}
239
240async 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 if let Some(root) = worktrees.first().and_then(|w| w.parent()) {
256 let _ = std::fs::remove_dir(root);
257 }
258}
259
260struct 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
273async 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 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 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 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 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
442struct GatherCtx<'a> {
446 artifacts: &'a Path,
447 run: &'a str,
448 language: &'a str,
449 timeout: Duration,
450 seed: u64,
451 max_parallel: usize,
457}
458
459async 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 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 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 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 fn sh_path(p: &Path) -> String {
715 p.to_string_lossy().replace('\\', "/")
716 }
717
718 #[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 #[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 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 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 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 #[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 #[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}