1use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35 AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36 ResolvedRoles,
37};
38use crate::git;
39use crate::land;
40use crate::proc::Quiet as _;
41use crate::prompt::{
42 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
43};
44use crate::queue;
45use crate::refs;
46use crate::run::{
47 BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
48 DeliberationRound, DeliberationTurn, E2eStatus, FixRecord, GateFixRecord, Handover, JobRecord,
49 JobStatus, Judgement, MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest,
50 Origin, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, Tally,
51 VoteRecord, tail, write_artifact,
52};
53use crate::verdict::{
54 self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
55 ReviewVote, Severity,
56};
57
58const OUTPUT_TAIL: usize = 8_000;
60
61const EVENT_OUTPUT_TAIL: usize = 2_000;
64
65const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
68
69const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
86
87pub(crate) const STAGNANT_LIMIT: usize = 2;
101
102const BASE_SYNC_ROUNDS: usize = 4;
115
116const MAX_FIX_CONTINUATIONS: usize = 2;
132
133#[derive(Clone)]
139struct SeatJob {
140 spec: AgentSpec,
141 seat: SeatState,
142 cwd: PathBuf,
143 prompt: String,
144 timeout: Duration,
145 allow_write: bool,
146 sessions: bool,
147 artifacts: PathBuf,
148 stem: String,
149}
150
151enum AgentOutcome {
163 Ok(AgentOutput),
165 Quota(AgentOutput),
167 Dropped(AgentOutput),
170 Failed(String),
172}
173
174#[derive(Debug, Clone, Default)]
201pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
202
203impl Pause {
204 #[must_use]
206 pub fn new() -> Self {
207 Self::default()
208 }
209
210 pub fn park(&self) {
212 self.0.store(true, Ordering::SeqCst);
213 }
214
215 pub fn park_because(&self, reason: impl Into<String>) {
221 let mut reason_guard = self
222 .1
223 .lock()
224 .unwrap_or_else(std::sync::PoisonError::into_inner);
225 if reason_guard.is_none() {
226 *reason_guard = Some(reason.into());
227 }
228 drop(reason_guard);
229 self.park();
230 }
231
232 #[must_use]
234 pub fn parked(&self) -> bool {
235 self.0.load(Ordering::SeqCst)
236 }
237
238 #[must_use]
240 pub fn reason(&self) -> Option<String> {
241 self.1
242 .lock()
243 .unwrap_or_else(std::sync::PoisonError::into_inner)
244 .clone()
245 }
246}
247
248pub struct Runner {
250 pub state: RunState,
252 roles: ResolvedRoles,
253 sem: Arc<Semaphore>,
254 pause: Pause,
258 interrupt: Pause,
264}
265
266async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
295 let tracking = format!("{remote}/{branch}");
296 let fetched = git::fetch(repo, remote, branch).await;
297 let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
298 let local_exists = git::branch_exists(repo, branch).await?;
299 if !fresh {
300 if !local_exists {
301 bail!("no branch `{branch}` in {} or on {remote}", repo.display());
302 }
303 tracing::warn!(
304 "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
305 );
306 return Ok(());
307 }
308 let remote_sha = git::rev_parse(repo, &tracking).await?;
309 if !local_exists {
310 git::git(repo, &["branch", branch, &tracking]).await?;
311 return Ok(());
312 }
313 let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
314 if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
315 return Ok(());
316 }
317 if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
318 match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
324 .await?
325 {
326 crate::reconcile::Reconciliation::Pushed => {
327 tracing::warn!(
328 "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
329 short(&local_sha),
330 short(&remote_sha)
331 );
332 return Ok(());
333 }
334 crate::reconcile::Reconciliation::Placeholder => {}
335 crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
336 }
337 }
338 let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
339 if !out.ok() {
340 bail!(
341 "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
342 short(&local_sha),
343 short(&remote_sha),
344 out.stderr
345 );
346 }
347 tracing::warn!(
348 "local `{branch}` was stale: fast-forwarded {} -> {}",
349 short(&local_sha),
350 short(&remote_sha)
351 );
352 Ok(())
353}
354
355async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
356 let tracking = format!("{remote}/{base_branch}");
357 let fetched = git::fetch(repo, remote, base_branch).await;
358 if let Ok(out) = &fetched
359 && out.ok()
360 && git::rev_exists(repo, &tracking).await
361 {
362 return git::rev_parse(repo, &tracking).await;
363 }
364 let why = match &fetched {
365 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
366 Ok(_) => format!("{remote} has no {base_branch}"),
367 Err(e) => e.to_string(),
368 };
369 tracing::warn!(
370 "could not read {tracking} ({why}); branching off the local \
371 {base_branch} instead, which may be behind"
372 );
373 git::rev_parse(repo, base_branch).await.with_context(|| {
374 format!(
375 "cannot resolve `{base_branch}`; set [merge] base in magi.toml to a \
376 branch that exists"
377 )
378 })
379}
380
381struct FixClaim {
398 path: PathBuf,
399}
400
401impl FixClaim {
402 fn acquire(dir: &Path) -> Result<Self> {
403 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
404 let path = dir.join("fix.lock");
405 match Self::create(&path) {
406 Ok(claim) => Ok(claim),
407 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
408 if Self::reclaim_if_dead(&path) {
409 Self::create(&path).with_context(|| format!("lock {}", path.display()))
410 } else {
411 bail!(
412 "another `magi fix` is already running for this run ({} exists)",
413 path.display()
414 )
415 }
416 }
417 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
418 }
419 }
420
421 fn create(path: &Path) -> std::io::Result<Self> {
422 let mut f = std::fs::OpenOptions::new()
423 .write(true)
424 .create_new(true)
425 .open(path)?;
426 use std::io::Write as _;
427 writeln!(f, "{}", std::process::id())?;
429 Ok(Self {
430 path: path.to_owned(),
431 })
432 }
433
434 fn reclaim_if_dead(path: &Path) -> bool {
438 let dead = std::fs::read_to_string(path)
439 .ok()
440 .and_then(|body| body.trim().parse::<u32>().ok())
441 .is_some_and(|pid| !crate::proc::pid_alive(pid));
442 dead && std::fs::remove_file(path).is_ok()
443 }
444}
445
446impl Drop for FixClaim {
447 fn drop(&mut self) {
448 let _ = std::fs::remove_file(&self.path);
449 }
450}
451
452impl Runner {
453 pub async fn start(
455 repo: &Path,
456 instruction: String,
457 config: Config,
458 origin: Origin,
459 ) -> Result<Self> {
460 Self::start_naming(repo, instruction, "", config, origin).await
461 }
462
463 pub async fn start_naming(
467 repo: &Path,
468 instruction: String,
469 also_scan: &str,
470 config: Config,
471 origin: Origin,
472 ) -> Result<Self> {
473 let repo = git::toplevel(repo).await?;
474 let missing = agent::missing_programs(&config.agents);
475 if !missing.is_empty() {
476 bail!(
477 "these agent programs are not on PATH: {}. Fix the roster in \
478 magi.toml or install them.",
479 missing.join(", ")
480 );
481 }
482 let base_branch = match config.merge.base.clone() {
483 Some(b) => b,
484 None => git::current_branch(&repo)
485 .await?
486 .context("HEAD is detached; set [merge] base in magi.toml")?,
487 };
488 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
489 if !git::is_clean(&repo).await? {
493 tracing::warn!(
494 "{} has uncommitted changes; they are not part of this run, \
495 which branches off {base_branch} ({})",
496 repo.display(),
497 &base_commit[..base_commit.len().min(8)]
498 );
499 }
500 let roles = config.resolve_roles()?;
501 let max_parallel = config.graph.max_parallel.max(1);
502 let seeds = refs::resolve(
505 &repo,
506 &base_commit,
507 &config.merge.remote,
508 &format!("{also_scan}\n{instruction}"),
509 )
510 .await;
511 refs::plan(&repo, &seeds).await?;
512 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
513 state.origin = Some(origin);
517 for seed in &seeds {
518 state.event(
519 "seed",
520 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
521 );
522 }
523 state.seeds = seeds;
524 state.event("start", format!("run {} created", state.id));
525 state.save()?;
526 Ok(Self {
527 state,
528 roles,
529 sem: Arc::new(Semaphore::new(max_parallel)),
530 pause: Pause::new(),
531 interrupt: Pause::new(),
532 })
533 }
534
535 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
549 Self::review_taking_over(repo, branch, config, None, origin).await
550 }
551
552 pub async fn review_taking_over(
559 repo: &Path,
560 branch: &str,
561 config: Config,
562 takeover: Option<crate::handover::Takeover>,
563 origin: Origin,
564 ) -> Result<Self> {
565 let repo = git::toplevel(repo).await?;
566 let missing = agent::missing_programs(&config.agents);
567 if !missing.is_empty() {
568 bail!(
569 "these agent programs are not on PATH: {}. Fix the roster in \
570 magi.toml or install them.",
571 missing.join(", ")
572 );
573 }
574 let base_branch = match config.merge.base.clone() {
575 Some(b) => b,
576 None => git::current_branch(&repo)
577 .await?
578 .context("HEAD is detached; set [merge] base in magi.toml")?,
579 };
580 if base_branch == branch {
581 bail!("`{branch}` is the base branch; there is nothing to review against");
582 }
583 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
584
585 let roles = config.resolve_roles()?;
586 let max_parallel = config.graph.max_parallel.max(1);
587 let mut state = RunState::new(
588 repo.clone(),
589 base_branch,
590 base_commit.clone(),
591 String::new(),
592 config,
593 );
594 state.origin = Some(origin);
595
596 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
603 earlier: Vec::new(),
604 home: crate::run::home(),
605 choice: None,
606 });
607 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
608 if let Some(released) = &released {
609 state.event(
610 "release",
611 format!(
612 "took `{branch}` over from run {}: its worktree was released: {}",
613 crate::run::short_of(&released.old_id),
614 released.audit
615 ),
616 );
617 }
618 if let Some(choice) = takeover.choice.as_ref()
622 && let Err(e) =
623 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
624 .await
625 {
626 if let Some(released) = &released {
627 released.restore(&repo, branch).await;
628 }
629 return Err(e.context("applying the owner's answer about the diverged branch"));
630 }
631 let opened =
632 Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
633 if opened.is_err()
634 && let Some(released) = &released
635 {
636 released.restore(&repo, branch).await;
637 }
638 opened
639 }
640
641 async fn open_review(
644 repo: &Path,
645 branch: &str,
646 mut state: RunState,
647 roles: ResolvedRoles,
648 max_parallel: usize,
649 base_commit: String,
650 ) -> Result<Self> {
651 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
652 let log = git::log_oneline(repo, &base_commit, branch)
655 .await
656 .unwrap_or_default();
657 let instruction = format!(
658 "Review the work already on branch `{branch}`. There is no task \
659 statement: what the change claims to do is whatever its commits \
660 say.\n\n{}",
661 if log.trim().is_empty() {
662 "(no commit messages)"
663 } else {
664 log.trim()
665 }
666 );
667 state.instruction = instruction;
668 state.reviewed_commits = Some(
669 git::subjects(repo, &base_commit, branch)
670 .await
671 .unwrap_or_default(),
672 );
673
674 let worktree = state.worktree_root().join("under-review");
677 if let Some(parent) = worktree.parent() {
678 tokio::fs::create_dir_all(parent).await.ok();
679 }
680 let path = worktree.to_string_lossy().to_string();
681 git::git(repo, &["worktree", "add", &path, branch])
682 .await
683 .with_context(|| {
684 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
685 })?;
686
687 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
688 .await
689 .unwrap_or(0);
690 if commits == 0 {
691 git::worktree_remove(repo, &worktree).await.ok();
692 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
693 }
694 let files = git::changed_files(&worktree, &base_commit, "HEAD")
695 .await
696 .map(|f| f.len())
697 .unwrap_or(0);
698 if files == 0
699 && let (Ok(head_tree), Ok(base_tree)) = (
700 git::tree_of(&worktree, "HEAD").await,
701 git::tree_of(&worktree, &base_commit).await,
702 )
703 && head_tree == base_tree
704 {
705 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
706 git::worktree_remove(repo, &worktree).await.ok();
707 bail!(
708 "`{branch}` at {} has a tree identical to base {}; this usually means \
709 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
710 against `{}/{branch}`) rather than an empty change",
711 short(&head),
712 short(&base_commit),
713 state.config.merge.remote
714 );
715 }
716 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
717 .await
718 .unwrap_or_default();
719
720 state.candidates.push(Candidate {
721 index: 0,
722 label: 'A',
723 agent: EXISTING_BRANCH.to_owned(),
726 branch: branch.to_owned(),
727 worktree,
728 summary: String::new(),
729 stat,
730 files,
731 commits,
732 empty: false,
733 failed: None,
734 verified_noop: None,
735 duration_ms: 0,
736 folded: false,
737 });
738 state.tally = Some(Tally {
739 first_choice: BTreeMap::from([('A', 0)]),
740 borda: BTreeMap::new(),
741 winner: 'A',
742 rankings: 0,
743 unanimous_initial: false,
744 deliberated: false,
745 changed_votes: 0,
746 unanimous_final: false,
747 tie_break: None,
748 judges: 0,
752 present: 0,
753 quorum: 0,
754 met_quorum: true,
755 uncontested: Some("review-only run: nothing competed".to_owned()),
756 });
757 state.status = RunStatus::Reviewing;
758 state.event(
759 "start",
760 format!(
761 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
762 state.id
763 ),
764 );
765 state.save()?;
766 Ok(Self {
767 state,
768 roles,
769 sem: Arc::new(Semaphore::new(max_parallel)),
770 pause: Pause::new(),
771 interrupt: Pause::new(),
772 })
773 }
774
775 pub fn resume(id: &str) -> Result<Self> {
777 let state = RunState::load(id)?;
778 if let Some(to) = &state.released_to {
779 bail!(
780 "run {} cannot be resumed: its worktree was released to run {}",
781 state.short(),
782 crate::run::short_of(to)
783 );
784 }
785 let roles = state.config.resolve_roles()?;
786 let max_parallel = state.config.graph.max_parallel.max(1);
787 Ok(Self {
788 state,
789 roles,
790 sem: Arc::new(Semaphore::new(max_parallel)),
791 pause: Pause::new(),
792 interrupt: Pause::new(),
793 })
794 }
795
796 pub async fn execute(&mut self) -> Result<()> {
803 let result = self.execute_graph().await;
804 self.mark_driver_exited();
805 let ended = if result.is_err() {
806 Some(crate::notices::run_stopped(&self.state.id, &self.state))
807 } else {
808 crate::notices::run_ended(&self.state)
809 };
810 if let Some(notice) = ended {
811 crate::notices::raise(notice);
812 }
813 result
814 }
815
816 fn mark_driver_exited(&mut self) {
825 self.state.driver_exited = true;
826 let pid = std::process::id();
827 let Ok(mut disk) = RunState::load(&self.state.id) else {
828 return;
829 };
830 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
831 return;
832 }
833 disk.driver_exited = true;
834 if let Err(e) = disk.save() {
835 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
836 }
837 }
838
839 async fn execute_graph(&mut self) -> Result<()> {
840 self.state.parked = false;
845 self.state.clear_active();
852 if let Ok(disk) = RunState::load(&self.state.id)
870 && let Some(to) = &disk.released_to
871 {
872 bail!(
873 "run {} cannot continue: its worktree was released to run {}",
874 self.state.short(),
875 crate::run::short_of(to)
876 );
877 }
878 let pid = std::process::id();
879 self.state.driver_pid = Some(pid);
880 self.state.driver_started_at = crate::proc::process_started_at(pid);
881 self.state.driver_exited = false;
882 self.state.save()?;
883 if self.state.status == RunStatus::Stalled {
896 if self.recover_stall().await? {
897 self.finish_after_tally().await?;
898 } else {
899 self.state.save()?;
901 }
902 return Ok(());
903 }
904 if self.state.status == RunStatus::Landing {
914 self.run_land().await?;
915 self.settle_questions();
920 return Ok(());
921 }
922 self.prep().await?;
923 if self.park_here()? {
924 return Ok(());
925 }
926 self.advise().await?;
927 if self.park_here()? {
928 return Ok(());
929 }
930 self.implement().await?;
931 if self.park_here()? {
932 return Ok(());
933 }
934 if self.state.status == RunStatus::VerifiedNoop {
938 return Ok(());
939 }
940 self.judge().await?;
941 if self.park_here()? {
942 return Ok(());
943 }
944 self.deliberate().await?;
945 if self.park_here()? {
946 return Ok(());
947 }
948 self.vote().await?;
949 if self.park_here()? {
950 return Ok(());
951 }
952 self.tally()?;
953 if self.state.status == RunStatus::Stalled {
958 self.state.save()?;
962 return Ok(());
963 }
964 self.finish_after_tally().await?;
965 Ok(())
966 }
967
968 fn park_here(&mut self) -> Result<bool> {
975 if !self.pause.parked() && !self.interrupt.parked() {
980 return Ok(false);
981 }
982 let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
983 Some(reason) => format!(
984 "parked after `{}` ({reason}) — resume to carry on from here",
985 self.state.status.as_str()
986 ),
987 None => format!(
988 "parked after `{}` — resume to carry on from here",
989 self.state.status.as_str()
990 ),
991 };
992 self.state.event("park", why);
993 self.state.parked = true;
994 self.state.save()?;
995 Ok(true)
996 }
997
998 pub fn on_pause(&mut self, pause: Pause) {
1000 self.pause = pause;
1001 }
1002
1003 pub fn watch_interrupt(&mut self, pause: Pause) {
1009 self.interrupt = pause;
1010 }
1011
1012 fn settle_questions(&mut self) {
1032 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1033 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1034 }
1035 }
1036
1037 async fn finish_after_tally(&mut self) -> Result<()> {
1040 self.fold_losers().await?;
1041 self.sync_to_base().await?;
1046 if self.state.status == RunStatus::AlreadyInBase {
1047 return Ok(());
1048 }
1049 self.review_loop().await?;
1050 self.sync_to_base().await?;
1051 if self.state.status == RunStatus::AlreadyInBase {
1052 return Ok(());
1053 }
1054 self.gate().await?;
1055 self.merge().await?;
1056 self.state.save()?;
1057 Ok(())
1058 }
1059
1060 async fn prep(&mut self) -> Result<()> {
1063 if !self.state.candidates.is_empty() {
1064 return Ok(());
1065 }
1066 self.state.status = RunStatus::Prep;
1067 let repo = self.state.repo.clone();
1068 let base = self.state.base_commit.clone();
1069 let plan = refs::plan(&repo, &self.state.seeds).await?;
1070 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1071 let root = self.state.worktree_root();
1072 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1073
1074 let hooks_dir = self.state.dir().join("hooks");
1077 if self.state.config.blind.commit_msg_hook {
1078 std::fs::create_dir_all(&hooks_dir)
1079 .with_context(|| format!("create {}", hooks_dir.display()))?;
1080 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1081 let path = hooks_dir.join("commit-msg");
1082 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1083 make_executable(&path)?;
1084 git::acquire_worktree_config(&repo).await?;
1092 self.state.enabled_worktree_config = true;
1093 }
1094
1095 for (index, (spec, label)) in self
1096 .roles
1097 .implementers
1098 .clone()
1099 .into_iter()
1100 .zip(labels)
1101 .enumerate()
1102 {
1103 let branch = self.state.branch_for(label);
1104 let worktree = root.join(format!("cand-{label}"));
1105 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1106 if self.state.config.blind.commit_msg_hook {
1107 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1108 }
1109 git::local_exclude(&worktree, "/.magi/").await?;
1110 for pick in &plan.picks {
1111 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1112 self.state.status = RunStatus::Blocked;
1113 self.state
1114 .event("prep", format!("cannot apply referenced commit: {e}"));
1115 self.state.save()?;
1116 return Err(e);
1117 }
1118 }
1119 self.state.candidates.push(Candidate {
1120 index,
1121 label,
1122 agent: spec.id.clone(),
1123 branch,
1124 worktree,
1125 summary: String::new(),
1126 stat: String::new(),
1127 files: 0,
1128 commits: 0,
1129 empty: false,
1130 failed: None,
1131 verified_noop: None,
1132 duration_ms: 0,
1133 folded: false,
1134 });
1135 }
1136
1137 for j in 1..=self.roles.judges.len() {
1138 let wt = root.join(format!("judge-{j}"));
1139 if !wt.exists() {
1140 git::worktree_add_detached(&repo, &wt, &base).await?;
1141 }
1142 }
1143
1144 if self.state.config.graph.advise {
1152 for k in 1..=self.state.config.graph.advisors {
1153 let wt = root.join(format!("advisor-{k}"));
1154 if !wt.exists() {
1155 git::worktree_add_detached(&repo, &wt, &base).await?;
1156 }
1157 }
1158 }
1159
1160 let authors: Vec<&str> = self
1165 .roles
1166 .implementers
1167 .iter()
1168 .map(|a| a.id.as_str())
1169 .collect();
1170 let overlap: Vec<String> = self
1171 .roles
1172 .judges
1173 .iter()
1174 .enumerate()
1175 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1176 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1177 .collect();
1178 if !overlap.is_empty() {
1179 let note = format!(
1180 "{} also authored a candidate; blind, but the panel is less \
1181 independent than {} distinct agents would be",
1182 overlap.join(", "),
1183 self.roles.judges.len()
1184 );
1185 self.state.event("prep", note);
1186 }
1187
1188 self.state.event(
1189 "prep",
1190 format!(
1191 "{} candidates, {} judges, base {} ({})",
1192 self.state.candidates.len(),
1193 self.roles.judges.len(),
1194 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1195 self.state.base_branch
1196 ),
1197 );
1198 self.state.status = RunStatus::Implementing;
1199 self.state.save()?;
1200 Ok(())
1201 }
1202
1203 async fn advise(&mut self) -> Result<()> {
1236 let implement_untouched = self
1237 .state
1238 .candidates
1239 .iter()
1240 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1241 if !self.state.config.graph.advise || self.state.advise_attempted {
1242 return Ok(());
1243 }
1244 if !implement_untouched {
1245 self.state.event(
1246 "advise",
1247 "skipping the design-deliberation stage: at least one \
1248 candidate already shows implementation progress, so this \
1249 run is past the point the stage exists to run before"
1250 .to_owned(),
1251 );
1252 self.state.advise_attempted = true;
1253 self.state.save()?;
1254 return Ok(());
1255 }
1256 let run_id = self.state.id.clone();
1257 let prompts = self.state.config.prompts.clone();
1258 let instruction = self.state.instruction.clone();
1259 let language = self.state.config.graph.language.clone();
1260 let root = self.state.worktree_root();
1261 let n = self.state.config.graph.advisors;
1262 let where_recorded = self.state.dir().join("run.json");
1263
1264 let seats = match self.state.config.advisors() {
1265 Ok(seats) if !seats.is_empty() => seats,
1266 Ok(_) => {
1267 self.state.event(
1268 "advise",
1269 format!(
1270 "[graph] advisors is 0; skipping the design-deliberation \
1271 stage and continuing without a synthesis brief (see {})",
1272 where_recorded.display()
1273 ),
1274 );
1275 self.state.advise_attempted = true;
1276 self.state.save()?;
1277 return Ok(());
1278 }
1279 Err(e) => {
1280 self.state.event(
1281 "advise",
1282 format!(
1283 "could not resolve advisor seats ({e:#}); continuing \
1284 without a design-deliberation brief (see {})",
1285 where_recorded.display()
1286 ),
1287 );
1288 self.state.advise_attempted = true;
1289 self.state.save()?;
1290 return Ok(());
1291 }
1292 };
1293
1294 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1295 let artifacts = agent::artifacts_dir(&self.state.dir());
1296 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1297
1298 let mut jobs = Vec::new();
1299 for (i, spec) in seats.iter().cloned().enumerate() {
1300 let seat_key = format!("advisor-{}", i + 1);
1301 let seat = self.seat(&seat_key, &spec.id);
1302 jobs.push(SeatJob {
1303 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1304 spec,
1305 seat,
1306 cwd: worktrees[i % worktrees.len()].clone(),
1307 timeout,
1308 allow_write: false,
1309 sessions: false,
1310 artifacts: artifacts.clone(),
1311 stem: seat_key,
1312 });
1313 }
1314
1315 self.state.event(
1316 "advise",
1317 format!(
1318 "{} advisor seat(s) sketching a design in parallel",
1319 jobs.len()
1320 ),
1321 );
1322 let mut quota_losses = Vec::new();
1323 let cache = self.state.config.cache_dir();
1324 let ctx = WaveCtx {
1325 run: &run_id,
1326 node: "advise",
1327 prompts: &prompts,
1328 cache: cache.as_deref(),
1329 round: None,
1330 };
1331 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1332 let results = ask_json_wave::<Proposal>(
1333 jobs,
1334 Arc::clone(&self.sem),
1335 self.state.config.graph.retries,
1336 &advisor_roster,
1337 &ctx,
1338 &mut quota_losses,
1339 &mut self.state,
1340 &|p: &Proposal| p.validate(),
1341 )
1342 .await;
1343 self.state.quota.extend(quota_losses);
1344
1345 let mut records = Vec::with_capacity(results.len());
1346 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1347 let agent_id = seat.agent.clone();
1348 self.state.seats.insert(seat.key.clone(), seat);
1349 match res {
1350 Ok((proposal, out)) => {
1351 self.state
1352 .event("advise", format!("advisor-{} proposed a design", i + 1));
1353 records.push(advise::AdvisorRecord::proposed(
1354 i + 1,
1355 agent_id,
1356 proposal,
1357 out.duration_ms,
1358 ));
1359 }
1360 Err(e) => {
1361 self.state.event(
1362 "advise",
1363 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1364 );
1365 records.push(advise::AdvisorRecord::failed(
1366 i + 1,
1367 agent_id,
1368 e.to_string(),
1369 ));
1370 }
1371 }
1372 }
1373
1374 let mut advice = advise::Advice {
1375 records,
1376 synthesis: None,
1377 };
1378 if advice.proposals().is_empty() {
1379 self.state.event(
1380 "advise",
1381 "no advisor produced a usable proposal; continuing without a \
1382 synthesis brief"
1383 .to_owned(),
1384 );
1385 } else {
1386 match self
1387 .synthesize_brief(
1388 &advice,
1389 &instruction,
1390 &language,
1391 &worktrees[0],
1392 &artifacts,
1393 &run_id,
1394 &prompts,
1395 cache.as_deref(),
1396 )
1397 .await
1398 {
1399 Ok(Some(text)) => {
1400 self.state.event(
1401 "advise",
1402 "synthesized a design brief for the implementer".to_owned(),
1403 );
1404 advice.synthesis = Some(text);
1405 }
1406 Ok(None) => {
1407 self.state.event(
1408 "advise",
1409 "the synthesis seat produced nothing usable; continuing \
1410 without a design brief"
1411 .to_owned(),
1412 );
1413 }
1414 Err(e) => {
1415 self.state.event(
1416 "advise",
1417 format!("could not synthesize a design brief: {e:#}"),
1418 );
1419 }
1420 }
1421 }
1422 advise::apply_reflection(&mut advice);
1423
1424 self.state.advice = Some(advice);
1425 self.state.advise_attempted = true;
1426 self.state.save()?;
1427 Ok(())
1428 }
1429
1430 #[allow(clippy::too_many_arguments)]
1442 async fn synthesize_brief(
1443 &mut self,
1444 advice: &advise::Advice,
1445 instruction: &str,
1446 language: &str,
1447 cwd: &Path,
1448 artifacts: &Path,
1449 run_id: &str,
1450 prompts: &Prompts,
1451 cache: Option<&Path>,
1452 ) -> Result<Option<String>> {
1453 let chain = agent::pick_chain(
1454 &self.state.config.agents,
1455 self.state.config.roles.synthesizer.as_ref(),
1456 &agent::installed,
1457 "synthesizer",
1458 )?;
1459 let proposals = advice.proposals();
1460 let mut prompt = prompt::with_overlay(
1461 prompt::synthesize_brief(instruction, &proposals, language),
1462 prompts.overlay("advise"),
1463 );
1464 if cache.is_some() {
1465 prompt.push('\n');
1470 prompt.push_str(&prompt::build_cache_note("advise", false));
1471 }
1472 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1473 let mut last = None;
1478 for (n, spec) in chain.iter().enumerate() {
1479 if n > 0 {
1480 self.state
1481 .event("advise", format!("synthesis falling back to {}", spec.id));
1482 }
1483 let mut seat = self.seat("advise-synthesis", &spec.id);
1484 let outcome = agent::invoke(
1485 spec,
1486 &mut seat,
1487 &Invocation {
1488 cwd,
1489 prompt: &prompt,
1490 timeout,
1491 allow_write: false,
1492 sessions: false,
1493 artifacts,
1494 stem: &if n == 0 {
1495 "advise-synthesis".to_owned()
1496 } else {
1497 format!("advise-synthesis-{}", spec.id)
1498 },
1499 run: run_id,
1500 node: "advise",
1501 cache_dir: None,
1502 attachments: &[],
1503 writable: &[],
1504 },
1505 )
1506 .await;
1507 if outcome.is_ok() {
1508 self.state.seats.insert(seat.key.clone(), seat);
1509 }
1510 let advance = agent::chain_advances(&outcome);
1511 last = Some(outcome);
1512 if !advance {
1513 break;
1514 }
1515 }
1516 let out = last.expect("a chain holds at least one agent")?;
1519 if !out.usable() {
1520 return Ok(None);
1521 }
1522 let text =
1523 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1524 Ok((!text.trim().is_empty()).then_some(text))
1525 }
1526
1527 async fn implement(&mut self) -> Result<()> {
1530 let run_id = self.state.id.clone();
1535 let prompts = self.state.config.prompts.clone();
1536 let todo: Vec<usize> = self
1537 .state
1538 .candidates
1539 .iter()
1540 .enumerate()
1541 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1542 .map(|(i, _)| i)
1543 .collect();
1544 if todo.is_empty() {
1545 return self.after_implement();
1546 }
1547 self.state.status = RunStatus::Implementing;
1548
1549 let language = self.state.config.graph.language.clone();
1550 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1551 let sessions = self.state.config.graph.sessions;
1552 let artifacts = agent::artifacts_dir(&self.state.dir());
1553 let brief = self
1557 .state
1558 .advice
1559 .as_ref()
1560 .and_then(|a| a.synthesis.as_deref())
1561 .map(str::to_owned);
1562 let attachments = self.state.attachments.clone();
1563
1564 let mut jobs = Vec::new();
1565 for &i in &todo {
1566 let (index, label, worktree) = {
1567 let c = &self.state.candidates[i];
1568 (c.index, c.label, c.worktree.clone())
1569 };
1570 let spec = self.roles.implementers[index].clone();
1571 let seat_key = format!("impl-{label}");
1572 let seat = self.seat(&seat_key, &spec.id);
1573 let instruction = seeded_instruction(&self.state);
1574 jobs.push(SeatJob {
1575 spec,
1576 seat,
1577 prompt: prompt::implement(
1578 &instruction,
1579 &worktree.to_string_lossy(),
1580 &language,
1581 brief.as_deref(),
1582 &attachments,
1583 ),
1584 cwd: worktree,
1585 timeout,
1586 allow_write: true,
1587 sessions,
1588 artifacts: artifacts.clone(),
1589 stem: format!("impl-{label}"),
1590 });
1591 }
1592
1593 self.state.event(
1594 "implement",
1595 format!("{} candidates in parallel", jobs.len()),
1596 );
1597 let mut sent = jobs.clone();
1603 let cache = self.state.config.cache_dir();
1604 let ctx = WaveCtx {
1605 run: &run_id,
1606 node: "implement",
1607 prompts: &prompts,
1608 cache: cache.as_deref(),
1609 round: None,
1610 };
1611 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1612 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1613 .await;
1614 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1615 .await;
1616 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1617 .await;
1618
1619 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1620 let seat_key = seat.key.clone();
1621 let agent = seat.agent.clone();
1630 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1631 self.state.seats.insert(seat.key.clone(), seat);
1632 let label = self.state.candidates[i].label;
1633 let worktree = self.state.candidates[i].worktree.clone();
1634 let base = self.state.base_commit.clone();
1635
1636 let (summary, duration, failed, verified_claim) = match out {
1637 AgentOutcome::Ok(o) => {
1638 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1639 let failed = (!o.usable()).then(|| {
1640 if o.timed_out {
1641 "agent timed out".to_owned()
1642 } else {
1643 format!("agent exited with {:?}", o.exit_code)
1644 }
1645 });
1646 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1647 (text, o.duration_ms, failed, verified_claim)
1648 }
1649 AgentOutcome::Dropped(o) => {
1655 let why = o
1656 .dropped
1657 .as_ref()
1658 .map(|d| d.why.as_str())
1659 .unwrap_or("the CLI ended the stream without delivering its answer");
1660 (
1661 String::new(),
1662 o.duration_ms,
1663 Some(format!("the CLI dropped the stream ({why})")),
1664 None,
1665 )
1666 }
1667 AgentOutcome::Quota(o) => {
1668 self.state.quota.push(QuotaLoss {
1669 seat: seat_key,
1670 node: "implement".to_owned(),
1671 at: Timestamp::now(),
1672 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1673 });
1674 (
1675 String::new(),
1676 o.duration_ms,
1677 Some("rate limited (quota); produced no change".to_owned()),
1678 None,
1679 )
1680 }
1681 AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1682 };
1683
1684 let rescued = match git::rescue_commit(
1687 &worktree,
1688 &format!("magi: candidate {label} (uncommitted work)"),
1689 )
1690 .await
1691 {
1692 Ok(r) => {
1693 self.state.note_withheld("implement", &r.withheld);
1694 r.committed
1695 }
1696 Err(_) => false,
1697 };
1698 let commits = git::commits_ahead(&worktree, &base, "HEAD")
1699 .await
1700 .unwrap_or(0);
1701 let patch = git::diff(&worktree, &base, "HEAD")
1702 .await
1703 .unwrap_or_default();
1704 let stat = git::diff_stat(&worktree, &base, "HEAD")
1705 .await
1706 .unwrap_or_default();
1707 let files = git::changed_files(&worktree, &base, "HEAD")
1708 .await
1709 .map(|f| f.len())
1710 .unwrap_or(0);
1711 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1712
1713 let c = &mut self.state.candidates[i];
1714 if !exhausted_the_fallback_chain {
1715 c.agent = agent;
1716 }
1717 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1718 c.stat = stat;
1719 c.files = files;
1720 c.commits = commits;
1721 c.duration_ms = duration;
1722 c.empty = commits == 0 || patch.trim().is_empty();
1723 c.failed = match failed {
1726 Some(_) if c.empty => failed,
1727 _ => None,
1728 };
1729 c.verified_noop = if c.empty { verified_claim } else { None };
1734 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1735 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1736 (None, true, Some(_), _) => {
1737 format!("candidate {label}: no change produced (agent-verified no-op)")
1738 }
1739 (None, true, None, _) => format!("candidate {label}: no change produced"),
1740 (None, false, _, true) => {
1741 format!(
1742 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1743 )
1744 }
1745 (None, false, _, false) => {
1746 format!("candidate {label}: {files} files, {commits} commits")
1747 }
1748 };
1749 self.state.event("implement", note);
1750 self.state.save()?;
1751 }
1752
1753 self.after_implement()
1754 }
1755
1756 async fn resume_undelivered(
1784 &mut self,
1785 results: &mut [(usize, SeatState, AgentOutcome)],
1786 sent: &[SeatJob],
1787 prompts: &Prompts,
1788 run_id: &str,
1789 ) {
1790 for (wi, seat, out) in results.iter_mut() {
1791 let Some(dropped) = (match &*out {
1792 AgentOutcome::Dropped(o) => o.dropped.clone(),
1793 _ => None,
1794 }) else {
1795 continue;
1796 };
1797 let Some(job) = sent.get(*wi) else { continue };
1798 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1800 self.state.event(
1801 "implement",
1802 format!(
1803 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1804 work is in the tree",
1805 seat.key, dropped.output_tokens, dropped.why
1806 ),
1807 );
1808 continue;
1809 }
1810 if !has_context(&job.spec, seat, job.sessions) {
1818 self.state.event(
1819 "implement",
1820 format!(
1821 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
1822 is no session left to resume",
1823 seat.key, dropped.output_tokens, dropped.why
1824 ),
1825 );
1826 continue;
1827 }
1828 self.state.event(
1829 "implement",
1830 format!(
1831 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
1832 conversation",
1833 seat.key, dropped.output_tokens, dropped.why
1834 ),
1835 );
1836 let mut retry = job.clone();
1837 retry.seat = seat.clone();
1838 retry.prompt = prompt::resume_after_drop(&dropped.why);
1839 retry.timeout = retry_budget(job.timeout, true);
1840 retry.stem = format!("{}-resume", job.stem);
1841 let cache = self.state.config.cache_dir();
1842 let ctx = WaveCtx {
1843 run: run_id,
1844 node: "implement",
1845 prompts,
1846 cache: cache.as_deref(),
1847 round: None,
1848 };
1849 let (resumed_seat, resumed) =
1850 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
1851 *seat = resumed_seat;
1852 *out = resumed;
1853 }
1854 }
1855
1856 async fn resume_seat_handovers(
1921 &mut self,
1922 results: &mut [(usize, SeatState, AgentOutcome)],
1923 sent: &mut [SeatJob],
1924 prompts: &Prompts,
1925 run_id: &str,
1926 ) {
1927 let instruction = seeded_instruction(&self.state);
1928 let language = self.state.config.graph.language.clone();
1929 let brief = self
1930 .state
1931 .advice
1932 .as_ref()
1933 .and_then(|a| a.synthesis.as_deref())
1934 .map(str::to_owned);
1935 let attachments = self.state.attachments.clone();
1936 for (wi, seat, out) in results.iter_mut() {
1937 let Some(job) = sent.get_mut(*wi) else {
1938 continue;
1939 };
1940 let start = self
1945 .roles
1946 .implementer_roster
1947 .iter()
1948 .position(|s| s.id == job.spec.id)
1949 .unwrap_or(0);
1950 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
1951 let mut fallback_attempt = 0usize;
1952 let mut prev: Option<FailClass> = None;
1953 while let Some(cur) = FailClass::of(&*out) {
1954 if !should_hand_over(prev.as_ref(), &cur) {
1955 break;
1956 }
1957 let Some(next) =
1958 next_untried_in_roster(&self.roles.implementer_roster, start, &tried).cloned()
1959 else {
1960 break;
1961 };
1962 tried.insert(next.id.clone());
1963 fallback_attempt += 1;
1964
1965 if let Ok(r) = git::rescue_commit(
1966 &job.cwd,
1967 &format!(
1968 "magi: candidate {} (uncommitted work before {} fallback)",
1969 seat.key,
1970 if cur == FailClass::Quota {
1971 "quota"
1972 } else {
1973 "handover"
1974 }
1975 ),
1976 )
1977 .await
1978 {
1979 self.state.note_withheld("implement", &r.withheld);
1980 }
1981
1982 record_handover(
1983 &mut self.state,
1984 "implement",
1985 &seat.key,
1986 &seat.agent,
1987 &next.id,
1988 &cur,
1989 &fail_reason(&*out),
1990 );
1991 prev = Some(cur.clone());
1992
1993 let new_seat = handover_seat(&seat.key, &next.id, self.state.seed);
1994 self.state.seats.insert(seat.key.clone(), new_seat.clone());
1995 job.spec = next.clone();
2003 let mut retry = job.clone();
2004 retry.seat = new_seat;
2005 retry.prompt = prompt::implement(
2006 &instruction,
2007 &job.cwd.to_string_lossy(),
2008 &language,
2009 brief.as_deref(),
2010 &attachments,
2011 );
2012 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2013 let cache = self.state.config.cache_dir();
2014 let ctx = WaveCtx {
2015 run: run_id,
2016 node: "implement",
2017 prompts,
2018 cache: cache.as_deref(),
2019 round: None,
2020 };
2021 let (fallback_seat, fallback_out) = run_one(
2022 retry,
2023 Arc::clone(&self.sem),
2024 &ctx,
2025 &mut self.state,
2026 fallback_attempt,
2027 )
2028 .await;
2029 *seat = fallback_seat;
2030 *out = fallback_out;
2031 }
2032 }
2033 }
2034
2035 async fn resume_unconfirmed_commands(
2059 &mut self,
2060 results: &mut [(usize, SeatState, AgentOutcome)],
2061 sent: &[SeatJob],
2062 prompts: &Prompts,
2063 run_id: &str,
2064 ) {
2065 for (wi, seat, out) in results.iter_mut() {
2066 let AgentOutcome::Ok(o) = &*out else {
2067 continue;
2068 };
2069 if !has_unconfirmed_command(&o.commands) {
2070 continue;
2071 }
2072 let Some(job) = sent.get(*wi) else { continue };
2073 if !has_context(&job.spec, seat, job.sessions) {
2074 self.state.event(
2075 "implement",
2076 format!(
2077 "{}: the reply named a command whose own CLI never confirmed the exit \
2078 status of, but there is no session left to resume",
2079 seat.key
2080 ),
2081 );
2082 continue;
2083 }
2084 self.state.event(
2085 "implement",
2086 format!(
2087 "{}: the reply named a command whose own CLI never confirmed the exit \
2088 status of; resuming the conversation",
2089 seat.key
2090 ),
2091 );
2092 let mut retry = job.clone();
2093 retry.seat = seat.clone();
2094 retry.prompt = prompt::resume_incomplete(
2095 "a command in your last reply had no confirmed exit status",
2096 );
2097 retry.timeout = retry_budget(job.timeout, true);
2098 retry.stem = format!("{}-confirm", job.stem);
2099 let cache = self.state.config.cache_dir();
2100 let ctx = WaveCtx {
2101 run: run_id,
2102 node: "implement",
2103 prompts,
2104 cache: cache.as_deref(),
2105 round: None,
2106 };
2107 let (resumed_seat, resumed) =
2108 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2109 *seat = resumed_seat;
2110 *out = resumed;
2111 }
2112 }
2113
2114 async fn continue_fix_report(
2135 &mut self,
2136 mut seat: SeatState,
2137 parse_err: String,
2138 job: &SeatJob,
2139 prompts: &Prompts,
2140 run_id: &str,
2141 round: usize,
2142 ) -> (
2143 SeatState,
2144 Option<FixReport>,
2145 Option<String>,
2146 ContinuationRecord,
2147 ) {
2148 let mut last_err = parse_err;
2149 let mut cumulative_wait_ms = 0u64;
2150 let mut attempts = 0usize;
2151 loop {
2152 if !has_context(&job.spec, &seat, job.sessions) {
2153 self.state.event(
2154 "fix",
2155 format!(
2156 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2157 session left to resume into"
2158 ),
2159 );
2160 let outcome = if attempts == 0 {
2161 ContinuationOutcome::NoSession
2162 } else {
2163 ContinuationOutcome::Exhausted
2164 };
2165 return (
2166 seat,
2167 None,
2168 Some(format!("unparsable fix report: {last_err}")),
2169 ContinuationRecord {
2170 attempts,
2171 cumulative_wait_ms,
2172 outcome,
2173 },
2174 );
2175 }
2176 if attempts >= MAX_FIX_CONTINUATIONS {
2177 self.state.event(
2178 "fix",
2179 format!(
2180 "round {round}: fixer's reply still had no adoption report after \
2181 {attempts} continuation(s) ({last_err}); giving up"
2182 ),
2183 );
2184 return (
2185 seat,
2186 None,
2187 Some(format!(
2188 "unparsable fix report after {attempts} continuation(s): {last_err}"
2189 )),
2190 ContinuationRecord {
2191 attempts,
2192 cumulative_wait_ms,
2193 outcome: ContinuationOutcome::Exhausted,
2194 },
2195 );
2196 }
2197 attempts += 1;
2198 self.state.event(
2199 "fix",
2200 format!(
2201 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2202 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2203 ),
2204 );
2205 let mut retry = job.clone();
2206 retry.seat = seat.clone();
2207 retry.prompt = prompt::resume_incomplete(&last_err);
2208 retry.timeout = retry_budget(job.timeout, true);
2209 retry.stem = format!("{}-continue{attempts}", job.stem);
2210 let cache = self.state.config.cache_dir();
2211 let ctx = WaveCtx {
2212 run: run_id,
2213 node: "fix",
2214 prompts,
2215 cache: cache.as_deref(),
2216 round: Some(round),
2217 };
2218 let (resumed_seat, resumed_out) = run_one(
2219 retry,
2220 Arc::clone(&self.sem),
2221 &ctx,
2222 &mut self.state,
2223 attempts,
2224 )
2225 .await;
2226 seat = resumed_seat;
2227 match resumed_out {
2228 AgentOutcome::Ok(o) => {
2229 cumulative_wait_ms += o.duration_ms;
2230 match verdict::extract_json::<FixReport>(&o.text) {
2231 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2232 self.state.event(
2233 "fix",
2234 format!(
2235 "round {round}: fixer's adoption report recovered after \
2236 {attempts} continuation(s)"
2237 ),
2238 );
2239 return (
2240 seat,
2241 Some(report),
2242 None,
2243 ContinuationRecord {
2244 attempts,
2245 cumulative_wait_ms,
2246 outcome: ContinuationOutcome::Resumed,
2247 },
2248 );
2249 }
2250 Ok(_) => {
2258 last_err = "the reply parsed, but it reported a command whose own CLI \
2259 never confirmed an exit status"
2260 .to_owned();
2261 }
2262 Err(e) => last_err = e.to_string(),
2263 }
2264 }
2265 AgentOutcome::Quota(o) => {
2266 cumulative_wait_ms += o.duration_ms;
2267 self.state.quota.push(QuotaLoss {
2268 seat: seat.key.clone(),
2269 node: "fix".to_owned(),
2270 at: Timestamp::now(),
2271 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2272 });
2273 self.state.event(
2274 "fix",
2275 format!(
2276 "round {round}: continuation rate limited (quota); not retrying now"
2277 ),
2278 );
2279 return (
2280 seat,
2281 None,
2282 Some("rate limited (quota) while recovering the fix report".to_owned()),
2283 ContinuationRecord {
2284 attempts,
2285 cumulative_wait_ms,
2286 outcome: ContinuationOutcome::QuotaLost,
2287 },
2288 );
2289 }
2290 AgentOutcome::Dropped(o) => {
2291 cumulative_wait_ms += o.duration_ms;
2292 let why = o
2293 .dropped
2294 .as_ref()
2295 .map(|d| d.why.as_str())
2296 .unwrap_or("the CLI ended the stream without delivering its answer");
2297 last_err = format!("the CLI dropped the stream ({why})");
2298 }
2299 AgentOutcome::Failed(e) => last_err = e,
2300 }
2301 }
2302 }
2303
2304 fn after_implement(&mut self) -> Result<()> {
2305 if self.state.leaks.is_empty() {
2307 let cfg = self.state.config.blind.clone();
2308 let mut leaks = Vec::new();
2309 for c in &self.state.candidates {
2310 let Some(patch) =
2311 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2312 else {
2313 continue;
2314 };
2315 leaks.extend(blind::scan(
2316 &format!("candidate {} patch", c.label),
2317 &patch,
2318 &cfg.vendor_tokens,
2319 ));
2320 }
2321 if !leaks.is_empty() {
2322 let summary = leaks
2323 .iter()
2324 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2325 .collect::<Vec<_>>()
2326 .join(", ");
2327 match cfg.on_leak {
2328 LeakPolicy::Fail => {
2329 self.state.status = RunStatus::Failed;
2330 self.state
2331 .event("blind", format!("vendor text in a patch: {summary}"));
2332 self.state.leaks = leaks;
2333 self.state.save()?;
2334 self.settle_questions();
2335 bail!(
2336 "blind.on_leak = \"fail\" and vendor text reached a \
2337 judged patch: {summary}"
2338 );
2339 }
2340 LeakPolicy::Redact => self.state.event(
2341 "blind",
2342 format!("redacting vendor text for judging: {summary}"),
2343 ),
2344 LeakPolicy::Warn => self.state.event(
2345 "blind",
2346 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2347 ),
2348 }
2349 self.state.leaks = leaks;
2350 }
2351 }
2352
2353 if self.state.viable().is_empty() {
2354 if self.state.all_candidates_verified_noop() {
2355 self.state.status = RunStatus::VerifiedNoop;
2366 self.state.save()?;
2367 self.settle_questions();
2368 return Ok(());
2369 }
2370 self.state.status = RunStatus::Failed;
2371 self.state.save()?;
2372 self.settle_questions();
2373 bail!("no candidate produced a change; nothing to judge");
2374 }
2375 self.state.status = RunStatus::Judging;
2376 self.state.save()?;
2377 Ok(())
2378 }
2379
2380 async fn judge(&mut self) -> Result<()> {
2383 let run_id = self.state.id.clone();
2388 let prompts = self.state.config.prompts.clone();
2389 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2390 return Ok(());
2391 }
2392 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2393 if viable.len() == 1 {
2394 self.state.judge_skipped = true;
2401 self.state.event(
2402 "judge",
2403 format!(
2404 "only candidate {} produced a change; judging skipped",
2405 viable[0].label
2406 ),
2407 );
2408 self.state.save()?;
2409 return Ok(());
2410 }
2411 self.state.status = RunStatus::Judging;
2412
2413 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2414 let language = self.state.config.graph.language.clone();
2415 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2416 let sessions = self.state.config.graph.sessions;
2417 let artifacts = agent::artifacts_dir(&self.state.dir());
2418 let root = self.state.worktree_root();
2419 let base_short = short(&self.state.base_commit);
2420
2421 let mut jobs = Vec::new();
2422 let mut orders = Vec::new();
2423 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2424 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2425 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2426 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2427 let seat_key = format!("judge-{}", j + 1);
2428 let seat = self.seat(&seat_key, &spec.id);
2429 jobs.push(SeatJob {
2430 prompt: prompt::judge(
2431 &self.state.instruction,
2432 &views,
2433 self.roles.judges.len(),
2434 &base_short,
2435 &language,
2436 ),
2437 spec,
2438 seat,
2439 cwd: root.join(format!("judge-{}", j + 1)),
2440 timeout,
2441 allow_write: false,
2442 sessions,
2443 artifacts: artifacts.clone(),
2444 stem: format!("judge-{}", j + 1),
2445 });
2446 }
2447
2448 self.state.event(
2449 "judge",
2450 format!(
2451 "{} judges ranking {} candidates blind",
2452 jobs.len(),
2453 viable.len()
2454 ),
2455 );
2456 let labels_for_check = labels.clone();
2457 let mut quota_losses = Vec::new();
2458 let cache = self.state.config.cache_dir();
2459 let ctx = WaveCtx {
2460 run: &run_id,
2461 node: "judge",
2462 prompts: &prompts,
2463 cache: cache.as_deref(),
2464 round: None,
2465 };
2466 let results = ask_json_wave::<Ranking>(
2467 jobs,
2468 Arc::clone(&self.sem),
2469 self.state.config.graph.retries,
2470 &self.roles.judge_roster,
2471 &ctx,
2472 &mut quota_losses,
2473 &mut self.state,
2474 &move |r: &Ranking| r.validate(&labels_for_check),
2475 )
2476 .await;
2477 self.state.quota.extend(quota_losses);
2478
2479 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2480 let agent_id = seat.agent.clone();
2481 self.state.seats.insert(seat.key.clone(), seat);
2482 let mut record = Judgement {
2483 judge: j + 1,
2484 seat: format!("judge-{}", j + 1),
2485 agent: agent_id,
2486 ranking: Vec::new(),
2487 reasons: BTreeMap::new(),
2488 confidence: None,
2489 order: orders[j].clone(),
2490 failed: None,
2491 duration_ms: 0,
2492 };
2493 match res {
2494 Ok((ranking, out)) => {
2495 record.ranking = ranking.normalized();
2496 record.reasons = ranking.reasons;
2497 record.confidence = ranking.confidence;
2498 record.duration_ms = out.duration_ms;
2499 self.state.event(
2500 "judge",
2501 format!(
2502 "judge {} ranked {}",
2503 j + 1,
2504 record.ranking.iter().collect::<String>()
2505 ),
2506 );
2507 }
2508 Err(e) => {
2509 record.failed = Some(e.to_string());
2510 self.state
2511 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2512 }
2513 }
2514 self.state.judgements.push(record);
2515 self.state.save()?;
2516 }
2517 Ok(())
2518 }
2519
2520 async fn deliberate(&mut self) -> Result<()> {
2523 let run_id = self.state.id.clone();
2528 let prompts = self.state.config.prompts.clone();
2529 if !self.state.deliberation.is_empty() {
2530 return Ok(());
2531 }
2532 let tops: Vec<char> = self
2533 .state
2534 .judgements
2535 .iter()
2536 .filter_map(|j| j.ranking.first().copied())
2537 .collect();
2538 let rounds = self.state.config.graph.deliberate_rounds;
2539 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2540 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2541 self.state.event(
2542 "deliberate",
2543 format!("judges agreed on {} outright; no deliberation", tops[0]),
2544 );
2545 }
2546 self.state.status = RunStatus::Voting;
2547 self.state.save()?;
2548 return Ok(());
2549 }
2550
2551 self.state.status = RunStatus::Deliberating;
2552 self.state.event(
2553 "deliberate",
2554 format!(
2555 "split: first choices were {} — opening {rounds} round(s)",
2556 tops.iter().collect::<String>()
2557 ),
2558 );
2559
2560 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2561 let language = self.state.config.graph.language.clone();
2562 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2563 let sessions = self.state.config.graph.sessions;
2564 let artifacts = agent::artifacts_dir(&self.state.dir());
2565 let root = self.state.worktree_root();
2566 let base_short = short(&self.state.base_commit);
2567
2568 for round in 1..=rounds {
2572 let mut turns: Vec<DeliberationTurn> = Vec::new();
2573 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2574 if self.state.judgements[j].failed.is_some() {
2575 continue;
2576 }
2577 let seat_key = format!("judge-{}", j + 1);
2578 let spec = self.occupant(&seat_key, spec);
2579 let mut seat = self.seat(&seat_key, &spec.id);
2580 let transcript = self.transcript(&turns, j);
2581 let context = if has_context(&spec, &seat, sessions) {
2582 None
2583 } else {
2584 Some(self.candidate_block(&viable, &base_short))
2585 };
2586 let text = prompt::deliberate(
2587 &self.state.instruction,
2588 context.as_deref(),
2589 &transcript,
2590 round,
2591 rounds,
2592 &language,
2593 );
2594 let job = SeatJob {
2595 spec,
2596 seat: seat.clone(),
2597 prompt: text,
2598 cwd: root.join(format!("judge-{}", j + 1)),
2599 timeout,
2600 allow_write: false,
2601 sessions,
2602 artifacts: artifacts.clone(),
2603 stem: format!("delib-{round}-judge-{}", j + 1),
2604 };
2605 let cache = self.state.config.cache_dir();
2606 let ctx = WaveCtx {
2607 run: &run_id,
2608 node: "deliberate",
2609 prompts: &prompts,
2610 cache: cache.as_deref(),
2611 round: None,
2612 };
2613 let (updated, out) =
2614 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2615 seat = updated;
2616 let agent_id = seat.agent.clone();
2617 let seat_key = seat.key.clone();
2618 self.state.seats.insert(seat.key.clone(), seat);
2619 let body = match out {
2620 AgentOutcome::Ok(o) => verdict::section(&o.text, "position").unwrap_or(o.text),
2621 AgentOutcome::Dropped(o) => {
2625 let why =
2626 o.dropped.as_ref().map(|d| d.why.as_str()).unwrap_or(
2627 "the CLI ended the stream without delivering its answer",
2628 );
2629 self.state.event(
2630 "deliberate",
2631 format!(
2632 "judge {} skipped: the CLI dropped the stream ({why})",
2633 j + 1
2634 ),
2635 );
2636 continue;
2637 }
2638 AgentOutcome::Quota(o) => {
2639 self.state.quota.push(QuotaLoss {
2640 seat: seat_key,
2641 node: "deliberate".to_owned(),
2642 at: Timestamp::now(),
2643 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2644 });
2645 self.state.event(
2646 "deliberate",
2647 format!("judge {} skipped: rate limited (quota)", j + 1),
2648 );
2649 continue;
2650 }
2651 AgentOutcome::Failed(e) => {
2652 self.state
2653 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2654 continue;
2655 }
2656 };
2657 let tentative = verdict::extract_json::<Position>(&body)
2658 .ok()
2659 .and_then(|p| p.tentative)
2660 .and_then(|s| s.trim().chars().next())
2661 .map(|c| c.to_ascii_uppercase());
2662 self.state.event(
2663 "deliberate",
2664 format!(
2665 "round {round}: judge {} now favours {}",
2666 j + 1,
2667 tentative.map_or("—".to_owned(), |c| c.to_string())
2668 ),
2669 );
2670 turns.push(DeliberationTurn {
2671 judge: j + 1,
2672 agent: agent_id,
2673 body: blind::sanitize_prose(&body, &self.state.config.blind),
2674 tentative,
2675 });
2676 }
2677 self.state
2678 .deliberation
2679 .push(DeliberationRound { round, turns });
2680 self.state.save()?;
2681 }
2682
2683 self.state.status = RunStatus::Voting;
2684 self.state.save()?;
2685 Ok(())
2686 }
2687
2688 async fn vote(&mut self) -> Result<()> {
2691 let run_id = self.state.id.clone();
2696 let prompts = self.state.config.prompts.clone();
2697 if !self.state.votes.is_empty() {
2698 return Ok(());
2699 }
2700 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2701 if viable.len() == 1 {
2702 return Ok(());
2703 }
2704 self.state.status = RunStatus::Voting;
2705
2706 let language = self.state.config.graph.language.clone();
2707 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2708 let sessions = self.state.config.graph.sessions;
2709 let artifacts = agent::artifacts_dir(&self.state.dir());
2710 let root = self.state.worktree_root();
2711 let base_short = short(&self.state.base_commit);
2712 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2713
2714 let mut jobs = Vec::new();
2715 let mut seats_at = Vec::new();
2716 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2717 if self
2718 .state
2719 .judgements
2720 .get(j)
2721 .is_some_and(|r| r.failed.is_some())
2722 {
2723 continue;
2724 }
2725 let seat_key = format!("judge-{}", j + 1);
2726 let spec = self.occupant(&seat_key, spec);
2727 let seat = self.seat(&seat_key, &spec.id);
2728 let mut text = prompt::final_vote(&viable, &language);
2729 if !has_context(&spec, &seat, sessions) {
2730 text = format!(
2731 "{}\n\n# Candidates\n\n{}",
2732 text,
2733 self.candidate_block(&candidates, &base_short)
2734 );
2735 }
2736 jobs.push(SeatJob {
2737 spec,
2738 seat,
2739 prompt: text,
2740 cwd: root.join(format!("judge-{}", j + 1)),
2741 timeout,
2742 allow_write: false,
2743 sessions,
2744 artifacts: artifacts.clone(),
2745 stem: format!("vote-judge-{}", j + 1),
2746 });
2747 seats_at.push(j);
2748 }
2749
2750 self.state.event(
2751 "vote",
2752 format!(
2753 "collecting {} final votes one by one, privately",
2754 jobs.len()
2755 ),
2756 );
2757 let allowed = viable.clone();
2758 let mut quota_losses = Vec::new();
2759 let cache = self.state.config.cache_dir();
2760 let ctx = WaveCtx {
2761 run: &run_id,
2762 node: "vote",
2763 prompts: &prompts,
2764 cache: cache.as_deref(),
2765 round: None,
2766 };
2767 let results = ask_json_wave::<FinalVote>(
2768 jobs,
2769 Arc::clone(&self.sem),
2770 self.state.config.graph.retries,
2771 &[],
2772 &ctx,
2773 &mut quota_losses,
2774 &mut self.state,
2775 &move |v: &FinalVote| match v.label() {
2776 Some(c) if allowed.contains(&c) => Ok(()),
2777 other => bail!("vote {other:?} is not one of {allowed:?}"),
2778 },
2779 )
2780 .await;
2781 self.state.quota.extend(quota_losses);
2782
2783 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2784 let agent_id = seat.agent.clone();
2785 self.state.seats.insert(seat.key.clone(), seat);
2786 let initial = self
2787 .state
2788 .judgements
2789 .get(j)
2790 .and_then(|r| r.ranking.first().copied());
2791 let mut record = VoteRecord {
2792 judge: j + 1,
2793 agent: agent_id,
2794 vote: None,
2795 reason: String::new(),
2796 changed: false,
2797 };
2798 match res {
2799 Ok((v, _)) => {
2800 record.vote = v.label();
2801 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2802 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2803 self.state.event(
2804 "vote",
2805 format!(
2806 "judge {} voted {}{}",
2807 j + 1,
2808 record.vote.unwrap_or('?'),
2809 if record.changed { " (changed)" } else { "" }
2810 ),
2811 );
2812 }
2813 Err(e) => {
2814 self.state
2815 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
2816 }
2817 }
2818 self.state.votes.push(record);
2819 self.state.save()?;
2820 }
2821 Ok(())
2822 }
2823
2824 fn tally(&mut self) -> Result<()> {
2827 if self.state.tally.is_some() {
2828 return Ok(());
2829 }
2830 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2831 let tops: Vec<char> = self
2832 .state
2833 .judgements
2834 .iter()
2835 .filter_map(|j| j.ranking.first().copied())
2836 .collect();
2837 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
2838
2839 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2842 let mut cast: Vec<char> = Vec::new();
2843 for (i, j) in self.state.judgements.iter().enumerate() {
2844 let vote = self
2845 .state
2846 .votes
2847 .iter()
2848 .find(|v| v.judge == i + 1)
2849 .and_then(|v| v.vote)
2850 .or_else(|| j.ranking.first().copied());
2851 if let Some(v) = vote {
2852 *first_choice.entry(v).or_insert(0) += 1;
2853 cast.push(v);
2854 }
2855 }
2856
2857 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2858 for j in &self.state.judgements {
2859 let n = j.ranking.len();
2860 for (pos, label) in j.ranking.iter().enumerate() {
2861 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
2862 }
2863 }
2864
2865 let best = first_choice.values().copied().max().unwrap_or(0);
2866 let mut leaders: Vec<char> = first_choice
2867 .iter()
2868 .filter(|(_, v)| **v == best)
2869 .map(|(k, _)| *k)
2870 .collect();
2871 let mut tie_break = None;
2872 if leaders.len() > 1 {
2873 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
2874 let borda_leaders: Vec<char> = leaders
2875 .iter()
2876 .copied()
2877 .filter(|l| borda[l] == top_borda)
2878 .collect();
2879 tie_break = Some(if borda_leaders.len() == 1 {
2880 format!(
2881 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
2882 leaders.len()
2883 )
2884 } else {
2885 format!(
2886 "{} way tie on both first-choice votes and Borda points, broken by label order",
2887 leaders.len()
2888 )
2889 });
2890 leaders = borda_leaders;
2891 leaders.sort_unstable();
2892 }
2893 let winner = *leaders
2894 .first()
2895 .or(viable.first())
2896 .context("no candidate to declare a winner from")?;
2897
2898 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
2899 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
2900 let deliberated = !self.state.deliberation.is_empty();
2901
2902 let quota_seats: std::collections::BTreeSet<&str> =
2906 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
2907 let mut present = 0usize;
2908 for (i, j) in self.state.judgements.iter().enumerate() {
2909 if quota_seats.contains(j.seat.as_str()) {
2910 continue;
2911 }
2912 let ranked = !j.ranking.is_empty() && j.failed.is_none();
2913 let voted = self
2914 .state
2915 .votes
2916 .iter()
2917 .any(|v| v.judge == i + 1 && v.vote.is_some());
2918 if ranked || voted {
2919 present += 1;
2920 }
2921 }
2922 let needs_quorum = viable.len() > 1;
2928 let judges_total = if needs_quorum {
2929 self.roles.judges.len()
2930 } else {
2931 0
2932 };
2933 let quorum = if needs_quorum {
2934 judges_total / 2 + 1
2935 } else {
2936 0
2937 };
2938 let met_quorum = !needs_quorum || present >= quorum;
2939 let uncontested = (!needs_quorum).then(|| {
2940 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
2941 });
2942
2943 self.state.event(
2944 "tally",
2945 match &uncontested {
2946 Some(reason) => format!("winner {winner} — {reason}"),
2947 None => format!(
2948 "winner {winner} — votes {} | initial {} | {} changed | \
2949 {present}/{judges_total} judges{}",
2950 first_choice
2951 .iter()
2952 .map(|(k, v)| format!("{k}:{v}"))
2953 .collect::<Vec<_>>()
2954 .join(" "),
2955 if unanimous_initial {
2956 "unanimous"
2957 } else {
2958 "split"
2959 },
2960 changed_votes,
2961 if met_quorum {
2962 String::new()
2963 } else {
2964 format!(" — below quorum ({quorum} required)")
2965 },
2966 ),
2967 },
2968 );
2969 if !met_quorum {
2970 self.state.event(
2971 "stall",
2972 format!(
2973 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
2974 the run stops here, resumable"
2975 ),
2976 );
2977 }
2978 self.state.tally = Some(Tally {
2979 first_choice,
2980 borda,
2981 winner,
2982 rankings: tops.len(),
2983 unanimous_initial,
2984 deliberated,
2985 changed_votes,
2986 unanimous_final,
2987 tie_break,
2988 judges: judges_total,
2989 present,
2990 quorum,
2991 met_quorum,
2992 uncontested,
2993 });
2994 self.state.status = if met_quorum {
2995 RunStatus::Reviewing
2996 } else {
2997 RunStatus::Stalled
2998 };
2999 self.state.save()?;
3000 Ok(())
3001 }
3002
3003 #[allow(clippy::too_many_lines)]
3024 async fn recover_stall(&mut self) -> Result<bool> {
3025 let run_id = self.state.id.clone();
3030 let prompts = self.state.config.prompts.clone();
3031 let quota_seats: BTreeSet<&str> =
3036 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3037 let absent: Vec<String> = self
3038 .state
3039 .judgements
3040 .iter()
3041 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3042 .map(|j| j.seat.clone())
3043 .collect();
3044 if absent.is_empty() {
3045 return Ok(false);
3046 }
3047 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3048 if viable.len() <= 1 {
3049 return Ok(false);
3050 }
3051 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3052 let language = self.state.config.graph.language.clone();
3053 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3054 let sessions = self.state.config.graph.sessions;
3055 let artifacts = agent::artifacts_dir(&self.state.dir());
3056 let root = self.state.worktree_root();
3057 let base_short = short(&self.state.base_commit);
3058 let candidates: Vec<Candidate> = viable.clone();
3059
3060 let mut positions: Vec<usize> = absent
3062 .iter()
3063 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3064 .collect();
3065 if positions.is_empty() {
3066 return Ok(false);
3067 }
3068 positions.sort_unstable();
3069 positions.dedup();
3070
3071 let mut judge_jobs = Vec::new();
3073 for &j in &positions {
3074 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3075 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3076 let seat_key = format!("judge-{}", j + 1);
3077 let spec = self.roles.judges[j].clone();
3078 let seat = self.seat(&seat_key, &spec.id);
3079 judge_jobs.push(SeatJob {
3080 spec,
3081 seat,
3082 prompt: prompt::judge(
3083 &self.state.instruction,
3084 &views,
3085 self.roles.judges.len(),
3086 &base_short,
3087 &language,
3088 ),
3089 cwd: root.join(seat_key),
3090 timeout,
3091 allow_write: false,
3092 sessions,
3093 artifacts: artifacts.clone(),
3094 stem: format!("judge-{}-recover", j + 1),
3095 });
3096 }
3097
3098 let labels_for_check = labels.clone();
3099 let mut judge_losses = Vec::new();
3100 let retries = self.state.config.graph.retries;
3101 let cache = self.state.config.cache_dir();
3102 let ctx = WaveCtx {
3103 run: &run_id,
3104 node: "judge",
3105 prompts: &prompts,
3106 cache: cache.as_deref(),
3107 round: None,
3108 };
3109 let results = ask_json_wave::<Ranking>(
3110 judge_jobs,
3111 Arc::clone(&self.sem),
3112 retries,
3113 &[],
3114 &ctx,
3115 &mut judge_losses,
3116 &mut self.state,
3117 &move |r: &Ranking| r.validate(&labels_for_check),
3118 )
3119 .await;
3120
3121 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3123 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3124 self.state.seats.insert(seat.key.clone(), seat);
3125 let record = &mut self.state.judgements[j];
3126 match res {
3127 Ok((ranking, out)) => {
3128 record.ranking = ranking.normalized();
3129 record.reasons = ranking.reasons;
3130 record.confidence = ranking.confidence;
3131 record.failed = None;
3132 record.duration_ms = out.duration_ms;
3133 recovered.insert(j);
3134 self.state.event(
3135 "recover",
3136 format!("judge {} ranked again after the limit", j + 1),
3137 );
3138 }
3139 Err(e) => {
3140 self.state
3141 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3142 }
3143 }
3144 }
3145
3146 let mut vote_jobs = Vec::new();
3148 let mut vote_pos: Vec<usize> = Vec::new();
3149 for &j in &recovered {
3150 let seat_key = format!("judge-{}", j + 1);
3151 let spec = self.roles.judges[j].clone();
3152 let seat = self.seat(&seat_key, &spec.id);
3153 let mut text = prompt::final_vote(&labels, &language);
3154 if !has_context(&spec, &seat, sessions) {
3155 text = format!(
3156 "{}\n\n# Candidates\n\n{}",
3157 text,
3158 self.candidate_block(&candidates, &base_short)
3159 );
3160 }
3161 vote_jobs.push(SeatJob {
3162 spec,
3163 seat,
3164 prompt: text,
3165 cwd: root.join(seat_key),
3166 timeout,
3167 allow_write: false,
3168 sessions,
3169 artifacts: artifacts.clone(),
3170 stem: format!("vote-judge-{}-recover", j + 1),
3171 });
3172 vote_pos.push(j);
3173 }
3174 let allowed = labels.clone();
3175 let mut vote_losses = Vec::new();
3176 let vote_retries = self.state.config.graph.retries;
3177 let vote_cache = self.state.config.cache_dir();
3178 let ctx = WaveCtx {
3179 run: &run_id,
3180 node: "vote",
3181 prompts: &prompts,
3182 cache: vote_cache.as_deref(),
3183 round: None,
3184 };
3185 let votes = ask_json_wave::<FinalVote>(
3186 vote_jobs,
3187 Arc::clone(&self.sem),
3188 vote_retries,
3189 &[],
3190 &ctx,
3191 &mut vote_losses,
3192 &mut self.state,
3193 &move |v: &FinalVote| match v.label() {
3194 Some(c) if allowed.contains(&c) => Ok(()),
3195 other => bail!("vote {other:?} is not one of {allowed:?}"),
3196 },
3197 )
3198 .await;
3199 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3200 let agent_id = seat.agent.clone();
3201 self.state.seats.insert(seat.key.clone(), seat);
3202 match res {
3203 Ok((v, _)) => {
3204 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3205 rec.vote = v.label();
3206 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3207 } else {
3208 self.state.votes.push(VoteRecord {
3209 judge: j + 1,
3210 agent: agent_id,
3211 vote: v.label(),
3212 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3213 changed: false,
3214 });
3215 }
3216 self.state.event(
3217 "recover",
3218 format!("judge {} voted again after the limit", j + 1),
3219 );
3220 }
3221 Err(e) => {
3222 self.state
3223 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3224 }
3225 }
3226 }
3227
3228 let recovered_keys: BTreeSet<String> = recovered
3232 .iter()
3233 .map(|&j| format!("judge-{}", j + 1))
3234 .collect();
3235 self.state
3236 .quota
3237 .retain(|q| !recovered_keys.contains(&q.seat));
3238 for loss in judge_losses.into_iter().chain(vote_losses) {
3242 if recovered_keys.contains(&loss.seat) {
3243 continue;
3244 }
3245 self.state.quota.retain(|q| q.seat != loss.seat);
3246 self.state.quota.push(loss);
3247 }
3248
3249 self.state.tally = None;
3251 self.tally()?;
3252 Ok(self
3253 .state
3254 .tally
3255 .as_ref()
3256 .map(|t| t.met_quorum)
3257 .unwrap_or(false))
3258 }
3259
3260 async fn fold_losers(&mut self) -> Result<()> {
3263 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3264 return Ok(());
3265 };
3266 let repo = self.state.repo.clone();
3267 let mut folded = Vec::new();
3268 for i in 0..self.state.candidates.len() {
3269 let c = &self.state.candidates[i];
3270 if c.label == winner || c.folded {
3271 continue;
3272 }
3273 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3274 git::worktree_remove(&repo, &wt).await.ok();
3275 git::branch_delete(&repo, &branch).await.ok();
3276 self.state.candidates[i].folded = true;
3277 folded.push(label.to_string());
3278 }
3279 let root = self.state.worktree_root();
3281 for j in 1..=self.roles.judges.len() {
3282 let wt = root.join(format!("judge-{j}"));
3283 if wt.exists() {
3284 git::worktree_remove(&repo, &wt).await.ok();
3285 }
3286 }
3287 if self.state.config.graph.advise {
3290 for k in 1..=self.state.config.graph.advisors {
3291 let wt = root.join(format!("advisor-{k}"));
3292 if wt.exists() {
3293 git::worktree_remove(&repo, &wt).await.ok();
3294 }
3295 }
3296 }
3297 if !folded.is_empty() {
3298 self.state
3299 .event("fold", format!("folded candidates {}", folded.join(", ")));
3300 self.state.save()?;
3301 }
3302 Ok(())
3303 }
3304
3305 async fn sync_to_base(&mut self) -> Result<()> {
3345 if self.state.status == RunStatus::AlreadyInBase {
3346 return Ok(());
3347 }
3348 let conflicted = self
3349 .state
3350 .base_sync
3351 .as_ref()
3352 .is_some_and(|s| s.conflict.is_some());
3353 let Some(winner) = self.state.winner().cloned() else {
3354 return Ok(());
3355 };
3356
3357 let repo = self.state.repo.clone();
3358 let remote = self.state.config.merge.remote.clone();
3359 let base_branch = self.state.base_branch.clone();
3360 let tracking = format!("{remote}/{base_branch}");
3361
3362 git::fetch(&repo, &remote, &base_branch).await.ok();
3363 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3367 return Ok(());
3368 };
3369
3370 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3371 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3372 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3373
3374 if (behind > 0 || conflicted || head == tip)
3383 && self
3384 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3385 .await?
3386 {
3387 return Ok(());
3388 }
3389 if conflicted {
3390 return Ok(());
3391 }
3392
3393 if behind == 0 {
3394 if let Some(from) = self
3403 .state
3404 .rebase_fixes
3405 .iter()
3406 .rev()
3407 .find_map(|r| r.from.clone())
3408 && from != head
3409 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3410 .await
3411 .is_ok_and(|o| o.ok())
3412 {
3413 git::sync_to_head(&winner.worktree).await?;
3414 }
3415 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3421 if let Some(why) = &conflict {
3422 self.state.status = RunStatus::Blocked;
3423 self.state.event("land", why.clone());
3424 }
3425 self.state.base_sync = Some(BaseSync {
3426 tip,
3427 behind: 0,
3428 attempts,
3429 conflict,
3430 already_in: None,
3431 });
3432 self.state.save()?;
3433 return Ok(());
3434 }
3435
3436 if attempts >= BASE_SYNC_ROUNDS {
3437 let why = format!(
3438 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3439 rebase(s); rebasing again would only race it",
3440 winner.branch
3441 );
3442 self.state.status = RunStatus::Blocked;
3443 self.state.base_sync = Some(BaseSync {
3444 tip,
3445 behind,
3446 attempts,
3447 conflict: Some(why.clone()),
3448 already_in: None,
3449 });
3450 self.state.event("land", why);
3451 self.state.save()?;
3452 return Ok(());
3453 }
3454
3455 self.state.event(
3456 "land",
3457 format!(
3458 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3459 winner.branch
3460 ),
3461 );
3462 self.state.save()?;
3463
3464 let branch_tracking = format!("{remote}/{}", winner.branch);
3470 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3471 let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3472 git::rev_parse(&repo, &branch_tracking).await.ok()
3473 } else {
3474 None
3475 };
3476 if let Some(theirs) = &remote_tip
3480 && !git::is_ancestor(&repo, theirs, &head).await
3481 && !crate::reconcile::origin_missing(&repo, &head, theirs)
3482 .await
3483 .is_ok_and(|missing| missing.is_empty())
3484 {
3485 let why = format!(
3486 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3487 them",
3488 short(theirs),
3489 winner.branch
3490 );
3491 self.state.status = RunStatus::Blocked;
3492 self.state.base_sync = Some(BaseSync {
3493 tip,
3494 behind,
3495 attempts,
3496 conflict: Some(why.clone()),
3497 already_in: None,
3498 });
3499 self.state.event("land", why);
3500 self.state.save()?;
3501 return Ok(());
3502 }
3503
3504 let scratch = self.state.dir().join("base-sync");
3505 let rebased = match crate::rebase::rebase_with_fixer(
3506 &mut self.state,
3507 &scratch,
3508 &winner.branch,
3509 &tracking,
3510 )
3511 .await
3512 {
3513 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3514 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3515 Err(e) => Err(e),
3516 };
3517 let attempts = attempts + 1;
3518 match rebased {
3519 Ok(None) => {
3520 git::sync_to_head(&winner.worktree).await?;
3524 let mut conflict = None;
3525 if let Some(pinned) = &remote_tip {
3526 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3527 match pushed {
3528 Ok(o) if o.ok() => self.state.event(
3529 "land",
3530 format!("pushed rebased {} to {remote}", winner.branch),
3531 ),
3532 Ok(o) => {
3533 conflict = Some(format!(
3534 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
3535 winner.branch,
3536 short(pinned),
3537 o.stderr.chars().take(600).collect::<String>()
3538 ));
3539 }
3540 Err(e) => {
3541 conflict = Some(format!(
3542 "rebased {} locally but could not push it: {e:#}",
3543 winner.branch
3544 ));
3545 }
3546 }
3547 }
3548 if let Some(why) = &conflict {
3549 self.state.status = RunStatus::Blocked;
3550 self.state.event("land", why.clone());
3551 }
3552 self.state.base_sync = Some(BaseSync {
3553 tip: tip.clone(),
3554 behind: 0,
3555 attempts,
3556 conflict,
3557 already_in: None,
3558 });
3559 self.state
3560 .event("land", format!("rebased {} onto {tracking}", winner.branch));
3561 }
3562 Ok(Some(conflict)) => {
3563 let why = format!(
3564 "{} conflicts with {tracking} and did not rebase: {}",
3565 winner.branch,
3566 conflict.chars().take(600).collect::<String>()
3567 );
3568 self.state.status = RunStatus::Blocked;
3569 self.state.base_sync = Some(BaseSync {
3570 tip,
3571 behind,
3572 attempts,
3573 conflict: Some(why.clone()),
3574 already_in: None,
3575 });
3576 self.state.event("land", why);
3577 }
3578 Err(e) => {
3579 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3580 self.state.status = RunStatus::Blocked;
3581 self.state.base_sync = Some(BaseSync {
3582 tip,
3583 behind,
3584 attempts,
3585 conflict: Some(why.clone()),
3586 already_in: None,
3587 });
3588 self.state.event("land", why);
3589 }
3590 }
3591 self.state.save()?;
3592 Ok(())
3593 }
3594
3595 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3601 let repo = self.state.repo.clone();
3602 let remote = self.state.config.merge.remote.clone();
3603 let fetched = git::fetch(&repo, &remote, branch).await;
3604 if !matches!(&fetched, Ok(o) if o.ok()) {
3605 return None;
3606 }
3607 let branch_tracking = format!("{remote}/{branch}");
3608 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3609 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3610 return None;
3611 }
3612 if !crate::reconcile::origin_missing(&repo, head, &theirs)
3613 .await
3614 .is_ok_and(|missing| missing.is_empty())
3615 {
3616 return Some(format!(
3617 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3618 them",
3619 short(&theirs)
3620 ));
3621 }
3622 match git::push_pinned(&repo, &remote, branch, &theirs).await {
3623 Ok(o) if o.ok() => {
3624 self.state
3625 .event("land", format!("pushed rebased {branch} to {remote}"));
3626 None
3627 }
3628 Ok(o) => Some(format!(
3629 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3630 someone may have pushed): {}",
3631 short(&theirs),
3632 o.stderr.chars().take(600).collect::<String>()
3633 )),
3634 Err(e) => Some(format!(
3635 "{branch} is rebased locally but could not be pushed: {e:#}"
3636 )),
3637 }
3638 }
3639
3640 async fn settle_already_in(
3657 &mut self,
3658 branch: &str,
3659 tip: &str,
3660 head: &str,
3661 attempts: usize,
3662 behind: usize,
3663 ) -> Result<bool> {
3664 let repo = self.state.repo.clone();
3665 let remote = self.state.config.merge.remote.clone();
3666 let start = self.state.base_commit.clone();
3667 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3668 Ok(Some(e)) => e,
3669 Ok(None) => return Ok(false),
3670 Err(e) => {
3671 tracing::warn!("already-in-base check for {branch}: {e:#}");
3672 return Ok(false);
3673 }
3674 };
3675 let mut verified = vec![head.to_owned()];
3676 let fetched = git::fetch(&repo, &remote, branch).await;
3677 if matches!(&fetched, Ok(o) if o.ok())
3678 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3679 && theirs != head
3680 {
3681 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3682 Ok(Some(_)) => verified.push(theirs),
3683 _ => return Ok(false),
3684 }
3685 }
3686 let base_branch = self.state.base_branch.clone();
3687 let message = format!(
3688 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3689 land",
3690 evidence.names(),
3691 evidence.proof.as_str()
3692 );
3693 let closed =
3694 crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3695 match closed {
3696 Ok(Ok(url)) => self
3697 .state
3698 .event("land", format!("closed {url}: superseded on {base_branch}")),
3699 Ok(Err(why)) => self
3700 .state
3701 .event("land", format!("did not close a pull request: {why}")),
3702 Err(e) => {
3703 let why = format!(
3704 "{branch} is already in {remote}/{base_branch}, but its pull request could \
3705 not be closed ({e:#}); resume to retry"
3706 );
3707 self.state.status = RunStatus::Blocked;
3708 self.state.base_sync = Some(BaseSync {
3709 tip: tip.to_owned(),
3710 behind,
3711 attempts,
3712 conflict: Some(why.clone()),
3713 already_in: None,
3714 });
3715 self.state.event("land", why);
3716 self.state.save()?;
3717 return Ok(true);
3718 }
3719 }
3720 self.state.status = RunStatus::AlreadyInBase;
3721 self.state.base_sync = Some(BaseSync {
3722 tip: tip.to_owned(),
3723 behind,
3724 attempts,
3725 conflict: None,
3726 already_in: Some(evidence),
3727 });
3728 self.state.event("land", message);
3729 self.state.save()?;
3730 self.settle_questions();
3731 Ok(true)
3732 }
3733
3734 fn landing_base(&self) -> String {
3744 self.state
3745 .base_sync
3746 .as_ref()
3747 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3748 }
3749
3750 pub async fn fix_selected(
3783 &mut self,
3784 ids: &[String],
3785 reason: &str,
3786 allow_stale: bool,
3787 ) -> Result<()> {
3788 let reason = reason.trim();
3789 if reason.is_empty() {
3790 bail!("a fix request needs a reason — that is the operator's own record of why");
3791 }
3792 if ids.is_empty() {
3793 bail!("no finding id given");
3794 }
3795 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3796 bail!(
3797 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3798 has already concluded — can be given a targeted fix. A run still \
3799 in progress should simply be resumed; a `merged` run's branch has \
3800 already landed, so its answer is a fresh `magi review <branch>`, \
3801 not reopening this run's own record",
3802 self.state.id,
3803 self.state.status.as_str()
3804 );
3805 }
3806 let Some(winner) = self.state.winner().cloned() else {
3807 bail!("run {} has no winning candidate to fix", self.state.id);
3808 };
3809 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
3810 bail!(
3811 "branch `{}` no longer exists; this run cannot be extended",
3812 winner.branch
3813 );
3814 }
3815 let home = crate::run::home();
3816 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
3817 bail!(
3818 "run {} is currently being worked on by another magi process",
3819 self.state.id
3820 );
3821 }
3822 let _claim = FixClaim::acquire(&self.state.dir())?;
3828
3829 let mut seen = BTreeSet::new();
3833 let mut findings = Vec::new();
3834 let mut missing = Vec::new();
3835 for id in ids {
3836 if !seen.insert(id.clone()) {
3837 continue;
3838 }
3839 match self.state.finding(id) {
3840 Some((round, rec, f)) => findings.push(OperatorFixFinding {
3841 id: f.id.clone(),
3842 severity: f.severity,
3843 reviewer_vote: rec.vote,
3844 round: round.round,
3845 round_head: round.head.clone(),
3846 reviewer: rec.reviewer,
3847 agent: rec.agent.clone(),
3848 file: f.file.clone(),
3849 line: f.line,
3850 title: f.title.clone(),
3851 detail: f.detail.clone(),
3852 outcome: OperatorFixOutcome::Pending,
3853 }),
3854 None => missing.push(id.clone()),
3855 }
3856 }
3857 if !missing.is_empty() {
3858 bail!(
3859 "unknown finding id(s): {}; nothing was changed",
3860 missing.join(", ")
3861 );
3862 }
3863
3864 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
3865 let stale_details: Vec<(String, String)> = findings
3866 .iter()
3867 .filter(|f| f.round_head != head_at_request)
3868 .map(|f| (f.id.clone(), f.round_head.clone()))
3869 .collect();
3870 let stale = !stale_details.is_empty();
3871 if stale && !allow_stale {
3872 bail!(
3873 "the branch has moved since some finding(s) were raised — {} — now \
3874 at {}; pass --allow-stale to fix anyway, or re-run review first",
3875 stale_details
3876 .iter()
3877 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
3878 .collect::<Vec<_>>()
3879 .join(", "),
3880 short(&head_at_request)
3881 );
3882 }
3883
3884 let request = OperatorFixRequest {
3885 requested_at: Timestamp::now(),
3886 reason: reason.to_owned(),
3887 findings,
3888 head_at_request: head_at_request.clone(),
3889 allow_stale,
3890 stale,
3891 fix: None,
3892 result_head: None,
3893 follow_up_review_run: None,
3894 };
3895 self.state.event(
3896 "fix",
3897 format!(
3898 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
3899 request.findings.len(),
3900 request
3901 .findings
3902 .iter()
3903 .map(|f| f.id.as_str())
3904 .collect::<Vec<_>>()
3905 .join(", "),
3906 ),
3907 );
3908 self.state.operator_fixes.push(request);
3915 self.state.save()?;
3916 let request_index = self.state.operator_fixes.len() - 1;
3917
3918 if winner.worktree.exists() {
3927 let dirty = git::git(
3930 &winner.worktree,
3931 &["status", "--porcelain", "--untracked-files=all"],
3932 )
3933 .await?;
3934 let only_withheld = dirty.lines().all(|l| {
3935 l.strip_prefix("?? ")
3936 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
3937 });
3938 if !only_withheld {
3939 bail!(
3940 "`{}` has uncommitted changes; refusing to touch it — commit or \
3941 discard them first",
3942 winner.worktree.display()
3943 );
3944 }
3945 git::worktree_remove(&self.state.repo, &winner.worktree)
3946 .await
3947 .ok();
3948 }
3949 let fix_worktree = self.state.worktree_root().join("operator-fix");
3950 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
3951 git::git(
3952 &self.state.repo,
3953 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
3954 )
3955 .await
3956 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
3957 if !git::is_clean(&fix_worktree).await? {
3958 git::worktree_remove(&self.state.repo, &fix_worktree)
3959 .await
3960 .ok();
3961 bail!(
3962 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
3963 winner.branch
3964 );
3965 }
3966
3967 let run_id = self.state.id.clone();
3968 let prompts = self.state.config.prompts.clone();
3969 let language = self.state.config.graph.language.clone();
3970 let sessions = self.state.config.graph.sessions;
3971 let artifacts = agent::artifacts_dir(&self.state.dir());
3972 let (fix_spec, fix_seat_key) = match &self.roles.fixer {
3973 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
3974 _ => (
3975 self.state
3976 .config
3977 .agent(&winner.agent)
3978 .cloned()
3979 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
3980 format!("impl-{}", winner.label),
3981 ),
3982 };
3983 let seat = self.seat(&fix_seat_key, &fix_spec.id);
3984 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
3985 .findings
3986 .iter()
3987 .map(|f| Finding {
3988 id: f.id.clone(),
3989 severity: f.severity,
3990 file: f.file.clone(),
3991 line: f.line,
3992 title: f.title.clone(),
3993 detail: f.detail.clone(),
3994 })
3995 .collect();
3996 let job = SeatJob {
3997 prompt: prompt::operator_fix(
3998 &self.state.instruction,
3999 &finding_list,
4000 reason,
4001 &stale_details,
4002 &head_at_request,
4003 &language,
4004 ),
4005 spec: fix_spec.clone(),
4006 seat,
4007 cwd: fix_worktree.clone(),
4008 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
4009 allow_write: true,
4010 sessions,
4011 artifacts: artifacts.clone(),
4012 stem: "operator-fix".to_owned(),
4013 };
4014 let cache = self.state.config.cache_dir();
4015 let ctx = WaveCtx {
4016 run: &run_id,
4017 node: "fix",
4018 prompts: &prompts,
4019 cache: cache.as_deref(),
4020 round: None,
4021 };
4022 let (seat, out) =
4023 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4024 let agent_id = seat.agent.clone();
4025
4026 let mut fix = FixRecord {
4027 agent: agent_id,
4028 addressed: Vec::new(),
4029 rejected: Vec::new(),
4030 notes: String::new(),
4031 committed: false,
4032 failed: None,
4033 duration_ms: 0,
4034 continuation: None,
4035 };
4036 let mut final_seat = seat.clone();
4037 match out {
4038 AgentOutcome::Ok(o) => {
4039 fix.duration_ms = o.duration_ms;
4040 let parsed = verdict::extract_json::<FixReport>(&o.text);
4041 let incomplete_reason = match &parsed {
4042 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4043 "the reply parsed, but it reported a command whose own CLI \
4044 never confirmed an exit status"
4045 .to_owned(),
4046 ),
4047 Ok(_) => None,
4048 Err(e) => Some(e.to_string()),
4049 };
4050 match incomplete_reason {
4051 None => {
4052 let report = parsed.expect("checked Ok above");
4053 fix.addressed = report.addressed;
4054 fix.rejected = report.rejected;
4055 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4056 }
4057 Some(reason) => {
4058 let (resumed_seat, resolved, failure, cont) = self
4059 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4060 .await;
4061 fix.duration_ms += cont.cumulative_wait_ms;
4062 fix.continuation = Some(cont);
4063 final_seat = resumed_seat;
4064 match resolved {
4065 Some(report) => {
4066 fix.addressed = report.addressed;
4067 fix.rejected = report.rejected;
4068 fix.notes =
4069 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4070 }
4071 None => fix.failed = failure,
4072 }
4073 }
4074 }
4075 }
4076 AgentOutcome::Dropped(o) => {
4077 fix.duration_ms = o.duration_ms;
4078 let why = o
4079 .dropped
4080 .as_ref()
4081 .map(|d| d.why.as_str())
4082 .unwrap_or("the CLI ended the stream without delivering its answer");
4083 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4084 }
4085 AgentOutcome::Quota(o) => {
4086 self.state.quota.push(QuotaLoss {
4087 seat: final_seat.key.clone(),
4088 node: "fix".to_owned(),
4089 at: Timestamp::now(),
4090 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4091 });
4092 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4093 }
4094 AgentOutcome::Failed(e) => fix.failed = Some(e),
4095 }
4096 if fix.continuation.is_none() {
4097 fix.continuation = Some(ContinuationRecord::not_needed());
4098 }
4099 self.state.seats.insert(final_seat.key.clone(), final_seat);
4100
4101 let rescue_message = format!(
4102 "magi: operator-selected fix ({}) (uncommitted work)",
4103 self.state.operator_fixes[request_index]
4104 .findings
4105 .iter()
4106 .map(|f| f.id.as_str())
4107 .collect::<Vec<_>>()
4108 .join(", ")
4109 );
4110 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4111 self.state.note_withheld("fix", &r.withheld);
4112 }
4113 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4114 fix.committed = after != head_at_request;
4115 git::worktree_remove(&self.state.repo, &fix_worktree)
4116 .await
4117 .ok();
4118
4119 self.state.event(
4120 "fix",
4121 match &fix.failed {
4122 Some(reason) => format!(
4123 "operator fix: adoption report was lost ({reason}); {}",
4124 if fix.committed {
4125 "committed"
4126 } else {
4127 "NO new commit"
4128 }
4129 ),
4130 None => format!(
4131 "operator fix: {} addressed, {} rejected, {}",
4132 fix.addressed.len(),
4133 fix.rejected.len(),
4134 if fix.committed {
4135 "committed"
4136 } else {
4137 "NO new commit"
4138 }
4139 ),
4140 },
4141 );
4142
4143 for f in &mut self.state.operator_fixes[request_index].findings {
4150 f.outcome = if fix.failed.is_some() {
4151 OperatorFixOutcome::Unreported
4152 } else if fix.addressed.contains(&f.id) {
4153 OperatorFixOutcome::Addressed
4154 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4155 OperatorFixOutcome::Rejected { why: r.why.clone() }
4156 } else {
4157 OperatorFixOutcome::Unreported
4158 };
4159 }
4160
4161 let committed = fix.committed;
4162 if committed {
4163 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4164 }
4165 self.state.operator_fixes[request_index].fix = Some(fix);
4166 self.state.save()?;
4169
4170 if committed {
4171 self.state.event(
4172 "fix",
4173 format!(
4174 "operator fix committed {}; opening a follow-up review-only run",
4175 short(&after)
4176 ),
4177 );
4178 let origin =
4181 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4182 match Self::review(
4183 &self.state.repo,
4184 &winner.branch,
4185 self.state.config.clone(),
4186 origin,
4187 )
4188 .await
4189 {
4190 Ok(mut follow_up) => {
4191 follow_up.state.event(
4192 "start",
4193 format!(
4194 "requested by an operator fix on run {} for finding(s) {}",
4195 self.state.id,
4196 self.state.operator_fixes[request_index]
4197 .findings
4198 .iter()
4199 .map(|f| f.id.as_str())
4200 .collect::<Vec<_>>()
4201 .join(", "),
4202 ),
4203 );
4204 follow_up.state.save()?;
4205 let follow_up_id = follow_up.state.id.clone();
4206 if let Err(e) = follow_up.execute().await {
4207 self.state.event(
4208 "fix",
4209 format!(
4210 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4211 ),
4212 );
4213 }
4214 self.state.operator_fixes[request_index].follow_up_review_run =
4215 Some(follow_up_id);
4216 }
4217 Err(e) => {
4218 self.state.event(
4219 "fix",
4220 format!("committed the fix but could not open a follow-up review: {e:#}"),
4221 );
4222 }
4223 }
4224 self.state.save()?;
4225 }
4226
4227 Ok(())
4228 }
4229
4230 fn fixer_spec(&self, winner: &Candidate) -> (AgentSpec, String) {
4237 match &self.roles.fixer {
4238 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4239 _ => (
4240 self.state
4241 .config
4242 .agent(&winner.agent)
4243 .cloned()
4244 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4245 format!("impl-{}", winner.label),
4246 ),
4247 }
4248 }
4249
4250 async fn review_loop(&mut self) -> Result<()> {
4251 if self
4256 .state
4257 .base_sync
4258 .as_ref()
4259 .is_some_and(|s| s.conflict.is_some())
4260 {
4261 return Ok(());
4262 }
4263 let run_id = self.state.id.clone();
4268 let prompts = self.state.config.prompts.clone();
4269 let Some(winner) = self.state.winner().cloned() else {
4270 return Ok(());
4271 };
4272 let max_rounds = self.state.config.graph.review_rounds;
4273 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4283 if status == RunStatus::Gating {
4286 self.record_contested_handoff();
4287 }
4288 self.state.status = status;
4289 self.state.save()?;
4290 return Ok(());
4291 }
4292 self.state.status = RunStatus::Reviewing;
4293 if self
4303 .state
4304 .reviews
4305 .last()
4306 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4307 {
4308 let shell = self.state.config.shell();
4309 return self
4310 .stop_reviewing(
4311 "the last round's own verification never resolved",
4312 &shell,
4313 &winner.worktree,
4314 )
4315 .await;
4316 }
4317
4318 let repo = self.state.repo.clone();
4319 let root = self.state.worktree_root();
4320 let language = self.state.config.graph.language.clone();
4321 let sessions = self.state.config.graph.sessions;
4322 let artifacts = agent::artifacts_dir(&self.state.dir());
4323 let base = self.landing_base();
4324 let base_short = short(&base);
4325 let reviewers = self.roles.reviewers.clone();
4326 let shell = self.state.config.shell();
4327
4328 for round in (self.state.reviews.len() + 1)..=max_rounds {
4329 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4330 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4331 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4332 let prev_verification = self
4341 .state
4342 .reviews
4343 .last()
4344 .and_then(|r| r.verification_summary(&head));
4345
4346 let mut jobs = Vec::new();
4350 for (r, spec) in reviewers.iter().cloned().enumerate() {
4351 let wt = root.join(format!("review-{}", r + 1));
4352 if wt.exists() {
4353 git::reset_detached(&wt, &head).await?;
4354 } else {
4355 git::worktree_add_detached(&repo, &wt, &head).await?;
4356 }
4357 let seat_key = format!("review-{}", r + 1);
4358 let seat = self.seat(&seat_key, &spec.id);
4359 jobs.push(SeatJob {
4360 prompt: prompt::review(&prompt::ReviewCtx {
4361 instruction: &self.state.instruction,
4362 branch: &winner.branch,
4363 base_short: &base_short,
4364 stat: &stat,
4365 patch: &patch,
4366 verification: prev_verification.as_ref(),
4367 reviewers: reviewers.len(),
4368 round,
4369 rounds: max_rounds,
4370 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4373 lens: Lens::for_seat(r),
4374 language: &language,
4375 }),
4376 spec,
4377 seat,
4378 cwd: wt,
4379 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4380 allow_write: false,
4381 sessions,
4382 artifacts: artifacts.clone(),
4383 stem: format!("review-{round}-{}", r + 1),
4384 });
4385 }
4386
4387 self.state.event(
4388 "review",
4389 format!(
4390 "round {round}: {} reviewers on {}",
4391 jobs.len(),
4392 short(&head)
4393 ),
4394 );
4395 let mut quota_losses = Vec::new();
4396 let review_retries = self.state.config.graph.retries;
4397 let review_cache = self.state.config.cache_dir();
4398 let ctx = WaveCtx {
4399 run: &run_id,
4400 node: "review",
4401 prompts: &prompts,
4402 cache: review_cache.as_deref(),
4403 round: Some(round),
4404 };
4405 let results = ask_json_wave::<Review>(
4406 jobs,
4407 Arc::clone(&self.sem),
4408 review_retries,
4409 &self.roles.reviewer_roster,
4410 &ctx,
4411 &mut quota_losses,
4412 &mut self.state,
4413 &|_: &Review| Ok(()),
4414 )
4415 .await;
4416 let round_quota_missing = quota_losses.len();
4420 self.state.quota.extend(quota_losses);
4421
4422 let mut records = Vec::new();
4423 let mut all_findings = Vec::new();
4424 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4425 let agent_id = seat.agent.clone();
4426 self.state.seats.insert(seat.key.clone(), seat);
4427 let mut record = ReviewRecord {
4428 reviewer: r + 1,
4429 agent: agent_id,
4430 summary: String::new(),
4431 findings: Vec::new(),
4432 vote: None,
4433 failed: None,
4434 duration_ms: 0,
4435 attempts,
4441 };
4442 match res {
4443 Ok((review, out)) => {
4444 record.summary =
4452 blind::sanitize_prose(&review.summary, &self.state.config.blind);
4453 record.vote = Some(review.vote);
4454 record.duration_ms = out.duration_ms;
4455 for (n, mut f) in review.findings.into_iter().enumerate() {
4456 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4459 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4460 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4461 f.file = f
4467 .file
4468 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4469 all_findings.push(f.clone());
4470 record.findings.push(f);
4471 }
4472 self.state.event(
4473 "review",
4474 format!(
4475 "round {round}: reviewer {} voted {} with {} finding(s)",
4476 r + 1,
4477 review.vote.label(),
4478 record.findings.len()
4479 ),
4480 );
4481 }
4482 Err(e) => {
4483 record.failed = Some(e.to_string());
4484 self.state.event(
4485 "review",
4486 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4487 );
4488 }
4489 }
4490 records.push(record);
4491 }
4492
4493 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4500 let vote_split =
4501 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4502 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4503 if vote_split {
4504 self.state.event(
4505 "review",
4506 format!(
4507 "round {round}: votes split ({}) — one round of reconsideration",
4508 initial_votes
4509 .iter()
4510 .map(|v| v.label())
4511 .collect::<Vec<_>>()
4512 .join(", ")
4513 ),
4514 );
4515 let panel: Vec<ReviewSeatReport<'_>> = records
4518 .iter()
4519 .filter_map(|r| {
4520 r.vote.map(|vote| ReviewSeatReport {
4521 reviewer: r.reviewer,
4522 vote,
4523 summary: &r.summary,
4524 findings: &r.findings,
4525 })
4526 })
4527 .collect();
4528
4529 let mut jobs = Vec::new();
4530 let mut seats_at = Vec::new();
4531 for (r, spec) in reviewers.iter().cloned().enumerate() {
4532 if records[r].vote.is_none() {
4536 continue;
4537 }
4538 let wt = root.join(format!("review-{}", r + 1));
4539 let seat_key = format!("review-{}", r + 1);
4540 let spec = self.occupant(&seat_key, spec);
4541 let seat = self.seat(&seat_key, &spec.id);
4542 let patch_ctx = if has_context(&spec, &seat, sessions) {
4547 None
4548 } else {
4549 Some(ReviewPatch {
4550 branch: &winner.branch,
4551 base_short: &base_short,
4552 stat: &stat,
4553 patch: &patch,
4554 })
4555 };
4556 let prompt = prompt::review_reconsider(&ReviewReconsiderCtx {
4557 instruction: &self.state.instruction,
4558 reviewer: r + 1,
4559 lens: Lens::for_seat(r),
4560 panel: &panel,
4561 patch: patch_ctx,
4562 round,
4563 rounds: max_rounds,
4564 language: &language,
4565 });
4566 jobs.push(SeatJob {
4567 prompt,
4568 spec,
4569 seat,
4570 cwd: wt,
4571 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4572 allow_write: false,
4573 sessions,
4574 artifacts: artifacts.clone(),
4575 stem: format!("review-{round}-reconsider-{}", r + 1),
4576 });
4577 seats_at.push(r);
4578 }
4579
4580 let mut recon_quota_losses = Vec::new();
4581 let recon_cache = self.state.config.cache_dir();
4582 let recon_ctx = WaveCtx {
4583 run: &run_id,
4584 node: "review",
4585 prompts: &prompts,
4586 cache: recon_cache.as_deref(),
4587 round: Some(round),
4588 };
4589 let recon_results = ask_json_wave::<ReviewRevote>(
4590 jobs,
4591 Arc::clone(&self.sem),
4592 review_retries,
4593 &[],
4594 &recon_ctx,
4595 &mut recon_quota_losses,
4596 &mut self.state,
4597 &|_: &ReviewRevote| Ok(()),
4598 )
4599 .await;
4600 self.state.quota.extend(recon_quota_losses);
4601
4602 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4603 let agent_id = seat.agent.clone();
4604 self.state.seats.insert(seat.key.clone(), seat);
4605 let mut rec = ReviewRevoteRecord {
4606 reviewer: r + 1,
4607 agent: agent_id,
4608 vote: None,
4609 reason: String::new(),
4610 failed: None,
4611 };
4612 match res {
4613 Ok((rv, _)) => {
4614 rec.vote = Some(rv.vote);
4615 rec.reason =
4616 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4617 self.state.event(
4618 "review",
4619 format!(
4620 "round {round}: reviewer {} revoted {}",
4621 r + 1,
4622 rv.vote.label()
4623 ),
4624 );
4625 }
4626 Err(e) => {
4627 rec.failed = Some(e.to_string());
4628 self.state.event(
4629 "review",
4630 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4631 );
4632 }
4633 }
4634 reconsideration.push(rec);
4635 }
4636 } else if initial_votes.len() > 1 {
4637 self.state.event(
4638 "review",
4639 format!(
4640 "round {round}: votes agreed ({}) — no reconsideration",
4641 initial_votes[0].label()
4642 ),
4643 );
4644 }
4645
4646 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4647 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4648 let defer_e2e =
4659 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4660 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4661 let reason =
4662 format!("{blocking} blocking finding(s) already required a fix this round");
4663 self.state.event(
4664 "verify",
4665 format!(
4666 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4667 {}); it will run once a round has none left",
4668 short(&head)
4669 ),
4670 );
4671 (Vec::new(), false, true, Some(reason))
4672 } else {
4673 let e2e_commands = self.state.config.verify.e2e.clone();
4674 let cache_dir = self.state.config.cache_dir();
4675 let context = format!("round {round}");
4676 let (e2e, verify_retried) = with_cache_lease(
4677 &mut self.state,
4678 cache_dir.as_deref(),
4679 "e2e",
4680 "e2e",
4681 &winner.worktree,
4682 &head,
4683 verify_timeout,
4684 &context,
4685 |state, budget| {
4686 let shell = shell.clone();
4687 let e2e_commands = e2e_commands.clone();
4688 let worktree = winner.worktree.clone();
4689 let context = context.clone();
4690 async move {
4691 run_e2e_with_retry(
4692 state,
4693 &shell,
4694 &e2e_commands,
4695 &worktree,
4696 budget,
4697 &context,
4698 )
4699 .await
4700 }
4701 },
4702 )
4703 .await;
4704 (e2e, verify_retried, false, None)
4705 };
4706
4707 let expected = records.len();
4708 let answered = records.iter().filter(|r| r.failed.is_none()).count();
4709 let incomplete = answered < expected;
4710 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
4711 let policy = self.state.config.graph.incomplete_review;
4712 let clean = round_is_clean(
4713 blocking,
4714 e2e_ok,
4715 answered,
4716 expected,
4717 round_quota_missing,
4718 policy,
4719 );
4720
4721 let mut round_record = ReviewRound {
4722 round,
4723 head: head.clone(),
4724 verified_head: None,
4725 verified_at: None,
4726 reviews: records,
4727 e2e,
4728 verify_retried,
4729 e2e_deferred,
4730 e2e_defer_reason,
4731 fix: None,
4732 blocking,
4733 answered,
4734 expected,
4735 clean,
4736 progressed: false,
4737 vote_split,
4738 reconsideration,
4739 verdict: None,
4740 };
4741 round_record.verdict = ReviewVote::worst(
4745 round_record
4746 .final_votes()
4747 .into_iter()
4748 .map(|(_, _, vote)| vote),
4749 );
4750 if !matches!(
4761 round_record.e2e_status(),
4762 E2eStatus::Deferred | E2eStatus::NotConfigured
4763 ) {
4764 round_record.verified_head = Some(head.clone());
4765 round_record.verified_at = Some(Timestamp::now());
4766 }
4767 let this_round_verification = round_record.verification_summary(&head);
4768
4769 if incomplete {
4770 let missing: Vec<String> = round_record
4771 .reviews
4772 .iter()
4773 .filter(|r| r.failed.is_some())
4774 .map(|r| format!("review-{}", r.reviewer))
4775 .collect();
4776 self.state.event(
4777 "review",
4778 format!(
4779 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
4780 missing.join(", ")
4781 ),
4782 );
4783 }
4784
4785 if clean {
4786 self.state.event(
4787 "review",
4788 if incomplete && policy == IncompleteReviewPolicy::Warn {
4789 format!(
4790 "round {round}: clean (warn policy, incomplete panel) — no \
4791 blocking findings from the seats that answered, verification green"
4792 )
4793 } else if incomplete {
4794 format!(
4795 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
4796 quorum) — no blocking findings from the seats that answered, \
4797 verification green",
4798 expected - answered
4799 )
4800 } else {
4801 format!("round {round}: clean — no blocking findings, verification green")
4802 },
4803 );
4804 self.state.reviews.push(round_record);
4805 self.state.status = RunStatus::Gating;
4806 self.state.save()?;
4807 return Ok(());
4808 }
4809
4810 if incomplete && blocking == 0 && e2e_ok {
4818 self.state.reviews.push(round_record);
4819 self.state.save()?;
4820 if round == max_rounds {
4821 self.state.status = RunStatus::Blocked;
4822 self.state.event(
4823 "review",
4824 format!(
4825 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
4826 refusing to call it clean",
4827 expected - answered
4828 ),
4829 );
4830 return Ok(());
4831 }
4832 continue;
4833 }
4834
4835 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
4847 self.state.reviews.push(round_record);
4848 return self
4849 .stop_reviewing(
4850 "the round's own verification could not run",
4851 &shell,
4852 &winner.worktree,
4853 )
4854 .await;
4855 }
4856
4857 if round == max_rounds {
4858 self.state.reviews.push(round_record);
4859 return self
4860 .stop_reviewing(
4861 &format!(
4862 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
4863 ),
4864 &shell,
4865 &winner.worktree,
4866 )
4867 .await;
4868 }
4869
4870 let (fix_spec, fix_seat_key) = self.fixer_spec(&winner);
4873 let seat = self.seat(&fix_seat_key, &fix_spec.id);
4874 let blocking_findings: Vec<_> = all_findings
4875 .iter()
4876 .filter(|f| f.severity.blocks())
4877 .cloned()
4878 .collect();
4879 let job = SeatJob {
4880 prompt: prompt::fix(
4881 &self.state.instruction,
4882 &blocking_findings,
4883 this_round_verification.as_ref(),
4884 round,
4885 max_rounds,
4886 &language,
4887 ),
4888 spec: fix_spec.clone(),
4889 seat,
4890 cwd: winner.worktree.clone(),
4891 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
4892 allow_write: true,
4893 sessions,
4894 artifacts: artifacts.clone(),
4895 stem: format!("fix-{round}"),
4896 };
4897 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4898 let cache = self.state.config.cache_dir();
4899 let ctx = WaveCtx {
4900 run: &run_id,
4901 node: "fix",
4902 prompts: &prompts,
4903 cache: cache.as_deref(),
4904 round: Some(round),
4905 };
4906 let (seat, out) =
4907 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4908 let agent_id = seat.agent.clone();
4909
4910 let mut fix = FixRecord {
4911 agent: agent_id,
4912 addressed: Vec::new(),
4913 rejected: Vec::new(),
4914 notes: String::new(),
4915 committed: false,
4916 failed: None,
4917 duration_ms: 0,
4918 continuation: None,
4919 };
4920 let mut continuation = ContinuationRecord::not_needed();
4921 let mut final_seat = seat.clone();
4922 match out {
4923 AgentOutcome::Ok(o) => {
4924 fix.duration_ms = o.duration_ms;
4925 let parsed = verdict::extract_json::<FixReport>(&o.text);
4926 let incomplete_reason = match &parsed {
4933 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4934 "the reply parsed, but it reported a command whose own CLI never \
4935 confirmed an exit status"
4936 .to_owned(),
4937 ),
4938 Ok(_) => None,
4939 Err(e) => Some(e.to_string()),
4940 };
4941 match incomplete_reason {
4942 None => {
4943 let report = parsed.expect("checked Ok above");
4944 fix.addressed = report.addressed;
4945 fix.rejected = report.rejected;
4946 fix.notes =
4947 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4948 }
4949 Some(reason) => {
4950 let (resumed_seat, resolved, failure, cont) = self
4951 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
4952 .await;
4953 fix.duration_ms += cont.cumulative_wait_ms;
4954 continuation = cont;
4955 final_seat = resumed_seat;
4956 match resolved {
4957 Some(report) => {
4958 fix.addressed = report.addressed;
4959 fix.rejected = report.rejected;
4960 fix.notes = blind::sanitize_prose(
4961 &report.notes,
4962 &self.state.config.blind,
4963 );
4964 }
4965 None => fix.failed = failure,
4966 }
4967 }
4968 }
4969 }
4970 AgentOutcome::Dropped(o) => {
4972 fix.duration_ms = o.duration_ms;
4973 let why = o
4974 .dropped
4975 .as_ref()
4976 .map(|d| d.why.as_str())
4977 .unwrap_or("the CLI ended the stream without delivering its answer");
4978 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4979 }
4980 AgentOutcome::Quota(o) => {
4981 self.state.quota.push(QuotaLoss {
4982 seat: final_seat.key.clone(),
4983 node: "fix".to_owned(),
4984 at: Timestamp::now(),
4985 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4986 });
4987 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4988 }
4989 AgentOutcome::Failed(e) => fix.failed = Some(e),
4990 }
4991 fix.continuation = Some(continuation);
4992 self.state.seats.insert(final_seat.key.clone(), final_seat);
4993 if let Ok(r) = git::rescue_commit(
4994 &winner.worktree,
4995 &format!("magi: review round {round} fixes (uncommitted work)"),
4996 )
4997 .await
4998 {
4999 self.state.note_withheld("fix", &r.withheld);
5000 }
5001 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5002 fix.committed = after != before;
5003 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5011 let progressed = diff_after != patch;
5012 let commit_note = if fix.committed {
5013 "committed"
5014 } else {
5015 "NO new commit"
5016 };
5017 let tree_note = if progressed {
5018 "changed vs base"
5019 } else {
5020 "unchanged vs base"
5021 };
5022 self.state.event(
5023 "fix",
5024 match &fix.failed {
5025 Some(reason) => {
5031 format!(
5032 "round {round}: fixer's adoption report was lost ({reason}); \
5033 {commit_note}, tree {tree_note}"
5034 )
5035 }
5036 None => format!(
5037 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5038 {tree_note}{}",
5039 fix.addressed.len(),
5040 fix.rejected.len(),
5041 if continuation.outcome == ContinuationOutcome::Resumed {
5042 format!(
5043 " (adoption report recovered after {} continuation(s))",
5044 continuation.attempts
5045 )
5046 } else {
5047 String::new()
5048 },
5049 ),
5050 },
5051 );
5052 round_record.fix = Some(fix);
5053 round_record.progressed = progressed;
5054 self.state.reviews.push(round_record);
5055 self.state.save()?;
5056
5057 if matches!(
5070 continuation.outcome,
5071 ContinuationOutcome::Exhausted
5072 | ContinuationOutcome::QuotaLost
5073 | ContinuationOutcome::NoSession
5074 ) {
5075 return self
5076 .stop_reviewing(
5077 "the fixer's adoption report never came back, even after resuming its \
5078 own seat; refusing to start another round against the same worktree \
5079 while that is unresolved",
5080 &shell,
5081 &winner.worktree,
5082 )
5083 .await;
5084 }
5085
5086 let streak = self
5087 .state
5088 .reviews
5089 .iter()
5090 .rev()
5091 .take_while(|r| !r.progressed)
5092 .count();
5093 if streak >= STAGNANT_LIMIT {
5094 return self
5095 .stop_reviewing(
5096 &format!(
5097 "the tree has not moved against base for {streak} round(s) in a row"
5098 ),
5099 &shell,
5100 &winner.worktree,
5101 )
5102 .await;
5103 }
5104 }
5105 Ok(())
5106 }
5107
5108 fn record_contested_handoff(&mut self) {
5142 if self.state.contested_handoff.is_some() {
5143 return;
5144 }
5145 let Some(contested) = self
5146 .state
5147 .reviews
5148 .last()
5149 .and_then(ReviewRound::contested_handoff)
5150 else {
5151 return;
5152 };
5153 self.state.event(
5154 "review",
5155 format!(
5156 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5157 wait for the owner's approval",
5158 contested.findings.len(),
5159 contested.rejecters.len()
5160 ),
5161 );
5162 self.state.contested_handoff = Some(contested);
5163 }
5164
5165 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5166 let round_idx = self.state.reviews.len() - 1;
5167 let needs_catchup_run = matches!(
5175 self.state.reviews[round_idx].e2e_status(),
5176 E2eStatus::Deferred | E2eStatus::ResourceBlocked
5177 );
5178 if needs_catchup_run {
5179 let round = self.state.reviews[round_idx].round;
5180 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5181 let commands = self.state.config.verify.e2e.clone();
5182 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5183 let cache_dir = self.state.config.cache_dir();
5184 let context = format!(
5185 "round {round}: verification unresolved, catching up before the final decision"
5186 );
5187 let (outcomes, verify_retried) = with_cache_lease(
5188 &mut self.state,
5189 cache_dir.as_deref(),
5190 "e2e",
5191 "e2e",
5192 worktree,
5193 &attempted_head,
5194 timeout,
5195 &context,
5196 |state, budget| {
5197 let shell = shell.to_vec();
5198 let commands = commands.clone();
5199 let context = context.clone();
5200 async move {
5201 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5202 .await
5203 }
5204 },
5205 )
5206 .await;
5207 let last = &mut self.state.reviews[round_idx];
5208 last.e2e = outcomes;
5209 last.verify_retried = verify_retried;
5210 last.verified_head = Some(attempted_head);
5217 last.verified_at = Some(Timestamp::now());
5218 if verify_inconclusive(&last.e2e) {
5219 self.state.save()?;
5226 return Ok(());
5227 }
5228 last.e2e_deferred = false;
5229 }
5230 let last = &self.state.reviews[round_idx];
5231 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5232
5233 match last.e2e_status() {
5234 E2eStatus::Failed => {
5235 let red: Vec<String> = last
5236 .e2e
5237 .iter()
5238 .filter(|o| !o.ok())
5239 .map(|o| {
5240 format!(
5241 "`{}` -> {:?}\n{}",
5242 o.command,
5243 o.code,
5244 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5245 )
5246 })
5247 .collect();
5248 self.state
5249 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5250 self.state.status = RunStatus::Blocked;
5251 }
5252 E2eStatus::ResourceBlocked => {
5257 self.state.event(
5258 "review",
5259 format!(
5260 "{why}; e2e could not run (shared build cache unavailable); not \
5261 deciding yet"
5262 ),
5263 );
5264 }
5265 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5266 self.state.event(
5267 "review",
5268 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5269 );
5270 self.record_contested_handoff();
5271 self.state.status = RunStatus::Gating;
5272 }
5273 }
5274 self.state.save()?;
5275 Ok(())
5276 }
5277
5278 async fn gate(&mut self) -> Result<()> {
5281 if self.state.status == RunStatus::Failed
5293 || self
5294 .state
5295 .base_sync
5296 .as_ref()
5297 .is_some_and(|s| s.conflict.is_some())
5298 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5299 != Some(RunStatus::Gating)
5300 {
5301 return Ok(());
5302 }
5303 if self.state.gate_ran {
5304 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5315 self.state.status = RunStatus::Blocked;
5316 self.state.save()?;
5317 }
5318 return Ok(());
5319 }
5320 let Some(winner) = self.state.winner().cloned() else {
5321 return Ok(());
5322 };
5323 self.state.status = RunStatus::Gating;
5324 let mut outcomes = self.run_gate(&winner).await?;
5325 loop {
5326 if verify_inconclusive(&outcomes) {
5337 self.state.save()?;
5338 return Ok(());
5339 }
5340 if outcomes.iter().all(CommandOutcome::ok) {
5341 break;
5342 }
5343 match self.gate_fix_round(&winner, &outcomes).await? {
5344 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5345 GateFix::Stop => break,
5346 GateFix::Defer => {
5347 self.state.save()?;
5348 return Ok(());
5349 }
5350 }
5351 }
5352 let passed = outcomes.iter().all(CommandOutcome::ok);
5353 self.state.gate = outcomes;
5354 self.state.gate_ran = true;
5355 if !passed {
5356 self.state.status = RunStatus::Blocked;
5357 let spent = self.state.gate_fixes.len();
5358 self.state.event(
5359 "gate",
5360 if spent == 0 {
5361 "gate failed; not merging".to_owned()
5362 } else {
5363 format!("gate failed after {spent} gate-fix round(s); not merging")
5364 },
5365 );
5366 }
5367 self.state.save()?;
5368 Ok(())
5369 }
5370
5371 async fn run_pre_gate(&mut self, winner: &Candidate) {
5381 let commands = self.state.config.verify.pre_gate.clone();
5382 if commands.is_empty() {
5383 return;
5384 }
5385 let shell = self.state.config.shell();
5386 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5387 let (outcomes, _) = run_commands(
5388 &mut self.state,
5389 "pre_gate",
5390 "pre_gate",
5391 0,
5392 &shell,
5393 &commands,
5394 &winner.worktree,
5395 timeout,
5396 )
5397 .await;
5398 for o in &outcomes {
5399 if !o.ok() {
5400 tracing::warn!(
5401 "pre_gate `{}` failed ({:?}); the gate decides",
5402 o.command,
5403 o.code
5404 );
5405 }
5406 self.state.event(
5407 "pre_gate",
5408 format!(
5409 "`{}` -> {}",
5410 o.command,
5411 if o.ok() {
5412 "pass".to_owned()
5413 } else {
5414 format!(
5415 "FAIL ({:?})\n{}",
5416 o.code,
5417 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5418 )
5419 }
5420 ),
5421 );
5422 }
5423 self.state.pre_gate = outcomes;
5424 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5425 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5426 Ok(head) => {
5427 self.state
5428 .event("pre_gate", format!("committed mechanical fixes ({head})"));
5429 self.state.pre_gate_commit = Some(head);
5430 }
5431 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5432 },
5433 Ok(false) => {}
5434 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5435 }
5436 if let Err(e) = self.state.save() {
5437 tracing::warn!("could not persist the pre_gate record: {e:#}");
5438 }
5439 }
5440
5441 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5444 self.run_pre_gate(winner).await;
5445 let shell = self.state.config.shell();
5446 let gate_commands = self.state.config.verify.gate.clone();
5447 let outcomes = if gate_commands.is_empty() {
5456 Vec::new()
5457 } else {
5458 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5459 let cache_dir = self.state.config.cache_dir();
5460 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5461 let (outcomes, _) = with_cache_lease(
5462 &mut self.state,
5463 cache_dir.as_deref(),
5464 "gate",
5465 "gate",
5466 &winner.worktree,
5467 &head,
5468 timeout,
5469 "final gate",
5470 |state, budget| {
5471 let shell = shell.clone();
5472 let gate_commands = gate_commands.clone();
5473 let worktree = winner.worktree.clone();
5474 async move {
5475 let (outcomes, timed_out_pids) = run_commands(
5476 state,
5477 "gate",
5478 "gate",
5479 0,
5480 &shell,
5481 &gate_commands,
5482 &worktree,
5483 budget,
5484 )
5485 .await;
5486 (outcomes, false, timed_out_pids)
5487 }
5488 },
5489 )
5490 .await;
5491 outcomes
5492 };
5493 if outcomes.is_empty() {
5494 self.state.event(
5499 "gate",
5500 "no gate commands configured; nothing to check, passing",
5501 );
5502 }
5503 for o in &outcomes {
5504 self.state.event(
5505 "gate",
5506 format!(
5507 "`{}` -> {}",
5508 o.command,
5509 if o.ok() {
5510 "pass".to_owned()
5511 } else {
5512 format!(
5513 "FAIL ({:?})\n{}",
5514 o.code,
5515 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5516 )
5517 }
5518 ),
5519 );
5520 }
5521 Ok(outcomes)
5522 }
5523
5524 async fn gate_fix_round(
5537 &mut self,
5538 winner: &Candidate,
5539 outcomes: &[CommandOutcome],
5540 ) -> Result<GateFix> {
5541 let cap = self.state.config.graph.gate_fix_rounds;
5542 let spent = self.state.gate_fixes.len();
5543 if spent >= cap {
5544 if cap > 0 {
5545 self.state.event(
5546 "gate",
5547 format!("{spent} gate-fix round(s) spent and the gate still fails"),
5548 );
5549 }
5550 return Ok(GateFix::Stop);
5551 }
5552 if !gate_fixable(outcomes) {
5553 self.state.event(
5554 "gate",
5555 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5556 command or similar); not spending a fix round on it",
5557 );
5558 return Ok(GateFix::Stop);
5559 }
5560 let min_free = self.state.config.disk.min_free_bytes;
5561 if min_free > 0 {
5562 match crate::disk::free_bytes(&winner.worktree) {
5563 Ok(free) if crate::disk::enough_space(free, min_free) => {}
5564 Ok(free) => {
5565 self.state.event(
5566 "gate",
5567 format!(
5568 "only {free} bytes free ({min_free} required by `[disk] \
5569 min_free_bytes`); not spending a fix round on a failure the disk \
5570 may explain"
5571 ),
5572 );
5573 return Ok(GateFix::Stop);
5574 }
5575 Err(e) => {
5576 self.state.event(
5577 "gate",
5578 format!("free disk space could not be measured ({e:#}); no fix round"),
5579 );
5580 return Ok(GateFix::Stop);
5581 }
5582 }
5583 }
5584
5585 let attempt = spent + 1;
5586 let run_id = self.state.id.clone();
5587 let prompts = self.state.config.prompts.clone();
5588 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5589 let base = self.landing_base();
5590 let (fix_spec, fix_seat_key) = self.fixer_spec(winner);
5591 let seat = self.seat(&fix_seat_key, &fix_spec.id);
5592 let job = SeatJob {
5593 prompt: prompt::gate_fix(
5594 &self.state.instruction,
5595 &failed,
5596 attempt,
5597 cap,
5598 &self.state.config.graph.language,
5599 ),
5600 spec: fix_spec,
5601 seat,
5602 cwd: winner.worktree.clone(),
5603 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5604 allow_write: true,
5605 sessions: self.state.config.graph.sessions,
5606 artifacts: agent::artifacts_dir(&self.state.dir()),
5607 stem: format!("gate-fix-{attempt}"),
5608 };
5609 self.state.event(
5610 "gate",
5611 format!("gate failed; gate-fix round {attempt} of {cap}"),
5612 );
5613 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5614 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5615 let cache = self.state.config.cache_dir();
5616 let ctx = WaveCtx {
5617 run: &run_id,
5618 node: "gate-fix",
5619 prompts: &prompts,
5620 cache: cache.as_deref(),
5621 round: None,
5622 };
5623 let (seat, out) = run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5624 let mut record = GateFixRecord {
5625 agent: seat.agent.clone(),
5626 failed,
5627 notes: String::new(),
5628 committed: false,
5629 error: None,
5630 };
5631 match out {
5632 AgentOutcome::Ok(o) => {
5633 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5636 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5637 }
5638 }
5639 AgentOutcome::Dropped(_) => {
5640 record.error = Some("the CLI dropped the stream".to_owned());
5641 }
5642 AgentOutcome::Quota(o) => {
5643 self.state.quota.push(QuotaLoss {
5644 seat: seat.key.clone(),
5645 node: "gate-fix".to_owned(),
5646 at: Timestamp::now(),
5647 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5648 });
5649 record.error = Some("rate limited (quota); fixer could not run".to_owned());
5650 }
5651 AgentOutcome::Failed(e) => record.error = Some(e),
5652 }
5653 self.state.seats.insert(seat.key.clone(), seat);
5654 if let Ok(r) = git::rescue_commit(
5655 &winner.worktree,
5656 &format!("magi: gate fix {attempt} (uncommitted work)"),
5657 )
5658 .await
5659 {
5660 self.state.note_withheld("gate-fix", &r.withheld);
5661 }
5662 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5663 record.committed = after != before;
5664 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5665 let note = record.error.clone();
5666 self.state.gate_fixes.push(record);
5667 self.state.save()?;
5668 if !changed {
5669 self.state.event(
5670 "gate",
5671 match note {
5672 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5673 None => format!("gate-fix round {attempt}: the tree did not change"),
5674 },
5675 );
5676 return Ok(GateFix::Stop);
5677 }
5678 self.state.event(
5679 "gate",
5680 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5681 );
5682
5683 let commands = self.state.config.verify.e2e.clone();
5684 if !commands.is_empty() {
5685 let shell = self.state.config.shell();
5686 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5687 let cache_dir = self.state.config.cache_dir();
5688 let context = format!("gate-fix round {attempt}");
5689 let (e2e, _) = with_cache_lease(
5690 &mut self.state,
5691 cache_dir.as_deref(),
5692 "e2e",
5693 "e2e",
5694 &winner.worktree,
5695 &after,
5696 timeout,
5697 &context,
5698 |state, budget| {
5699 let shell = shell.clone();
5700 let commands = commands.clone();
5701 let context = context.clone();
5702 let worktree = winner.worktree.clone();
5703 async move {
5704 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5705 .await
5706 }
5707 },
5708 )
5709 .await;
5710 if verify_inconclusive(&e2e) {
5711 return Ok(GateFix::Defer);
5712 }
5713 if e2e.iter().any(|o| !o.ok()) {
5714 self.state.event(
5715 "gate",
5716 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
5717 );
5718 return Ok(GateFix::Stop);
5719 }
5720 }
5721 Ok(GateFix::Retry)
5722 }
5723
5724 async fn merge(&mut self) -> Result<()> {
5727 if self
5742 .state
5743 .base_sync
5744 .as_ref()
5745 .is_some_and(|s| s.conflict.is_some())
5746 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5747 != Some(RunStatus::Gating)
5748 || !self.state.gate_status().ok()
5757 {
5758 return Ok(());
5759 }
5760 if self.state.merge.is_some() {
5769 return Ok(());
5770 }
5771 let Some(winner) = self.state.winner().cloned() else {
5772 return Ok(());
5773 };
5774 let repo = self.state.repo.clone();
5775 let base = self.state.base_branch.clone();
5776 let mode = self.state.config.merge.mode;
5777 let style = self.state.config.merge.style;
5778 let facts = if is_review_run(&self.state) {
5779 branch_facts(&repo, &self.state.base_commit, &winner.branch).await
5780 } else {
5781 None
5782 };
5783 let pr = pr_message_with(&self.state, winner.label, facts.as_ref());
5784 let message = pr.commit_message();
5785
5786 let outcome = match mode {
5787 MergeMode::None => MergeOutcome {
5788 mode,
5789 ok: true,
5790 detail: manual_merge_command(style, &repo, &winner.branch, &message),
5791 empty: false,
5792 },
5793 MergeMode::Pr | MergeMode::Local
5794 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
5795 {
5796 MergeOutcome {
5797 mode,
5798 ok: false,
5799 detail: empty_candidate_detail(&self.state, &base),
5800 empty: true,
5801 }
5802 }
5803 MergeMode::Local => {
5804 let on = git::current_branch(&repo).await?;
5805 if on.as_deref() != Some(base.as_str()) {
5806 MergeOutcome {
5807 mode,
5808 ok: false,
5809 detail: format!(
5810 "{} has {} checked out, not the base branch {base}",
5811 repo.display(),
5812 on.unwrap_or_else(|| "a detached HEAD".to_owned())
5813 ),
5814 empty: false,
5815 }
5816 } else if !git::is_clean(&repo).await? {
5817 MergeOutcome {
5818 mode,
5819 ok: false,
5820 detail: format!("{} is dirty; refusing to merge", repo.display()),
5821 empty: false,
5822 }
5823 } else {
5824 let out = match style {
5825 MergeStyle::Merge => {
5826 git::merge_no_ff(&repo, &winner.branch, &message).await?
5827 }
5828 MergeStyle::Squash => {
5829 git::merge_squash(&repo, &winner.branch, &message).await?
5830 }
5831 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
5832 };
5833 MergeOutcome {
5834 mode,
5835 ok: out.ok(),
5836 detail: if out.ok() { out.stdout } else { out.stderr },
5837 empty: false,
5838 }
5839 }
5840 }
5841 MergeMode::Pr => {
5842 let remote = self.state.config.merge.remote.clone();
5843 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
5844 if !pushed.ok() {
5845 MergeOutcome {
5846 mode,
5847 ok: false,
5848 detail: pushed.stderr,
5849 empty: false,
5850 }
5851 } else {
5852 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
5858 let out = match pr_merge_plan(found) {
5859 PrPlan::Create => {
5860 gh_pr_create(
5861 &winner.worktree,
5862 &base,
5863 &winner.branch,
5864 &pr.title,
5865 &pr.body,
5866 )
5867 .await
5868 }
5869 PrPlan::Adopt { url, title } => {
5870 self.state
5871 .event("merge", format!("Pr: adopted open pull request {url}"));
5872 if title != pr.title
5873 && let Err(e) =
5874 land::set_pr_title(&winner.worktree, &url, &pr.title).await
5875 {
5876 tracing::warn!("could not refresh title of {url}: {e:#}");
5877 self.state
5878 .event("merge", format!("Pr: title refresh failed: {e:#}"));
5879 }
5880 Ok(url)
5881 }
5882 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
5883 };
5884 match out {
5885 Ok(url) => MergeOutcome {
5886 mode,
5887 ok: true,
5888 detail: url,
5889 empty: false,
5890 },
5891 Err(e) => MergeOutcome {
5892 mode,
5893 ok: false,
5894 detail: e.to_string(),
5895 empty: false,
5896 },
5897 }
5898 }
5899 }
5900 };
5901
5902 self.state.status = match (mode, outcome.ok) {
5903 (MergeMode::None, _) => RunStatus::Ready,
5904 (_, true) => RunStatus::Merged,
5905 (_, false) => RunStatus::Blocked,
5906 };
5907 self.state.event(
5908 "merge",
5909 format!(
5910 "{:?}: {}",
5911 mode,
5912 outcome.detail.lines().next().unwrap_or("")
5913 ),
5914 );
5915 self.state.merge = Some(outcome);
5916 self.state.save()?;
5917
5918 if self.state.config.graph.land
5924 && mode == MergeMode::Pr
5925 && self.state.status == RunStatus::Merged
5926 {
5927 self.run_land().await?;
5928 }
5929 self.settle_questions();
5934 Ok(())
5935 }
5936
5937 async fn run_land(&mut self) -> Result<()> {
5948 let url = self
5949 .state
5950 .merge
5951 .as_ref()
5952 .map(|m| m.detail.clone())
5953 .unwrap_or_default();
5954 let url = url.lines().next().unwrap_or("").trim().to_owned();
5955 if !url.starts_with("http") {
5956 return Ok(());
5957 }
5958 match land::land(&mut self.state, &url).await {
5961 Ok(pr) if self.state.parked => {
5962 let _ = pr;
5966 }
5967 Ok(pr) => {
5968 self.state.status = match pr.state {
5969 land::PrLifecycle::Merged => RunStatus::Merged,
5970 _ => RunStatus::Blocked,
5971 };
5972 if bump::should_release_bump(self.state.status)
5979 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
5980 {
5981 self.state
5987 .event("bump", format!("release bump skipped: {e:#}"));
5988 }
5989 if self.state.status == RunStatus::Merged {
5992 crate::followup::after_merge(&mut self.state, &pr.url).await;
5993 }
5994 self.state.save()?;
5995 }
5996 Err(e) => {
5997 self.state.status = RunStatus::Blocked;
5998 self.state.event("land", format!("gave up: {e}"));
5999 self.state.save()?;
6000 }
6001 }
6002 Ok(())
6003 }
6004
6005 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6009 if let Some(existing) = self.state.seats.get(key)
6010 && existing.agent == agent
6011 {
6012 return existing.clone();
6013 }
6014 let fresh = SeatState::new(key, agent, self.state.seed);
6015 self.state.seats.insert(key.to_owned(), fresh.clone());
6016 fresh
6017 }
6018
6019 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6025 match self.state.seats.get(key) {
6026 Some(s) if s.agent != spec.id => {
6027 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6028 }
6029 _ => spec,
6030 }
6031 }
6032
6033 fn view(&self, c: &Candidate) -> CandidateView {
6035 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6036 .unwrap_or_default();
6037 let (patch, _) = blind::sanitize_patch(
6038 &format!("candidate {} patch", c.label),
6039 &raw,
6040 &self.state.config.blind,
6041 );
6042 CandidateView {
6043 label: c.label,
6044 branch: c.branch.clone(),
6045 summary: c.summary.clone(),
6046 stat: c.stat.clone(),
6047 patch,
6048 }
6049 }
6050
6051 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6053 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6054 prompt::judge(
6055 "(see above)",
6056 &views,
6057 self.roles.judges.len(),
6058 base_short,
6059 "en",
6060 )
6061 }
6062
6063 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6070 let mut turns = Vec::new();
6071 for j in &self.state.judgements {
6072 if j.ranking.is_empty() {
6073 continue;
6074 }
6075 let reasons = j
6076 .reasons
6077 .iter()
6078 .map(|(k, v)| format!("- {k}: {v}"))
6079 .collect::<Vec<_>>()
6080 .join("\n");
6081 turns.push(Turn {
6082 who: format!("Judge {} (opening ranking)", j.judge),
6083 is_self: j.judge == self_idx + 1,
6084 body: format!(
6085 "Ranked {}{}{reasons}",
6086 j.ranking.iter().collect::<String>(),
6087 if reasons.is_empty() {
6088 ""
6089 } else {
6090 ", because:\n"
6091 }
6092 ),
6093 });
6094 }
6095 for t in self
6096 .state
6097 .deliberation
6098 .iter()
6099 .flat_map(|r| r.turns.iter())
6100 .chain(current)
6101 {
6102 turns.push(Turn {
6103 who: format!("Judge {}", t.judge),
6104 is_self: t.judge == self_idx + 1,
6105 body: t.body.clone(),
6106 });
6107 }
6108 turns
6109 }
6110}
6111
6112fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6114 agent::has_session(spec.kind, seat, sessions)
6115}
6116
6117fn next_untried_in_roster<'a>(
6138 roster: &'a [AgentSpec],
6139 start: usize,
6140 tried: &BTreeSet<String>,
6141) -> Option<&'a AgentSpec> {
6142 roster
6143 .get(start + 1..)?
6144 .iter()
6145 .find(|s| !tried.contains(&s.id))
6146}
6147
6148fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6152 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6153}
6154
6155const TIMED_OUT: &str = "timed out";
6158
6159#[derive(Debug, Clone, PartialEq, Eq)]
6162enum FailClass {
6163 Quota,
6164 Timeout,
6165 Other(String),
6167}
6168
6169impl FailClass {
6170 fn of(out: &AgentOutcome) -> Option<Self> {
6172 match out {
6173 AgentOutcome::Ok(_) => None,
6174 AgentOutcome::Quota(_) => Some(Self::Quota),
6175 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6176 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6177 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6178 }
6179 }
6180
6181 fn stem_word(&self) -> &'static str {
6183 match self {
6184 Self::Quota => "quota",
6185 _ => "handover",
6186 }
6187 }
6188}
6189
6190fn failure_signature(msg: &str) -> String {
6194 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
6195 let mut out = Vec::new();
6196 for word in line.split_whitespace() {
6197 if word.contains('/') || word.contains('\\') {
6198 out.push("<path>".to_owned());
6199 continue;
6200 }
6201 let mut w = String::new();
6202 let mut in_digits = false;
6203 for c in word.chars() {
6204 if c.is_ascii_digit() {
6205 if !in_digits {
6206 w.push('#');
6207 }
6208 in_digits = true;
6209 } else {
6210 in_digits = false;
6211 w.push(c);
6212 }
6213 }
6214 out.push(w);
6215 }
6216 out.join(" ").chars().take(120).collect()
6217}
6218
6219fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
6226 match cur {
6227 FailClass::Quota | FailClass::Timeout => true,
6228 FailClass::Other(_) => prev != Some(cur),
6229 }
6230}
6231
6232fn fail_reason(out: &AgentOutcome) -> String {
6234 match out {
6235 AgentOutcome::Ok(_) => String::new(),
6236 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
6237 AgentOutcome::Dropped(o) => format!(
6238 "the CLI dropped the stream ({})",
6239 o.dropped
6240 .as_ref()
6241 .map(|d| d.why.as_str())
6242 .unwrap_or("it ended without delivering its answer")
6243 ),
6244 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
6245 }
6246}
6247
6248fn record_handover(
6251 state: &mut RunState,
6252 node: &str,
6253 seat: &str,
6254 from: &str,
6255 to: &str,
6256 class: &FailClass,
6257 reason: &str,
6258) {
6259 let message = if *class == FailClass::Quota {
6260 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
6261 } else {
6262 format!("{seat}: handed over {from} -> {to} ({reason})")
6263 };
6264 state.event(node, message);
6265 state.handovers.push(Handover {
6266 at: Timestamp::now(),
6267 node: node.to_owned(),
6268 seat: seat.to_owned(),
6269 from: from.to_owned(),
6270 to: to.to_owned(),
6271 reason: reason.to_owned(),
6272 });
6273}
6274
6275fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
6289 commands.iter().any(|c| c.exit_code.is_none())
6290}
6291
6292fn verified_noop_claim(
6305 usable: bool,
6306 commands: &[agent::CommandEvidence],
6307 text: &str,
6308) -> Option<String> {
6309 (usable && !has_unconfirmed_command(commands))
6310 .then(|| verdict::verified_noop(text))
6311 .flatten()
6312}
6313
6314fn short(commit: &str) -> String {
6315 commit.chars().take(7).collect()
6316}
6317
6318fn make_executable(path: &Path) -> Result<()> {
6319 #[cfg(unix)]
6320 {
6321 use std::os::unix::fs::PermissionsExt as _;
6322 let mut perms = std::fs::metadata(path)?.permissions();
6323 perms.set_mode(0o755);
6324 std::fs::set_permissions(path, perms)?;
6325 }
6326 #[cfg(not(unix))]
6327 {
6328 let _ = path;
6329 }
6330 Ok(())
6331}
6332
6333struct WaveCtx<'a> {
6340 run: &'a str,
6343 node: &'a str,
6345 prompts: &'a Prompts,
6346 cache: Option<&'a Path>,
6348 round: Option<usize>,
6351}
6352
6353async fn run_one(
6355 job: SeatJob,
6356 sem: Arc<Semaphore>,
6357 ctx: &WaveCtx<'_>,
6358 state: &mut RunState,
6359 attempt: usize,
6360) -> (SeatState, AgentOutcome) {
6361 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
6362 .await
6363 .pop()
6364 .expect("one job in, one result out");
6365 (seat, out)
6366}
6367
6368async fn wave(
6374 jobs: Vec<SeatJob>,
6375 sem: Arc<Semaphore>,
6376 ctx: &WaveCtx<'_>,
6377 state: &mut RunState,
6378 attempt: usize,
6379) -> Vec<(usize, SeatState, AgentOutcome)> {
6380 let WaveCtx {
6381 run,
6382 node,
6383 prompts,
6384 cache,
6385 round,
6386 } = *ctx;
6387 for job in &jobs {
6388 state.seat_started(node, &job.seat.key, job.timeout, attempt);
6389 }
6390 if let Err(e) = state.save() {
6391 tracing::warn!("could not persist in-progress seats: {e:#}");
6396 }
6397 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
6413 let wait_started = Instant::now();
6414 let cache_guard = if let Some(cache_dir) = cache {
6415 if jobs_had_a_writer {
6416 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
6417 let budget = jobs
6418 .iter()
6419 .map(|j| j.timeout)
6420 .max()
6421 .unwrap_or(Duration::from_secs(60));
6422 acquire_cache_lease(state, cache_dir, &owner, budget, node)
6423 .await
6424 .ok()
6425 } else {
6426 None
6427 }
6428 } else {
6429 None
6430 };
6431 let waited_for_lease = wait_started.elapsed();
6438 let mut set = tokio::task::JoinSet::new();
6439 let overlay = prompts.overlay(node);
6440 for (i, mut job) in jobs.into_iter().enumerate() {
6441 job.timeout = job.timeout.saturating_sub(waited_for_lease);
6442 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
6443 if cache.is_some() {
6444 job.prompt.push('\n');
6445 job.prompt
6446 .push_str(&prompt::build_cache_note(node, job.allow_write));
6447 }
6448 let sem = Arc::clone(&sem);
6449 let run = run.to_owned();
6450 let node = node.to_owned();
6451 let attachments = if node == "implement" {
6454 state.attachments.clone()
6455 } else {
6456 Vec::new()
6457 };
6458 let cache = cache
6469 .filter(|_| job.allow_write && cache_guard.is_some())
6470 .map(Path::to_path_buf);
6471 set.spawn(async move {
6472 let _permit = sem.acquire().await;
6473 let mut seat = job.seat;
6474 let out = agent::invoke(
6475 &job.spec,
6476 &mut seat,
6477 &Invocation {
6478 cwd: &job.cwd,
6479 prompt: &job.prompt,
6480 timeout: job.timeout,
6481 allow_write: job.allow_write,
6482 sessions: job.sessions,
6483 artifacts: &job.artifacts,
6484 stem: &job.stem,
6485 run: &run,
6486 node: &node,
6487 cache_dir: cache.as_deref(),
6488 attachments: &attachments,
6489 writable: &[],
6490 },
6491 )
6492 .await;
6493 let out = match out {
6494 Ok(o) if o.usable() => AgentOutcome::Ok(o),
6495 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
6496 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
6504 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
6505 Ok(o) => AgentOutcome::Failed(format!(
6506 "exited with {:?} and no usable output",
6507 o.exit_code
6508 )),
6509 Err(e) => AgentOutcome::Failed(e.to_string()),
6510 };
6511 (i, seat, out)
6512 });
6513 }
6514 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
6515 while let Some(joined) = set.join_next().await {
6516 let (i, seat, out) = match joined {
6517 Ok(v) => v,
6518 Err(e) => {
6522 tracing::error!("agent task panicked: {e}");
6523 continue;
6524 }
6525 };
6526 state.seat_finished(&seat.key);
6527 record_jobs(state, node, round, &seat.key, &out);
6528 if let Err(e) = state.save() {
6529 tracing::warn!("could not persist a seat's completion: {e:#}");
6530 }
6531 if collected.len() <= i {
6532 collected.resize_with(i + 1, || None);
6533 }
6534 collected[i] = Some((i, seat, out));
6535 }
6536 if state
6542 .active
6543 .values()
6544 .any(|a| a.node == node && a.attempt == attempt)
6545 {
6546 state
6547 .active
6548 .retain(|_, a| !(a.node == node && a.attempt == attempt));
6549 if let Err(e) = state.save() {
6550 tracing::warn!("could not persist the end of a wave: {e:#}");
6551 }
6552 }
6553 if let Some(cache_dir) = cache
6560 && jobs_had_a_writer
6561 {
6562 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
6563 }
6564 if let Some(guard) = cache_guard {
6565 guard.release();
6566 }
6567 collected.into_iter().flatten().collect()
6568}
6569
6570fn record_jobs(
6581 state: &mut RunState,
6582 node: &str,
6583 round: Option<usize>,
6584 seat: &str,
6585 out: &AgentOutcome,
6586) {
6587 let commands: &[agent::CommandEvidence] = match out {
6588 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
6589 AgentOutcome::Failed(_) => &[],
6590 };
6591 let checked_at = Timestamp::now();
6592 for c in commands {
6593 state.jobs.push(JobRecord {
6594 node: node.to_owned(),
6595 round,
6596 seat: seat.to_owned(),
6597 id: c.id.clone(),
6598 description: c.description.clone(),
6599 checked_at,
6600 status: match c.exit_code {
6601 Some(0) => JobStatus::Completed,
6602 Some(_) => JobStatus::Failed,
6603 None => JobStatus::Unknown,
6604 },
6605 exit_code: c.exit_code,
6606 result_summary: c.result_summary.clone(),
6607 source: c.source.clone(),
6608 });
6609 }
6610}
6611
6612fn round_is_clean(
6633 blocking: usize,
6634 e2e_ok: bool,
6635 answered: usize,
6636 expected: usize,
6637 quota_missing: usize,
6638 policy: IncompleteReviewPolicy,
6639) -> bool {
6640 if blocking != 0 || !e2e_ok {
6641 return false;
6642 }
6643 if answered == expected || policy == IncompleteReviewPolicy::Warn {
6644 return true;
6645 }
6646 answered > 0 && expected - answered <= quota_missing
6647}
6648
6649fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
6673 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
6674 return Some(RunStatus::Gating);
6675 }
6676 let last = reviews.last()?;
6677 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
6678 if reviews.len() < max_rounds && !stagnant {
6679 return None;
6680 }
6681 if last.incomplete() && last.blocking == 0 {
6682 return Some(RunStatus::Blocked);
6683 }
6684 if last.e2e_status() == E2eStatus::ResourceBlocked {
6685 return None;
6686 }
6687 Some(if last.e2e.iter().all(CommandOutcome::ok) {
6688 RunStatus::Gating
6689 } else {
6690 RunStatus::Blocked
6691 })
6692}
6693
6694fn retry_budget(full: Duration, nudged: bool) -> Duration {
6709 if nudged {
6710 (full / 4).max(Duration::from_secs(120)).min(full)
6711 } else {
6712 full
6713 }
6714}
6715
6716#[allow(clippy::too_many_arguments)]
6745async fn ask_json_wave<T>(
6746 jobs: Vec<SeatJob>,
6747 sem: Arc<Semaphore>,
6748 retries: usize,
6749 roster: &[AgentSpec],
6750 ctx: &WaveCtx<'_>,
6751 losses: &mut Vec<QuotaLoss>,
6752 state: &mut RunState,
6753 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
6754) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
6755where
6756 T: serde::de::DeserializeOwned + Send + 'static,
6757{
6758 let n = jobs.len();
6759 let originals: Vec<SeatJob> = jobs;
6760 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
6761 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
6762 let mut nudges: Vec<usize> = vec![0; n];
6769 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
6773 let mut tried: Vec<BTreeSet<String>> = specs
6774 .iter()
6775 .map(|s| BTreeSet::from([s.id.clone()]))
6776 .collect();
6777 let starts: Vec<usize> = specs
6778 .iter()
6779 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
6780 .collect();
6781 let mut prev: Vec<Option<FailClass>> = vec![None; n];
6782 let mut fresh: Vec<Option<String>> = vec![None; n];
6783 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
6784 let mut pending: Vec<usize> = (0..n).collect();
6785
6786 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
6791 for round in 0..max_rounds {
6792 if pending.is_empty() {
6793 break;
6794 }
6795 let mut batch = Vec::with_capacity(pending.len());
6796 let mut renudged: Vec<&str> = Vec::new();
6797 for &i in &pending {
6798 let src = &originals[i];
6799 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
6802 (
6803 src.prompt.clone(),
6804 src.timeout,
6805 format!("{}-{word}-{}", src.stem, specs[i].id),
6806 )
6807 } else if nudges[i] == 0 {
6808 (src.prompt.clone(), src.timeout, src.stem.clone())
6809 } else {
6810 renudged.push(src.seat.key.as_str());
6811 let why = done[i]
6812 .as_ref()
6813 .and_then(|r| r.as_ref().err().map(ToString::to_string))
6814 .unwrap_or_else(|| "no parsable answer".to_owned());
6815 let nudge = prompt::nudge(&why);
6816 let nudged = has_context(&specs[i], &seats[i], src.sessions);
6817 let prompt = if nudged {
6818 nudge
6819 } else {
6820 format!("{}\n\n---\n\n{}", src.prompt, nudge)
6821 };
6822 (
6823 prompt,
6824 retry_budget(src.timeout, nudged),
6825 format!("{}-retry{}", src.stem, nudges[i]),
6826 )
6827 };
6828 batch.push(SeatJob {
6829 spec: specs[i].clone(),
6830 seat: seats[i].clone(),
6831 cwd: src.cwd.clone(),
6832 prompt,
6833 timeout,
6834 allow_write: src.allow_write,
6835 sessions: src.sessions,
6836 artifacts: src.artifacts.clone(),
6837 stem,
6838 });
6839 }
6840
6841 if !renudged.is_empty() {
6842 state.event(
6843 ctx.node,
6844 format!("retry {round}: re-asking {}", renudged.join(", ")),
6845 );
6846 }
6847 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
6848 let mut still = Vec::new();
6849 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
6850 seats[i] = seat;
6851 let class = FailClass::of(&out);
6852 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
6855 && nudges[i] < retries
6856 && !roster.is_empty();
6857 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
6858 let next = should_hand_over(prev[i].as_ref(), &cur)
6859 .then(|| next_untried_in_roster(roster, starts[i], &tried[i]).cloned())
6860 .flatten();
6861 if let Some(next) = next {
6862 record_handover(
6863 state,
6864 ctx.node,
6865 &originals[i].seat.key,
6866 &specs[i].id,
6867 &next.id,
6868 &cur,
6869 &fail_reason(&out),
6870 );
6871 tried[i].insert(next.id.clone());
6872 prev[i] = Some(cur.clone());
6873 seats[i] = handover_seat(&originals[i].seat.key, &next.id, state.seed);
6874 specs[i] = next;
6875 fresh[i] = Some(cur.stem_word().to_owned());
6876 nudges[i] = 0;
6877 done[i] = Some(Err(anyhow::anyhow!(
6878 "handed over after: {}",
6879 fail_reason(&out)
6880 )));
6881 still.push(i);
6882 continue;
6883 }
6884 }
6885 let parsed = match out {
6886 AgentOutcome::Ok(o) => match verdict::extract_json::<T>(&o.text) {
6887 Ok(v) => match validate(&v) {
6888 Ok(()) => Ok((v, o)),
6889 Err(e) => Err(e),
6890 },
6891 Err(e) => Err(e),
6892 },
6893 AgentOutcome::Quota(o) => {
6894 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
6895 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
6896 }
6897 AgentOutcome::Dropped(o) => {
6901 let why = o
6902 .dropped
6903 .as_ref()
6904 .map(|d| d.why.as_str())
6905 .unwrap_or("the CLI ended the stream without delivering its answer");
6906 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
6907 }
6908 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
6909 };
6910 let quota = class == Some(FailClass::Quota);
6911 let failed = parsed.is_err();
6912 done[i] = Some(parsed);
6913 let agent_failure = class.is_some()
6921 && !nudge_first
6922 && !roster.is_empty()
6923 && next_untried_in_roster(roster, starts[i], &tried[i]).is_some();
6924 if failed && !quota && !agent_failure && nudges[i] < retries {
6925 nudges[i] += 1;
6926 still.push(i);
6927 }
6928 }
6929 pending = still;
6930 }
6931
6932 for (i, q) in last_quota.into_iter().enumerate() {
6935 if let Some(reset) = q {
6936 losses.push(QuotaLoss {
6937 seat: originals[i].seat.key.clone(),
6938 node: ctx.node.to_owned(),
6939 at: Timestamp::now(),
6940 reset,
6941 });
6942 }
6943 }
6944
6945 seats
6946 .into_iter()
6947 .zip(done)
6948 .zip(nudges)
6949 .map(|((seat, res), attempts)| {
6950 (
6951 seat,
6952 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
6953 attempts,
6954 )
6955 })
6956 .collect()
6957}
6958
6959async fn acquire_cache_lease(
6972 state: &mut RunState,
6973 cache_dir: &Path,
6974 owner: &crate::cache::Owner,
6975 budget: Duration,
6976 context: &str,
6977) -> Result<crate::cache::Guard> {
6978 let home = crate::run::home();
6979 let started = Instant::now();
6980 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
6981 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
6982 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
6983 Err(e) => {
6984 state.event(
6985 "verify",
6986 format!("{context}: could not check the shared build cache: {e:#}"),
6987 );
6988 if let Err(e2) = state.save() {
6989 tracing::warn!("could not persist a cache-check failure: {e2:#}");
6990 }
6991 return Err(e);
6992 }
6993 };
6994 state.event(
6995 "verify",
6996 format!(
6997 "{context}: waiting for the shared build cache at {} ({})",
6998 cache_dir.display(),
6999 busy.describe()
7000 ),
7001 );
7002 if let Err(e) = state.save() {
7003 tracing::warn!("could not persist a cache wait: {e:#}");
7004 }
7005 let remaining = budget.saturating_sub(started.elapsed());
7006 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7007 Ok(g) => Ok(g),
7008 Err(e) => {
7009 state.event("verify", format!("{context}: {e:#}"));
7010 if let Err(e2) = state.save() {
7011 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7012 }
7013 Err(e)
7014 }
7015 }
7016}
7017
7018#[allow(clippy::too_many_arguments)]
7039async fn with_cache_lease<'s, F, Fut>(
7040 state: &'s mut RunState,
7041 cache_dir: Option<&Path>,
7042 node: &str,
7043 seat: &str,
7044 worktree: &Path,
7045 head: &str,
7046 budget: Duration,
7047 context: &str,
7048 body: F,
7049) -> (Vec<CommandOutcome>, bool)
7050where
7051 F: FnOnce(&'s mut RunState, Duration) -> Fut,
7052 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7053{
7054 let Some(cache_dir) = cache_dir else {
7055 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7056 return (outcomes, retried);
7057 };
7058 let home = crate::run::home();
7059 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7060 let started = Instant::now();
7061 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
7062 Ok(g) => g,
7063 Err(e) => {
7064 return (
7065 vec![CommandOutcome {
7066 command: "(waiting for the shared build cache)".to_owned(),
7067 code: None,
7068 output_tail: e.to_string(),
7069 duration_ms: started.elapsed().as_millis() as u64,
7070 resource_blocked: true,
7071 }],
7072 false,
7073 );
7074 }
7075 };
7076 let identity = crate::cache::Identity::new(worktree, head);
7077 if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
7078 state.event(
7086 "verify",
7087 format!(
7088 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
7089 worktree.display(),
7090 short(head)
7091 ),
7092 );
7093 guard.release();
7094 return (
7095 vec![CommandOutcome {
7096 command: "(confirming the shared build cache is fresh)".to_owned(),
7097 code: None,
7098 output_tail: e.to_string(),
7099 duration_ms: started.elapsed().as_millis() as u64,
7100 resource_blocked: true,
7101 }],
7102 false,
7103 );
7104 }
7105 let remaining = budget.saturating_sub(started.elapsed());
7106 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
7107 if !timed_out_pids.is_empty() {
7112 wait_for_timed_out_children_to_die(&timed_out_pids).await;
7113 }
7114 guard.release();
7115 (outcomes, retried)
7116}
7117
7118async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
7130 wait_for_pids_with(
7131 pids,
7132 crate::proc::pid_alive,
7133 LEASE_RELEASE_POLL,
7134 LEASE_RELEASE_MAX_WAIT,
7135 )
7136 .await;
7137}
7138
7139async fn wait_for_pids_with<F: Fn(u32) -> bool>(
7145 pids: &[u32],
7146 alive: F,
7147 poll: Duration,
7148 max_wait: Duration,
7149) {
7150 let deadline = Instant::now() + max_wait;
7151 loop {
7152 if pids.iter().all(|&pid| !alive(pid)) {
7153 return;
7154 }
7155 if Instant::now() >= deadline {
7156 return;
7157 }
7158 tokio::time::sleep(poll).await;
7159 }
7160}
7161
7162fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
7169 outcomes.iter().any(|o| o.resource_blocked)
7170}
7171
7172enum GateFix {
7174 Retry,
7176 Stop,
7179 Defer,
7182}
7183
7184fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
7192 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
7193 red.peek().is_some()
7194 && red.all(|o| {
7195 !o.resource_blocked
7196 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
7197 && !o.output_tail.trim().is_empty()
7198 })
7199}
7200
7201fn e2e_outcome_label(o: &CommandOutcome) -> String {
7205 if o.ok() {
7206 return "pass".to_owned();
7207 }
7208 let reason = if o.build_failed() {
7209 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
7210 } else {
7211 format!("FAIL ({:?})", o.code)
7212 };
7213 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
7214}
7215
7216async fn run_e2e_with_retry(
7224 state: &mut RunState,
7225 shell: &[String],
7226 commands: &[String],
7227 worktree: &Path,
7228 timeout: Duration,
7229 context: &str,
7230) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
7231 let (mut e2e, mut timed_out_pids) = run_commands(
7232 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
7233 )
7234 .await;
7235 for o in &e2e {
7236 state.event(
7237 "verify",
7238 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
7239 );
7240 }
7241 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
7245 if verify_retried {
7246 state.event(
7247 "verify",
7248 format!(
7249 "{context}: verify could not build/link, not a test result — retrying once \
7250 before concluding"
7251 ),
7252 );
7253 let retried = run_commands(
7254 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
7255 )
7256 .await;
7257 e2e = retried.0;
7258 timed_out_pids.extend(retried.1);
7261 for o in &e2e {
7262 state.event(
7263 "verify",
7264 format!(
7265 "{context}: retry `{}` -> {}",
7266 o.command,
7267 e2e_outcome_label(o)
7268 ),
7269 );
7270 }
7271 }
7272 (e2e, verify_retried, timed_out_pids)
7273}
7274
7275#[allow(clippy::too_many_arguments)]
7291async fn run_commands(
7292 state: &mut RunState,
7293 node: &str,
7294 task: &str,
7295 attempt: usize,
7296 shell: &[String],
7297 commands: &[String],
7298 cwd: &Path,
7299 timeout: Duration,
7300) -> (Vec<CommandOutcome>, Vec<u32>) {
7301 if commands.is_empty() {
7302 return (Vec::new(), Vec::new());
7307 }
7308 let mut out = Vec::new();
7309 let mut timed_out_pids = Vec::new();
7310 let total = commands.len();
7311 for (idx, command) in commands.iter().enumerate() {
7312 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
7313 if let Err(e) = state.save() {
7314 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
7315 }
7316 let started = Instant::now();
7317 let mut cmd = tokio::process::Command::new(&shell[0]);
7318 cmd.quiet();
7319 cmd.args(&shell[1..])
7320 .arg(command)
7321 .current_dir(cwd)
7322 .stdin(std::process::Stdio::null())
7323 .stdout(std::process::Stdio::piped())
7324 .stderr(std::process::Stdio::piped())
7325 .kill_on_drop(true);
7326 let spawned = cmd.spawn();
7327 let (code, body) = match spawned {
7328 Ok(child) => {
7329 let pid = child.id();
7334 match tokio::time::timeout(timeout, child.wait_with_output()).await {
7335 Ok(Ok(o)) => {
7336 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
7337 body.push_str(&String::from_utf8_lossy(&o.stderr));
7338 (o.status.code(), body)
7339 }
7340 Ok(Err(e)) => (None, format!("failed to run: {e}")),
7341 Err(_) => {
7342 if let Some(pid) = pid {
7343 timed_out_pids.push(pid);
7344 }
7345 (None, format!("timed out after {}s", timeout.as_secs()))
7346 }
7347 }
7348 }
7349 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
7350 };
7351 out.push(CommandOutcome {
7352 command: command.clone(),
7353 code,
7354 output_tail: tail(&body, OUTPUT_TAIL),
7355 duration_ms: started.elapsed().as_millis() as u64,
7356 resource_blocked: false,
7357 });
7358 }
7359 state.task_finished(task);
7360 if let Err(e) = state.save() {
7361 tracing::warn!("could not persist the end of {task}: {e:#}");
7362 }
7363 (out, timed_out_pids)
7364}
7365
7366fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
7378 let repo = repo.display();
7379 match style {
7380 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
7381 MergeStyle::Squash => {
7382 let subject = message
7385 .lines()
7386 .next()
7387 .unwrap_or(branch)
7388 .replace(['\\', '"', '$', '`'], "");
7389 format!(
7390 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
7391 )
7392 }
7393 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
7394 }
7395}
7396
7397const PR_TITLE_MAX: usize = 240;
7408
7409struct PrMessage {
7414 title: String,
7415 body: String,
7416}
7417
7418impl PrMessage {
7419 fn commit_message(&self) -> String {
7423 format!("{}\n\n{}", self.title, self.body)
7424 }
7425}
7426
7427fn title_marker(line: &str) -> Option<&str> {
7429 let line = line.trim();
7430 let head = line.get(..6)?;
7431 head.eq_ignore_ascii_case("title:")
7432 .then(|| line[6..].trim())
7433}
7434
7435fn summary_title(summary: &str) -> Option<String> {
7440 let first = summary.lines().find(|l| !l.trim().is_empty())?;
7441 let raw = title_marker(first)?;
7442 if raw.is_empty() {
7443 return None;
7444 }
7445 let title = queue::title_from(raw, PR_TITLE_MAX);
7446 let lower = title.to_ascii_lowercase();
7447 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7448 return None;
7449 }
7450 Some(title)
7451}
7452
7453const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
7455
7456const EXISTING_BRANCH: &str = "(existing branch)";
7458
7459fn is_review_run(state: &RunState) -> bool {
7463 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
7464}
7465
7466fn review_title(state: &RunState) -> Option<String> {
7471 english_subject(state.reviewed_commits.as_ref()?.first()?)
7472}
7473
7474fn english_subject(raw: &str) -> Option<String> {
7477 let raw = raw.trim();
7478 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
7479 return None;
7480 }
7481 let title = queue::title_from(raw, PR_TITLE_MAX);
7482 let lower = title.to_ascii_lowercase();
7483 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7484 return None;
7485 }
7486 Some(title)
7487}
7488
7489#[derive(Debug, Clone, PartialEq, Eq)]
7492struct BranchFacts {
7493 commits: Vec<(String, String)>,
7495 stat: String,
7497}
7498
7499const STAT_FILE_LINES: usize = 25;
7501const COMMITS_MAX_CHARS: usize = 20_000;
7503
7504async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
7507 let commits = git::commit_log(repo, base, branch).await.ok()?;
7508 if commits.is_empty() {
7509 return None;
7510 }
7511 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
7512 let lines: Vec<&str> = stat.lines().collect();
7513 let stat = if lines.len() > STAT_FILE_LINES + 1 {
7514 let omitted = lines.len() - 1 - STAT_FILE_LINES;
7515 let more = format!(" ... {omitted} more file(s)");
7516 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
7517 kept.push(&more);
7518 kept.push(lines[lines.len() - 1]);
7519 kept.join("\n")
7520 } else {
7521 lines.join("\n")
7522 };
7523 Some(BranchFacts { commits, stat })
7524}
7525
7526fn markdown_safe(text: &str) -> String {
7529 text.replace("</details>", "</details>")
7530 .replace("\x60\x60\x60", "~~~")
7531}
7532
7533fn neutral_title(state: &RunState, winner: char) -> String {
7534 format!(
7535 "chore: land candidate {} of run {}",
7536 winner.to_ascii_uppercase(),
7537 state.id
7538 )
7539}
7540
7541pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
7547 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
7548 ""
7549 } else {
7550 pr_title
7551 }
7552}
7553
7554pub fn landing_subject_source(state: &RunState) -> String {
7558 if is_review_run(state) {
7559 let winner = state.candidates.first().map_or('A', |c| c.label);
7560 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
7561 }
7562 state.instruction.clone()
7563}
7564
7565fn summary_without_title(summary: &str) -> String {
7568 let mut lines = summary.trim().lines().peekable();
7569 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
7570 lines.next();
7571 }
7572 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
7573}
7574
7575#[cfg(test)]
7589fn pr_message(state: &RunState, winner: char) -> PrMessage {
7590 pr_message_with(state, winner, None)
7591}
7592
7593fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
7596 let summary = state
7597 .candidates
7598 .iter()
7599 .find(|c| c.label == winner)
7600 .map(|c| c.summary.as_str())
7601 .unwrap_or_default();
7602 let review = is_review_run(state);
7605 let title = if review {
7606 facts
7607 .and_then(|f| english_subject(&f.commits.first()?.0))
7608 .or_else(|| review_title(state))
7609 .or_else(|| {
7610 state
7611 .candidates
7612 .iter()
7613 .find(|c| c.label == winner)
7614 .filter(|c| !c.branch.starts_with("magi/"))
7615 .and_then(|c| english_subject(&c.branch))
7616 })
7617 .unwrap_or_else(|| neutral_title(state, winner))
7618 } else {
7619 summary_title(summary).unwrap_or_else(|| {
7620 let t = queue::title_from(&state.instruction, PR_TITLE_MAX);
7621 if t.is_ascii() && t.chars().any(|c| c.is_ascii_alphabetic()) {
7622 t
7623 } else {
7624 neutral_title(state, winner)
7625 }
7626 })
7627 };
7628
7629 let mut body = String::new();
7630 let what = summary_without_title(summary);
7631 if !what.is_empty() {
7632 body.push_str("## Summary\n\n");
7633 body.push_str(&what);
7634 body.push_str("\n\n");
7635 }
7636
7637 let notes: Vec<(usize, &str)> = state
7640 .reviews
7641 .iter()
7642 .filter_map(|r| {
7643 let n = r.fix.as_ref()?.notes.trim();
7644 (!n.is_empty()).then_some((r.round, n))
7645 })
7646 .collect();
7647 if !notes.is_empty() {
7648 body.push_str("## Review fixes\n\n");
7649 if let [(_, only)] = notes.as_slice() {
7650 body.push_str(only);
7651 body.push_str("\n\n");
7652 } else {
7653 for (round, n) in ¬es {
7654 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
7655 }
7656 }
7657 }
7658 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
7659
7660 let open = state.open_findings();
7661 if !open.is_empty() {
7662 body.push_str("## Open review findings\n\n");
7663 for f in &open {
7664 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
7665 }
7666 body.push('\n');
7667 }
7668
7669 if let Some(fix) = fix
7670 && !fix.rejected.is_empty()
7671 {
7672 body.push_str("## Declined by the fixer\n\n");
7673 for r in &fix.rejected {
7674 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
7675 }
7676 body.push('\n');
7677 }
7678
7679 if review {
7680 body.push_str("## Commits under review\n\n");
7682 if let Some(facts) = facts {
7683 let mut left = COMMITS_MAX_CHARS;
7684 for (i, (subject, text)) in facts.commits.iter().enumerate() {
7685 let mut entry = format!("- {}\n", markdown_safe(subject));
7686 for l in markdown_safe(text).lines() {
7687 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
7688 }
7689 if left == 0 {
7690 body.push_str(&format!(
7691 "- ... {} more commit(s)\n",
7692 facts.commits.len() - i
7693 ));
7694 break;
7695 }
7696 if entry.len() > left {
7697 let mut end = left;
7700 while !entry.is_char_boundary(end) {
7701 end -= 1;
7702 }
7703 entry.truncate(end);
7704 entry.push_str("\n ... (truncated)\n");
7705 left = 0;
7706 } else {
7707 left -= entry.len();
7708 }
7709 body.push_str(&entry);
7710 }
7711 if !facts.stat.trim().is_empty() {
7712 body.push_str(&format!(
7713 "\n## Diff stat\n\n```\n{}\n```\n",
7714 markdown_safe(facts.stat.trim())
7715 ));
7716 }
7717 } else {
7718 match &state.reviewed_commits {
7719 Some(subjects) => {
7720 for s in subjects {
7721 body.push_str(&format!("- {}\n", s.trim()));
7722 }
7723 }
7724 None => {
7725 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
7728 body.push_str(rest.trim());
7729 body.push('\n');
7730 }
7731 }
7732 }
7733 } else {
7734 let task = state.instruction.trim();
7735 let task = if task.is_empty() {
7736 "(empty task)"
7737 } else {
7738 task
7739 };
7740 body.push_str(&format!(
7741 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
7742 task.replace("</details>", "</details>")
7743 ));
7744 }
7745
7746 body.push_str(&format!(
7747 "\n---\nmagi:run/{} magi:candidate-{}\n",
7748 state.id,
7749 winner.to_ascii_lowercase()
7750 ));
7751
7752 let id = crate::scrub::Identity::current();
7755 PrMessage {
7756 title: crate::scrub::scrub(&title, &id),
7757 body: crate::scrub::scrub(&body, &id),
7758 }
7759}
7760
7761fn seeded_instruction(state: &RunState) -> String {
7765 match refs::describe(&state.seeds) {
7766 Some(facts) => format!(
7767 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
7768 Candidates start from the unmerged branch named above, when there \
7769 is one, and carry any unmerged commit named by sha as a \
7770 cherry-pick. Check that this is what the task meant before \
7771 building on it.",
7772 state.instruction
7773 ),
7774 None => state.instruction.clone(),
7775 }
7776}
7777
7778async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
7782 let base = &state.base_branch;
7783 let mut against = base.clone();
7784 if mode == MergeMode::Pr {
7785 let remote = &state.config.merge.remote;
7786 let tracking = format!("{remote}/{base}");
7787 let fetched = git::fetch(repo, remote, base).await;
7788 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
7789 against = tracking;
7790 }
7791 }
7792 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
7793}
7794
7795fn empty_candidate_detail(state: &RunState, base: &str) -> String {
7798 let mut detail = format!(
7799 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
7800 nothing to open a pull request for"
7801 );
7802 match refs::describe(&state.seeds) {
7803 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
7804 None => detail.push_str(
7805 "\nThe task names no existing branch or commit; if it means to land work \
7806 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
7807 ),
7808 }
7809 detail
7810}
7811
7812#[derive(Debug, PartialEq, Eq)]
7815enum PrPlan {
7816 Create,
7817 Adopt { url: String, title: String },
7818 Stop(String),
7819}
7820
7821fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
7824 match found {
7825 Ok(land::OpenPr::None) => PrPlan::Create,
7826 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
7827 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
7828 "several open pull requests exist for this branch, not picking one: {}",
7829 urls.join(" ")
7830 )),
7831 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
7832 }
7833}
7834
7835async fn gh_pr_create(
7837 cwd: &Path,
7838 base: &str,
7839 head: &str,
7840 title: &str,
7841 body: &str,
7842) -> Result<String> {
7843 let out = tokio::process::Command::new("gh")
7844 .args([
7845 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
7846 ])
7847 .current_dir(cwd)
7848 .quiet()
7849 .stdin(std::process::Stdio::null())
7850 .output()
7851 .await
7852 .context("spawn gh")?;
7853 if out.status.success() {
7854 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
7855 } else {
7856 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
7857 }
7858}
7859
7860pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
7869 let repo = state.repo.clone();
7870 let root = state.worktree_root();
7871 let winner = state.tally.as_ref().map(|t| t.winner);
7872 let mut removed = Vec::new();
7873
7874 for i in 0..state.candidates.len() {
7875 let c = state.candidates[i].clone();
7876 let is_winner = Some(c.label) == winner;
7877 if is_winner && !drop_winner {
7878 continue;
7879 }
7880 if c.worktree.exists() {
7881 git::worktree_remove(&repo, &c.worktree).await.ok();
7882 removed.push(c.worktree.to_string_lossy().into_owned());
7883 }
7884 let handed_over = state.released_branches.contains(&c.branch);
7887 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
7888 git::branch_delete(&repo, &c.branch).await.ok();
7889 removed.push(c.branch.clone());
7890 }
7891 state.candidates[i].folded = true;
7892 }
7893
7894 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
7895 let path = name.path();
7896 let keep = !drop_winner
7897 && winner.is_some_and(|w| {
7898 path.file_name()
7899 .is_some_and(|n| n == format!("cand-{w}").as_str())
7900 });
7901 if keep {
7902 continue;
7903 }
7904 git::worktree_remove(&repo, &path).await.ok();
7905 removed.push(path.to_string_lossy().into_owned());
7906 }
7907
7908 remove_if_empty(&root);
7917
7918 if state.enabled_worktree_config && drop_winner {
7919 git::release_worktree_config(&repo).await.ok();
7923 state.enabled_worktree_config = false;
7924 }
7925 state.save_under(home)?;
7926 Ok(removed)
7927}
7928
7929fn remove_if_empty(dir: &Path) {
7940 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
7941 std::fs::remove_dir(dir).ok();
7942 }
7943}
7944
7945pub fn worst_open(state: &RunState) -> Option<Severity> {
7947 state
7948 .reviews
7949 .last()?
7950 .reviews
7951 .iter()
7952 .flat_map(|r| r.findings.iter())
7953 .map(|f| f.severity)
7954 .max()
7955}
7956
7957#[cfg(test)]
7958mod tests {
7959 #[test]
7960 fn pr_merge_plan_creates_adopts_or_stops() {
7961 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
7962 assert_eq!(
7963 pr_merge_plan(Ok(land::OpenPr::One {
7964 url: "u".into(),
7965 title: "t".into()
7966 })),
7967 PrPlan::Adopt {
7968 url: "u".into(),
7969 title: "t".into()
7970 }
7971 );
7972 let PrPlan::Stop(many) =
7973 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
7974 else {
7975 panic!("many must stop");
7976 };
7977 assert!(many.contains('a') && many.contains('b'));
7978 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
7979 panic!("a failed lookup must stop");
7980 };
7981 assert!(err.contains("bad token"));
7982 }
7983
7984 use super::*;
7985 use crate::run::GateStatus;
7986 use std::collections::BTreeMap;
7987 use std::time::Duration;
7988
7989 fn conductor() -> AgentSpec {
7990 AgentSpec {
7991 id: "conductor".to_owned(),
7992 kind: crate::config::AgentKind::Command,
7993 model: None,
7994 command: vec!["true".to_owned()],
7995 extra_args: Vec::new(),
7996 env: BTreeMap::new(),
7997 prompt_delivery: None,
7998 }
7999 }
8000
8001 fn spec(id: &str) -> AgentSpec {
8002 AgentSpec {
8003 id: id.to_owned(),
8004 kind: crate::config::AgentKind::Command,
8005 model: None,
8006 command: vec!["true".to_owned()],
8007 extra_args: Vec::new(),
8008 env: BTreeMap::new(),
8009 prompt_delivery: None,
8010 }
8011 }
8012
8013 #[test]
8020 fn failure_signature_ignores_numbers_and_paths() {
8021 assert_eq!(
8022 failure_signature("exited with Some(2) and no usable output"),
8023 failure_signature("exited with Some(137) and no usable output")
8024 );
8025 assert_eq!(
8026 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
8027 failure_signature("cannot open /var/x.txt: denied")
8028 );
8029 assert_ne!(failure_signature("boom"), failure_signature("bang"));
8030 }
8031
8032 #[test]
8033 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
8034 let other = FailClass::Other("x".into());
8035 assert!(should_hand_over(None, &FailClass::Quota));
8036 assert!(should_hand_over(Some(&other), &FailClass::Quota));
8037 assert!(should_hand_over(
8038 Some(&FailClass::Timeout),
8039 &FailClass::Timeout
8040 ));
8041 assert!(should_hand_over(None, &other));
8042 assert!(!should_hand_over(Some(&other), &other));
8043 assert!(should_hand_over(
8044 Some(&other),
8045 &FailClass::Other("y".into())
8046 ));
8047 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
8049 assert!(should_hand_over(Some(&FailClass::Quota), &other));
8050 }
8051
8052 #[test]
8053 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
8054 let a = SeatState::new("judge-1", "alpha", 7);
8055 let b = handover_seat("judge-1", "beta", 7);
8056 assert_ne!(a.claude_session, b.claude_session);
8057 assert_eq!(b.turns, 0);
8058 }
8059
8060 #[test]
8061 fn a_timeout_is_classified_apart_from_other_failures() {
8062 assert_eq!(
8063 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
8064 Some(FailClass::Timeout)
8065 );
8066 assert!(matches!(
8067 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
8068 Some(FailClass::Other(_))
8069 ));
8070 }
8071
8072 #[test]
8073 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
8074 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8075 let tried = BTreeSet::from(["beta".to_owned()]);
8076 let next = next_untried_in_roster(&roster, 1, &tried);
8079 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
8080 }
8081
8082 #[test]
8083 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
8084 let roster = vec![spec("alpha"), spec("beta")];
8085 let tried = BTreeSet::from(["beta".to_owned()]);
8086 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8090 }
8091
8092 #[test]
8093 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
8094 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8095 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
8096 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8100 }
8101
8102 #[test]
8103 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
8104 let roster = vec![spec("a"), spec("a"), spec("b")];
8105 let tried = BTreeSet::from(["a".to_owned()]);
8106 let next = next_untried_in_roster(&roster, 0, &tried);
8107 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8108 }
8109
8110 #[test]
8111 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
8112 let roster = vec![spec("a"), spec("b")];
8113 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
8114 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
8115 }
8116
8117 #[test]
8118 fn remove_if_empty_only_ever_takes_a_bare_directory() {
8119 let dir = tempfile::tempdir().unwrap();
8120 let bay = dir.path().join("ffff");
8121
8122 remove_if_empty(&bay);
8124 assert!(!bay.exists());
8125
8126 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
8129 remove_if_empty(&bay);
8130 assert!(bay.exists(), "non-empty directory must survive");
8131
8132 std::fs::remove_dir(bay.join("cand-A")).unwrap();
8134 remove_if_empty(&bay);
8135 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
8136 }
8137
8138 #[test]
8147 fn a_full_panel_that_found_nothing_is_clean() {
8148 assert!(round_is_clean(
8149 0,
8150 true,
8151 2,
8152 2,
8153 0,
8154 IncompleteReviewPolicy::Block
8155 ));
8156 }
8157
8158 #[test]
8159 fn a_missing_seat_is_never_clean_under_the_default_policy() {
8160 assert!(!round_is_clean(
8161 0,
8162 true,
8163 1,
8164 2,
8165 0,
8166 IncompleteReviewPolicy::Block
8167 ));
8168 }
8169
8170 #[test]
8171 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
8172 assert!(!round_is_clean(
8173 1,
8174 true,
8175 1,
8176 2,
8177 0,
8178 IncompleteReviewPolicy::Warn
8179 ));
8180 }
8181
8182 #[test]
8183 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
8184 assert!(round_is_clean(
8185 0,
8186 true,
8187 1,
8188 2,
8189 0,
8190 IncompleteReviewPolicy::Warn
8191 ));
8192 }
8193
8194 #[test]
8195 fn a_full_panel_with_an_open_finding_is_not_clean() {
8196 assert!(!round_is_clean(
8197 1,
8198 true,
8199 2,
8200 2,
8201 0,
8202 IncompleteReviewPolicy::Block
8203 ));
8204 }
8205
8206 #[test]
8207 fn a_full_panel_with_a_red_e2e_is_not_clean() {
8208 assert!(!round_is_clean(
8209 0,
8210 false,
8211 2,
8212 2,
8213 0,
8214 IncompleteReviewPolicy::Block
8215 ));
8216 }
8217
8218 #[test]
8225 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
8226 assert!(round_is_clean(
8229 0,
8230 true,
8231 1,
8232 2,
8233 1,
8234 IncompleteReviewPolicy::Block
8235 ));
8236 }
8237
8238 #[test]
8239 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
8240 assert!(!round_is_clean(
8243 0,
8244 true,
8245 1,
8246 2,
8247 0,
8248 IncompleteReviewPolicy::Block
8249 ));
8250 }
8251
8252 #[test]
8253 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
8254 assert!(!round_is_clean(
8255 1,
8256 true,
8257 1,
8258 2,
8259 1,
8260 IncompleteReviewPolicy::Block
8261 ));
8262 assert!(!round_is_clean(
8263 0,
8264 false,
8265 1,
8266 2,
8267 1,
8268 IncompleteReviewPolicy::Block
8269 ));
8270 }
8271
8272 #[test]
8273 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
8274 assert!(!round_is_clean(
8278 0,
8279 true,
8280 0,
8281 2,
8282 2,
8283 IncompleteReviewPolicy::Block
8284 ));
8285 }
8286
8287 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
8288 CommandOutcome {
8289 command: "test".to_owned(),
8290 code,
8291 output_tail: String::new(),
8292 duration_ms: 0,
8293 resource_blocked,
8294 }
8295 }
8296
8297 #[test]
8298 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
8299 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
8300 assert!(
8301 !verify_inconclusive(&[outcome(Some(1), false)]),
8302 "an ordinary failure is still evidence about the patch"
8303 );
8304 assert!(verify_inconclusive(&[outcome(None, true)]));
8305 assert!(
8306 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
8307 "one inconclusive outcome taints the whole batch"
8308 );
8309 assert!(!verify_inconclusive(&[]));
8310 }
8311
8312 #[tokio::test]
8313 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
8314 let calls = std::sync::atomic::AtomicUsize::new(0);
8318 let started = Instant::now();
8319 wait_for_pids_with(
8320 &[123],
8321 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
8322 Duration::from_millis(5),
8323 Duration::from_secs(5),
8324 )
8325 .await;
8326 assert!(
8327 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
8328 "must keep checking rather than deciding on the first answer"
8329 );
8330 assert!(
8331 started.elapsed() < Duration::from_secs(1),
8332 "must return the moment it is confirmed dead, not wait out the ceiling"
8333 );
8334 }
8335
8336 #[tokio::test]
8337 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
8338 let started = Instant::now();
8339 wait_for_pids_with(
8340 &[123],
8341 |_| true, Duration::from_millis(5),
8343 Duration::from_millis(30),
8344 )
8345 .await;
8346 let elapsed = started.elapsed();
8347 assert!(
8348 elapsed >= Duration::from_millis(30),
8349 "must not give up before its own ceiling: {elapsed:?}"
8350 );
8351 assert!(
8352 elapsed < Duration::from_secs(1),
8353 "must not wait past its own ceiling either: {elapsed:?}"
8354 );
8355 }
8356
8357 #[tokio::test]
8358 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
8359 let started = Instant::now();
8360 wait_for_pids_with(
8361 &[],
8362 |_| true,
8363 Duration::from_secs(5),
8364 Duration::from_secs(5),
8365 )
8366 .await;
8367 assert!(
8368 started.elapsed() < Duration::from_millis(200),
8369 "an empty pid list has nothing to confirm"
8370 );
8371 }
8372
8373 fn review_round(
8379 clean: bool,
8380 blocking: usize,
8381 answered: usize,
8382 expected: usize,
8383 progressed: bool,
8384 e2e_ok: bool,
8385 ) -> ReviewRound {
8386 ReviewRound {
8387 round: 1,
8388 head: "h".to_owned(),
8389 verified_head: None,
8390 verified_at: None,
8391 reviews: Vec::new(),
8392 e2e: vec![CommandOutcome {
8393 command: "test".to_owned(),
8394 code: Some(if e2e_ok { 0 } else { 1 }),
8395 output_tail: String::new(),
8396 duration_ms: 0,
8397 resource_blocked: false,
8398 }],
8399 verify_retried: false,
8400 e2e_deferred: false,
8401 e2e_defer_reason: None,
8402 fix: None,
8403 blocking,
8404 answered,
8405 expected,
8406 clean,
8407 progressed,
8408 vote_split: false,
8409 reconsideration: Vec::new(),
8410 verdict: None,
8411 }
8412 }
8413
8414 #[test]
8415 fn review_conclusion_is_none_when_nothing_has_run() {
8416 assert_eq!(review_conclusion(&[], 3), None);
8417 }
8418
8419 #[test]
8420 fn review_conclusion_is_none_while_rounds_remain() {
8421 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
8422 assert_eq!(review_conclusion(&rounds, 3), None);
8423 }
8424
8425 #[test]
8426 fn review_conclusion_is_gating_once_a_round_is_clean() {
8427 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
8428 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
8429 }
8430
8431 #[test]
8432 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
8433 let rounds = vec![
8434 review_round(false, 1, 2, 2, true, true),
8435 review_round(false, 1, 2, 2, true, true),
8436 ];
8437 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
8438 }
8439
8440 #[test]
8441 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
8442 let rounds = vec![
8443 review_round(false, 1, 2, 2, true, true),
8444 review_round(false, 1, 2, 2, true, false),
8445 ];
8446 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
8447 }
8448
8449 #[test]
8450 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
8451 let mut blocked = review_round(false, 1, 2, 2, true, false);
8458 blocked.e2e[0].resource_blocked = true;
8459 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
8460 assert_eq!(review_conclusion(&rounds, 2), None);
8461 }
8462
8463 #[test]
8464 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
8465 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
8467 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
8468 }
8469
8470 #[test]
8471 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
8472 let rounds = vec![
8473 review_round(false, 1, 2, 2, false, true),
8474 review_round(false, 1, 2, 2, false, true),
8475 ];
8476 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
8477 }
8478
8479 fn secs(n: u64) -> Duration {
8480 Duration::from_secs(n)
8481 }
8482
8483 fn init_repo(dir: &Path) {
8486 let run = |args: &[&str]| {
8487 let out = std::process::Command::new("git")
8488 .args(args)
8489 .current_dir(dir)
8490 .quiet()
8491 .output()
8492 .expect("spawn git");
8493 assert!(
8494 out.status.success(),
8495 "git {args:?} failed: {}",
8496 String::from_utf8_lossy(&out.stderr)
8497 );
8498 };
8499 run(&["init", "-b", "main"]);
8500 run(&["config", "user.name", "magi test"]);
8501 run(&["config", "user.email", "magi@example.com"]);
8502 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
8503 run(&["add", "-A"]);
8504 run(&["commit", "-m", "init"]);
8505 }
8506
8507 fn ask_test_home() {
8515 crate::run::set_home(std::env::temp_dir().join("magi-graph-ask-tests-home"));
8516 }
8517
8518 fn runner_at(status: RunStatus) -> Runner {
8521 let mut state = RunState::new(
8522 PathBuf::from("/nonexistent/repo"),
8523 "main".to_owned(),
8524 "deadbeef".to_owned(),
8525 "task".to_owned(),
8526 Config::default(),
8527 );
8528 state.status = status;
8529 Runner {
8530 state,
8531 roles: ResolvedRoles {
8532 implementers: Vec::new(),
8533 judges: Vec::new(),
8534 reviewers: Vec::new(),
8535 fixer: None,
8536 conductor: conductor(),
8537 implementer_roster: Vec::new(),
8538 judge_roster: Vec::new(),
8539 reviewer_roster: Vec::new(),
8540 },
8541 sem: Arc::new(Semaphore::new(1)),
8542 pause: Pause::new(),
8543 interrupt: Pause::new(),
8544 }
8545 }
8546
8547 #[test]
8551 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
8552 crate::run::set_home(std::env::temp_dir().join("magi-graph-interrupt-tests-home"));
8553 let mut runner = runner_at(RunStatus::Implementing);
8554 let interrupt = Pause::new();
8555 runner.watch_interrupt(interrupt.clone());
8556
8557 interrupt.park_because("task a1b2 asked to run first");
8558
8559 assert!(runner.park_here().expect("park_here"));
8560 assert!(runner.state.parked);
8561 let last = runner.state.events.last().expect("a park event");
8562 assert_eq!(last.node, "park");
8563 assert!(
8564 last.message.contains("task a1b2 asked to run first"),
8565 "expected the interrupt reason in {:?}",
8566 last.message
8567 );
8568 }
8569
8570 #[test]
8578 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
8579 crate::run::set_home(std::env::temp_dir().join("magi-graph-interrupt-tests-home"));
8580 let mut runner = runner_at(RunStatus::Implementing);
8581 let shutdown = Pause::new();
8582 runner.on_pause(shutdown.clone());
8583 let interrupt = Pause::new();
8584 runner.watch_interrupt(interrupt.clone());
8585
8586 assert!(!runner.park_here().expect("park_here"));
8588 assert!(!runner.state.parked);
8589
8590 interrupt.park_because("test");
8592 assert!(!shutdown.parked());
8593 assert!(runner.park_here().expect("park_here"));
8594 }
8595
8596 #[tokio::test]
8610 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
8611 crate::run::set_home(std::env::temp_dir().join("magi-graph-interrupt-tests-home"));
8612 let mut runner = runner_at(RunStatus::Implementing);
8613 let interrupt = Pause::new();
8614 runner.watch_interrupt(interrupt.clone());
8615
8616 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
8617 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
8618
8619 let node = async move {
8623 started_tx.send(()).expect("send started");
8624 finish_rx.await.expect("recv finish");
8625 "node finished"
8626 };
8627
8628 let interrupter = async move {
8629 started_rx.await.expect("recv started");
8630 interrupt.park_because("higher-priority task waiting");
8632 tokio::task::yield_now().await;
8636 finish_tx.send(()).expect("send finish");
8637 };
8638
8639 let (node_result, ()) = tokio::join!(node, interrupter);
8640 assert_eq!(
8641 node_result, "node finished",
8642 "the in-flight call ran to completion"
8643 );
8644
8645 assert!(runner.park_here().expect("park_here"));
8648 assert!(runner.state.parked);
8649 }
8650
8651 #[test]
8657 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
8658 crate::run::set_home(std::env::temp_dir().join("magi-graph-interrupt-tests-home"));
8659 let mut runner = runner_at(RunStatus::Judging);
8660 runner.state.config.agents = vec![conductor()];
8664 runner.state.candidates = vec![Candidate {
8665 index: 0,
8666 label: 'A',
8667 agent: "alpha".to_owned(),
8668 branch: "magi/x/A".to_owned(),
8669 worktree: PathBuf::from("/nonexistent/worktree"),
8670 summary: "did the thing".to_owned(),
8671 stat: "1 file changed".to_owned(),
8672 files: 1,
8673 commits: 1,
8674 empty: false,
8675 failed: None,
8676 verified_noop: None,
8677 duration_ms: 1234,
8678 folded: false,
8679 }];
8680 let run_id = runner.state.id.clone();
8681
8682 let interrupt = Pause::new();
8683 runner.watch_interrupt(interrupt.clone());
8684 interrupt.park_because("task c3d4 asked to run first");
8685 assert!(runner.park_here().expect("park_here"));
8686
8687 let resumed = Runner::resume(&run_id).expect("resume");
8688 assert_eq!(resumed.state.candidates.len(), 1);
8689 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
8690 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
8691 assert_eq!(resumed.state.status, runner.state.status);
8692 assert!(
8693 resumed.state.parked,
8694 "still parked until `execute` actually walks the graph again"
8695 );
8696 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
8697 }
8698
8699 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
8701 let mut q = ask::Question::new(
8702 run.to_owned(),
8703 "implement".to_owned(),
8704 "impl-A".to_owned(),
8705 "Which storage backend should the cache use?".to_owned(),
8706 String::new(),
8707 vec!["SQLite".to_owned(), "Redis".to_owned()],
8708 );
8709 store.put(&mut q).unwrap();
8710 q
8711 }
8712
8713 #[test]
8714 fn a_failed_runs_open_question_is_abandoned() {
8715 ask_test_home();
8716 let store = ask::Questions::open();
8717 let mut runner = runner_at(RunStatus::Failed);
8718 let run = runner.state.id.clone();
8719 let q = ask_open_question(&store, &run);
8720
8721 runner.settle_questions();
8722
8723 let back = store.get(&q.id).unwrap();
8724 assert!(
8725 !back.status.open(),
8726 "the seat that asked died with the run; nobody is left to read an answer"
8727 );
8728 assert!(
8729 back.detail.contains(&run) && back.detail.contains("failed"),
8730 "the reason names what the run became, not just that it is gone: {}",
8731 back.detail
8732 );
8733 }
8734
8735 #[test]
8736 fn a_merged_runs_open_question_is_abandoned_too() {
8737 ask_test_home();
8738 let store = ask::Questions::open();
8739 for status in [RunStatus::Merged, RunStatus::Ready] {
8742 let mut runner = runner_at(status);
8743 let run = runner.state.id.clone();
8744 let q = ask_open_question(&store, &run);
8745
8746 runner.settle_questions();
8747
8748 let back = store.get(&q.id).unwrap();
8749 assert!(
8750 !back.status.open(),
8751 "{status:?} run's question must not outlive the run"
8752 );
8753 }
8754 }
8755
8756 #[test]
8757 fn a_still_resumable_runs_open_question_is_left_alone() {
8758 ask_test_home();
8759 let store = ask::Questions::open();
8760 for status in [RunStatus::Blocked, RunStatus::Stalled] {
8766 let mut runner = runner_at(status);
8767 let run = runner.state.id.clone();
8768 let q = ask_open_question(&store, &run);
8769
8770 runner.settle_questions();
8771
8772 let back = store.get(&q.id).unwrap();
8773 assert!(
8774 back.status.open(),
8775 "{status:?} is still alive; the question must still be waiting"
8776 );
8777 }
8778 }
8779
8780 #[test]
8781 fn settle_questions_never_touches_an_already_answered_question() {
8782 ask_test_home();
8783 let store = ask::Questions::open();
8784 let mut runner = runner_at(RunStatus::Failed);
8785 let run = runner.state.id.clone();
8786 let mut q = ask_open_question(&store, &run);
8787 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
8788 .unwrap();
8789 store.put(&mut q).unwrap();
8790
8791 runner.settle_questions();
8796 runner.settle_questions();
8797
8798 let back = store.get(&q.id).unwrap();
8799 assert_eq!(
8800 back.status,
8801 ask::QuestionStatus::Answered,
8802 "a real answer is a decision on record, never overwritten by a sweep"
8803 );
8804 }
8805
8806 #[tokio::test]
8817 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
8818 crate::run::set_home(std::env::temp_dir().join("magi-graph-fold-run-tests-home"));
8819 let tmp = tempfile::tempdir().expect("tempdir");
8820 let repo = tmp.path().join("repo");
8821 std::fs::create_dir_all(&repo).unwrap();
8822 init_repo(&repo);
8823
8824 let mut config = Config::default();
8825 config.graph.worktree_root = Some(tmp.path().join("wt"));
8826
8827 let mut state = RunState::new(
8828 repo.clone(),
8829 "main".to_owned(),
8830 "deadbeef".to_owned(),
8831 "task".to_owned(),
8832 config,
8833 );
8834 let root = state.worktree_root();
8835 let wt_a = root.join("cand-A");
8836 let wt_b = root.join("cand-B");
8837 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
8838 .await
8839 .expect("worktree A");
8840 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
8841 .await
8842 .expect("worktree B");
8843
8844 state.candidates = vec![
8845 Candidate {
8846 index: 0,
8847 label: 'A',
8848 agent: "alpha".to_owned(),
8849 branch: "magi/x/A".to_owned(),
8850 worktree: wt_a.clone(),
8851 summary: String::new(),
8852 stat: String::new(),
8853 files: 0,
8854 commits: 0,
8855 empty: false,
8856 failed: None,
8857 verified_noop: None,
8858 duration_ms: 0,
8859 folded: false,
8860 },
8861 Candidate {
8862 index: 1,
8863 label: 'B',
8864 agent: "beta".to_owned(),
8865 branch: "magi/x/B".to_owned(),
8866 worktree: wt_b.clone(),
8867 summary: String::new(),
8868 stat: String::new(),
8869 files: 0,
8870 commits: 0,
8871 empty: false,
8872 failed: None,
8873 verified_noop: None,
8874 duration_ms: 0,
8875 folded: false,
8876 },
8877 ];
8878 state.tally = Some(Tally {
8879 first_choice: BTreeMap::from([('A', 1)]),
8880 borda: BTreeMap::new(),
8881 winner: 'A',
8882 rankings: 1,
8883 unanimous_initial: true,
8884 deliberated: false,
8885 changed_votes: 0,
8886 unanimous_final: true,
8887 tie_break: None,
8888 judges: 1,
8889 present: 1,
8890 quorum: 1,
8891 met_quorum: true,
8892 uncontested: None,
8893 });
8894 state.status = RunStatus::Ready;
8895
8896 fold_run(&mut state, false, &crate::run::home())
8897 .await
8898 .expect("fold_run");
8899
8900 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
8901 assert!(
8902 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
8903 "the unmerged winner's branch survives"
8904 );
8905 assert!(
8906 !state.candidates[0].folded,
8907 "the winner is not marked folded"
8908 );
8909
8910 assert!(!wt_b.exists(), "the loser's worktree is removed");
8911 assert!(
8912 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
8913 "the loser's branch is removed"
8914 );
8915 assert!(state.candidates[1].folded, "the loser is marked folded");
8916 }
8917
8918 #[tokio::test]
8921 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
8922 let tmp = tempfile::tempdir().expect("tempdir");
8923 let repo = tmp.path().join("repo");
8924 std::fs::create_dir_all(&repo).unwrap();
8925 init_repo(&repo);
8926 let home = tmp.path().join("home");
8927
8928 let mut config = Config::default();
8929 config.graph.worktree_root = Some(tmp.path().join("wt"));
8930 let mut state = RunState::new(
8931 repo.clone(),
8932 "main".to_owned(),
8933 "deadbeef".to_owned(),
8934 "task".to_owned(),
8935 config,
8936 );
8937 git::git(&repo, &["branch", "magi/x/A", "main"])
8939 .await
8940 .expect("branch");
8941 state.candidates = vec![Candidate {
8942 index: 0,
8943 label: 'A',
8944 agent: "alpha".to_owned(),
8945 branch: "magi/x/A".to_owned(),
8946 worktree: state.worktree_root().join("cand-A"),
8947 summary: String::new(),
8948 stat: String::new(),
8949 files: 0,
8950 commits: 0,
8951 empty: false,
8952 failed: None,
8953 verified_noop: None,
8954 duration_ms: 0,
8955 folded: true,
8956 }];
8957 state.released_to = Some("20260901-000000-new1".to_owned());
8958 state.released_branches = vec!["magi/x/A".to_owned()];
8959
8960 fold_run(&mut state, true, &home).await.expect("fold_run");
8961
8962 assert!(
8963 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
8964 "the handed-over branch survives a fold"
8965 );
8966 }
8967
8968 #[tokio::test]
8972 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
8973 let tmp = tempfile::tempdir().expect("tempdir");
8974 let repo = tmp.path().join("repo");
8975 std::fs::create_dir_all(&repo).unwrap();
8976 init_repo(&repo);
8977 let run = |args: &[&str]| {
8978 let out = std::process::Command::new("git")
8979 .quiet()
8980 .args(args)
8981 .current_dir(&repo)
8982 .output()
8983 .expect("spawn git");
8984 assert!(out.status.success(), "git {args:?}");
8985 };
8986 run(&["branch", "magi/x/A"]);
8987 run(&["checkout", "-q", "-b", "magi/x/B"]);
8988 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
8989 run(&["add", "-A"]);
8990 run(&["commit", "-q", "-m", "work"]);
8991 run(&["checkout", "-q", "main"]);
8992
8993 let mut state = RunState::new(
8994 repo.clone(),
8995 "main".to_owned(),
8996 "deadbeef".to_owned(),
8997 "task".to_owned(),
8998 Config::default(),
8999 );
9000 state.seeds = vec![refs::Seed {
9001 token: "magi/27b2/A".to_owned(),
9002 kind: refs::SeedKind::Unresolved,
9003 sha: String::new(),
9004 branch: true,
9005 detail: "no branch or commit named magi/27b2/A".to_owned(),
9006 }];
9007
9008 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
9009 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
9010 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
9011 let detail = empty_candidate_detail(&state, "main");
9012 assert!(detail.starts_with("empty candidate"), "{detail}");
9013 assert!(detail.contains("magi/27b2/A"), "{detail}");
9014 }
9015
9016 #[tokio::test]
9025 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
9026 let tmp = tempfile::tempdir().expect("tempdir");
9027 let repo = tmp.path().join("repo");
9028 std::fs::create_dir_all(&repo).unwrap();
9029 init_repo(&repo);
9030
9031 let mut config = Config::default();
9032 config.merge.mode = MergeMode::Local;
9033
9034 let mut state = RunState::new(
9035 repo.clone(),
9036 "main".to_owned(),
9037 "deadbeef".to_owned(),
9038 "task".to_owned(),
9039 config,
9040 );
9041 state.candidates = vec![Candidate {
9042 index: 0,
9043 label: 'A',
9044 agent: "alpha".to_owned(),
9045 branch: "does-not-exist".to_owned(),
9046 worktree: repo.clone(),
9047 summary: String::new(),
9048 stat: String::new(),
9049 files: 0,
9050 commits: 0,
9051 empty: false,
9052 failed: None,
9053 verified_noop: None,
9054 duration_ms: 0,
9055 folded: false,
9056 }];
9057 state.tally = Some(Tally {
9058 first_choice: BTreeMap::from([('A', 1)]),
9059 borda: BTreeMap::new(),
9060 winner: 'A',
9061 rankings: 1,
9062 unanimous_initial: true,
9063 deliberated: false,
9064 changed_votes: 0,
9065 unanimous_final: true,
9066 tie_break: None,
9067 judges: 0,
9068 present: 0,
9069 quorum: 0,
9070 met_quorum: true,
9071 uncontested: Some("only candidate A produced a change".to_owned()),
9072 });
9073 state.reviews = vec![ReviewRound {
9074 round: 1,
9075 head: "deadbeef".to_owned(),
9076 verified_head: None,
9077 verified_at: None,
9078 reviews: Vec::new(),
9079 e2e: Vec::new(),
9080 fix: None,
9081 blocking: 0,
9082 answered: 0,
9083 expected: 0,
9084 clean: true,
9085 verify_retried: false,
9086 e2e_deferred: false,
9087 e2e_defer_reason: None,
9088 progressed: false,
9089 vote_split: false,
9090 reconsideration: Vec::new(),
9091 verdict: None,
9092 }];
9093 state.gate = vec![CommandOutcome {
9094 command: "test".to_owned(),
9095 code: Some(0),
9096 output_tail: String::new(),
9097 duration_ms: 0,
9098 resource_blocked: false,
9099 }];
9100 state.gate_ran = true;
9101 state.status = RunStatus::Ready;
9106 state.merge = Some(MergeOutcome {
9107 mode: MergeMode::Local,
9108 ok: false,
9109 detail: "already concluded".to_owned(),
9110 empty: false,
9111 });
9112
9113 let mut runner = Runner {
9114 state,
9115 roles: ResolvedRoles {
9116 implementers: Vec::new(),
9117 judges: Vec::new(),
9118 reviewers: Vec::new(),
9119 fixer: None,
9120 conductor: conductor(),
9121 implementer_roster: Vec::new(),
9122 judge_roster: Vec::new(),
9123 reviewer_roster: Vec::new(),
9124 },
9125 sem: Arc::new(Semaphore::new(1)),
9126 pause: Pause::new(),
9127 interrupt: Pause::new(),
9128 };
9129
9130 runner.merge().await.expect("merge");
9131
9132 assert_eq!(
9133 runner.state.status,
9134 RunStatus::Ready,
9135 "a concluded run's status must not change on reentry"
9136 );
9137 assert_eq!(
9138 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
9139 Some("already concluded"),
9140 "merge must not run again once the node already recorded an outcome"
9141 );
9142 }
9143
9144 #[tokio::test]
9153 async fn merge_refuses_a_gate_that_has_not_actually_run() {
9154 let tmp = tempfile::tempdir().expect("tempdir");
9155 let repo = tmp.path().join("repo");
9156 std::fs::create_dir_all(&repo).unwrap();
9157 init_repo(&repo);
9158
9159 let mut config = Config::default();
9160 config.merge.mode = MergeMode::Local;
9161
9162 let mut state = RunState::new(
9163 repo.clone(),
9164 "main".to_owned(),
9165 "deadbeef".to_owned(),
9166 "task".to_owned(),
9167 config,
9168 );
9169 state.candidates = vec![Candidate {
9170 index: 0,
9171 label: 'A',
9172 agent: "alpha".to_owned(),
9173 branch: "does-not-exist".to_owned(),
9174 worktree: repo.clone(),
9175 summary: String::new(),
9176 stat: String::new(),
9177 files: 0,
9178 commits: 0,
9179 empty: false,
9180 failed: None,
9181 verified_noop: None,
9182 duration_ms: 0,
9183 folded: false,
9184 }];
9185 state.tally = Some(Tally {
9186 first_choice: BTreeMap::from([('A', 1)]),
9187 borda: BTreeMap::new(),
9188 winner: 'A',
9189 rankings: 1,
9190 unanimous_initial: true,
9191 deliberated: false,
9192 changed_votes: 0,
9193 unanimous_final: true,
9194 tie_break: None,
9195 judges: 0,
9196 present: 0,
9197 quorum: 0,
9198 met_quorum: true,
9199 uncontested: Some("only candidate A produced a change".to_owned()),
9200 });
9201 state.reviews = vec![ReviewRound {
9202 round: 1,
9203 head: "deadbeef".to_owned(),
9204 verified_head: None,
9205 verified_at: None,
9206 reviews: Vec::new(),
9207 e2e: Vec::new(),
9208 fix: None,
9209 blocking: 0,
9210 answered: 0,
9211 expected: 0,
9212 clean: true,
9213 verify_retried: false,
9214 e2e_deferred: false,
9215 e2e_defer_reason: None,
9216 progressed: false,
9217 vote_split: false,
9218 reconsideration: Vec::new(),
9219 verdict: None,
9220 }];
9221 state.gate = Vec::new();
9223 state.gate_ran = false;
9224 state.status = RunStatus::Gating;
9225
9226 let mut runner = Runner {
9227 state,
9228 roles: ResolvedRoles {
9229 implementers: Vec::new(),
9230 judges: Vec::new(),
9231 reviewers: Vec::new(),
9232 fixer: None,
9233 conductor: conductor(),
9234 implementer_roster: Vec::new(),
9235 judge_roster: Vec::new(),
9236 reviewer_roster: Vec::new(),
9237 },
9238 sem: Arc::new(Semaphore::new(1)),
9239 pause: Pause::new(),
9240 interrupt: Pause::new(),
9241 };
9242
9243 runner.merge().await.expect("merge");
9244
9245 assert!(
9246 runner.state.merge.is_none(),
9247 "an empty gate must never be read as a passing one: {:?}",
9248 runner.state.merge
9249 );
9250 }
9251
9252 #[tokio::test]
9259 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
9260 let tmp = tempfile::tempdir().expect("tempdir");
9261 let repo = tmp.path().join("repo");
9262 std::fs::create_dir_all(&repo).unwrap();
9263 init_repo(&repo);
9264
9265 let config = Config::default();
9267
9268 let mut state = RunState::new(
9269 repo.clone(),
9270 "main".to_owned(),
9271 "deadbeef".to_owned(),
9272 "task".to_owned(),
9273 config,
9274 );
9275 state.candidates = vec![Candidate {
9276 index: 0,
9277 label: 'A',
9278 agent: "alpha".to_owned(),
9279 branch: "does-not-exist".to_owned(),
9280 worktree: repo.clone(),
9281 summary: String::new(),
9282 stat: String::new(),
9283 files: 0,
9284 commits: 0,
9285 empty: false,
9286 failed: None,
9287 verified_noop: None,
9288 duration_ms: 0,
9289 folded: false,
9290 }];
9291 state.tally = Some(Tally {
9292 first_choice: BTreeMap::from([('A', 1)]),
9293 borda: BTreeMap::new(),
9294 winner: 'A',
9295 rankings: 1,
9296 unanimous_initial: true,
9297 deliberated: false,
9298 changed_votes: 0,
9299 unanimous_final: true,
9300 tie_break: None,
9301 judges: 0,
9302 present: 0,
9303 quorum: 0,
9304 met_quorum: true,
9305 uncontested: Some("only candidate A produced a change".to_owned()),
9306 });
9307 state.reviews = vec![ReviewRound {
9308 round: 1,
9309 head: "deadbeef".to_owned(),
9310 verified_head: None,
9311 verified_at: None,
9312 reviews: Vec::new(),
9313 e2e: Vec::new(),
9314 fix: None,
9315 blocking: 0,
9316 answered: 0,
9317 expected: 0,
9318 clean: true,
9319 verify_retried: false,
9320 e2e_deferred: false,
9321 e2e_defer_reason: None,
9322 progressed: false,
9323 vote_split: false,
9324 reconsideration: Vec::new(),
9325 verdict: None,
9326 }];
9327
9328 let mut runner = Runner {
9329 state,
9330 roles: ResolvedRoles {
9331 implementers: Vec::new(),
9332 judges: Vec::new(),
9333 reviewers: Vec::new(),
9334 fixer: None,
9335 conductor: conductor(),
9336 implementer_roster: Vec::new(),
9337 judge_roster: Vec::new(),
9338 reviewer_roster: Vec::new(),
9339 },
9340 sem: Arc::new(Semaphore::new(1)),
9341 pause: Pause::new(),
9342 interrupt: Pause::new(),
9343 };
9344
9345 runner.gate().await.expect("gate");
9346 assert!(
9347 runner.state.gate_ran,
9348 "zero configured commands is still a real attempt, not an unrun gate"
9349 );
9350 assert!(runner.state.gate.is_empty());
9351 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
9352 assert_ne!(
9353 runner.state.status,
9354 RunStatus::Blocked,
9355 "a gate with nothing to check must not read as failed"
9356 );
9357
9358 runner.merge().await.expect("merge");
9359 assert_eq!(
9360 runner.state.status,
9361 RunStatus::Ready,
9362 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
9363 );
9364 }
9365
9366 #[tokio::test]
9377 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
9378 crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9379 let home = crate::run::home();
9380
9381 let tmp = tempfile::tempdir().expect("tempdir");
9382 let repo = tmp.path().join("repo");
9383 std::fs::create_dir_all(&repo).unwrap();
9384 init_repo(&repo);
9385 let cache_dir = tmp.path().join("target");
9388
9389 let mut config = Config::default();
9390 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
9391 config.graph.timeout_verify = Some(2);
9394
9395 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
9396 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
9397 .expect("no io error acquiring directly")
9398 {
9399 crate::cache::AcquireOutcome::Acquired(g) => g,
9400 crate::cache::AcquireOutcome::Busy(b) => {
9401 panic!("expected the direct acquire to win the lease first: {b:?}")
9402 }
9403 };
9404
9405 let mut state = RunState::new(
9406 repo.clone(),
9407 "main".to_owned(),
9408 "deadbeef".to_owned(),
9409 "task".to_owned(),
9410 config,
9411 );
9412 state.candidates = vec![Candidate {
9413 index: 0,
9414 label: 'A',
9415 agent: "alpha".to_owned(),
9416 branch: "does-not-exist".to_owned(),
9417 worktree: repo.clone(),
9418 summary: String::new(),
9419 stat: String::new(),
9420 files: 0,
9421 commits: 0,
9422 empty: false,
9423 failed: None,
9424 verified_noop: None,
9425 duration_ms: 0,
9426 folded: false,
9427 }];
9428 state.tally = Some(Tally {
9429 first_choice: BTreeMap::from([('A', 1)]),
9430 borda: BTreeMap::new(),
9431 winner: 'A',
9432 rankings: 1,
9433 unanimous_initial: true,
9434 deliberated: false,
9435 changed_votes: 0,
9436 unanimous_final: true,
9437 tie_break: None,
9438 judges: 0,
9439 present: 0,
9440 quorum: 0,
9441 met_quorum: true,
9442 uncontested: Some("only candidate A produced a change".to_owned()),
9443 });
9444 state.reviews = vec![ReviewRound {
9445 round: 1,
9446 head: "deadbeef".to_owned(),
9447 verified_head: None,
9448 verified_at: None,
9449 reviews: Vec::new(),
9450 e2e: Vec::new(),
9451 fix: None,
9452 blocking: 0,
9453 answered: 0,
9454 expected: 0,
9455 clean: true,
9456 verify_retried: false,
9457 e2e_deferred: false,
9458 e2e_defer_reason: None,
9459 progressed: false,
9460 vote_split: false,
9461 reconsideration: Vec::new(),
9462 verdict: None,
9463 }];
9464
9465 let mut runner = Runner {
9466 state,
9467 roles: ResolvedRoles {
9468 implementers: Vec::new(),
9469 judges: Vec::new(),
9470 reviewers: Vec::new(),
9471 fixer: None,
9472 conductor: conductor(),
9473 implementer_roster: Vec::new(),
9474 judge_roster: Vec::new(),
9475 reviewer_roster: Vec::new(),
9476 },
9477 sem: Arc::new(Semaphore::new(1)),
9478 pause: Pause::new(),
9479 interrupt: Pause::new(),
9480 };
9481
9482 let started = std::time::Instant::now();
9483 runner.gate().await.expect("gate");
9484 assert!(
9485 started.elapsed() < Duration::from_secs(1),
9486 "a gate with nothing to run must never wait on a lease it never needed"
9487 );
9488 assert!(
9489 runner.state.gate_ran,
9490 "zero commands is still a real, immediate attempt"
9491 );
9492 assert!(runner.state.gate.is_empty());
9493 assert_ne!(
9494 runner.state.status,
9495 RunStatus::Blocked,
9496 "must not read as resource-blocked on a lease it never asked for"
9497 );
9498 }
9499
9500 #[tokio::test]
9510 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
9511 crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9512
9513 let tmp = tempfile::tempdir().expect("tempdir");
9514 let repo = tmp.path().join("repo");
9515 std::fs::create_dir_all(&repo).unwrap();
9516 init_repo(&repo);
9517
9518 let mut config = Config::default();
9519 config.verify.gate = vec![
9520 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
9521 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
9522 .to_owned(),
9523 ];
9524
9525 let mut state = RunState::new(
9526 repo.clone(),
9527 "main".to_owned(),
9528 "deadbeef".to_owned(),
9529 "task".to_owned(),
9530 config,
9531 );
9532 let run_id = state.id.clone();
9533 state.candidates = vec![Candidate {
9534 index: 0,
9535 label: 'A',
9536 agent: "alpha".to_owned(),
9537 branch: "does-not-exist".to_owned(),
9538 worktree: repo.clone(),
9539 summary: String::new(),
9540 stat: String::new(),
9541 files: 0,
9542 commits: 0,
9543 empty: false,
9544 failed: None,
9545 verified_noop: None,
9546 duration_ms: 0,
9547 folded: false,
9548 }];
9549 state.tally = Some(Tally {
9550 first_choice: BTreeMap::from([('A', 1)]),
9551 borda: BTreeMap::new(),
9552 winner: 'A',
9553 rankings: 1,
9554 unanimous_initial: true,
9555 deliberated: false,
9556 changed_votes: 0,
9557 unanimous_final: true,
9558 tie_break: None,
9559 judges: 0,
9560 present: 0,
9561 quorum: 0,
9562 met_quorum: true,
9563 uncontested: Some("only candidate A produced a change".to_owned()),
9564 });
9565 state.reviews = vec![ReviewRound {
9566 round: 1,
9567 head: "deadbeef".to_owned(),
9568 verified_head: None,
9569 verified_at: None,
9570 reviews: Vec::new(),
9571 e2e: Vec::new(),
9572 fix: None,
9573 blocking: 0,
9574 answered: 0,
9575 expected: 0,
9576 clean: true,
9577 verify_retried: false,
9578 e2e_deferred: false,
9579 e2e_defer_reason: None,
9580 progressed: false,
9581 vote_split: false,
9582 reconsideration: Vec::new(),
9583 verdict: None,
9584 }];
9585
9586 let mut runner = Runner {
9587 state,
9588 roles: ResolvedRoles {
9589 implementers: Vec::new(),
9590 judges: Vec::new(),
9591 reviewers: Vec::new(),
9592 fixer: None,
9593 conductor: conductor(),
9594 implementer_roster: Vec::new(),
9595 judge_roster: Vec::new(),
9596 reviewer_roster: Vec::new(),
9597 },
9598 sem: Arc::new(Semaphore::new(1)),
9599 pause: Pause::new(),
9600 interrupt: Pause::new(),
9601 };
9602
9603 let started_marker = repo.join("started.marker");
9604 let release_marker = repo.join("release.marker");
9605 let poller = tokio::spawn(async move {
9606 for _ in 0..100 {
9611 if started_marker.exists()
9612 && let Ok(s) = crate::run::RunState::load(&run_id)
9613 && let Some(a) = s.active.get("gate")
9614 {
9615 std::fs::write(&release_marker, b"go").expect("release marker");
9616 return Some(a.clone());
9617 }
9618 tokio::time::sleep(Duration::from_millis(50)).await;
9619 }
9620 None
9621 });
9622
9623 runner.gate().await.expect("gate");
9624 let captured = poller.await.expect("poller task");
9625 let captured = captured.expect(
9626 "the poller never saw a `gate` task entry in run.json while the command was \
9627 still blocked on its own release marker",
9628 );
9629
9630 assert_eq!(captured.task.as_deref(), Some("gate"));
9631 assert_eq!(captured.node, "gate");
9632 assert_eq!(captured.index, Some(1));
9633 assert_eq!(captured.total, Some(1));
9634 assert!(
9635 captured
9636 .command
9637 .as_deref()
9638 .is_some_and(|c| c.contains("started.marker")),
9639 "{captured:?}"
9640 );
9641
9642 assert!(
9643 runner.state.active.is_empty(),
9644 "the entry must be cleared once the command actually finished: {:?}",
9645 runner.state.active
9646 );
9647 assert!(runner.state.gate_ran);
9648 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
9649 }
9650
9651 #[tokio::test]
9654 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
9655 use crate::verdict::{Finding, ReviewVote, Severity};
9656 crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9657 let tmp = tempfile::tempdir().expect("tempdir");
9658 let repo = tmp.path().join("repo");
9659 std::fs::create_dir_all(&repo).unwrap();
9660 init_repo(&repo);
9661
9662 for (severity, vote, expect) in [
9663 (Severity::Major, ReviewVote::Reject, true),
9664 (Severity::Minor, ReviewVote::Reject, false),
9665 (Severity::Major, ReviewVote::Approve, false),
9666 ] {
9667 let mut round = review_round(false, 1, 1, 1, false, true);
9668 round.reviews = vec![ReviewRecord {
9669 reviewer: 1,
9670 agent: "alpha".to_owned(),
9671 summary: String::new(),
9672 findings: vec![Finding {
9673 id: "R1-1-1".to_owned(),
9674 severity,
9675 file: None,
9676 line: None,
9677 title: "t".to_owned(),
9678 detail: String::new(),
9679 }],
9680 vote: Some(vote),
9681 failed: None,
9682 duration_ms: 0,
9683 attempts: 0,
9684 }];
9685 let mut state = RunState::new(
9686 repo.clone(),
9687 "main".to_owned(),
9688 "deadbeef".to_owned(),
9689 "task".to_owned(),
9690 Config::default(),
9691 );
9692 state.reviews = vec![round];
9693 let mut runner = Runner {
9694 state,
9695 roles: ResolvedRoles {
9696 implementers: Vec::new(),
9697 judges: Vec::new(),
9698 reviewers: Vec::new(),
9699 fixer: None,
9700 conductor: conductor(),
9701 implementer_roster: Vec::new(),
9702 judge_roster: Vec::new(),
9703 reviewer_roster: Vec::new(),
9704 },
9705 sem: Arc::new(Semaphore::new(1)),
9706 pause: Pause::new(),
9707 interrupt: Pause::new(),
9708 };
9709 let shell = runner.state.config.shell();
9710 runner
9711 .stop_reviewing("round budget spent", &shell, &repo)
9712 .await
9713 .expect("stop_reviewing");
9714 assert_eq!(runner.state.status, RunStatus::Gating);
9715 assert_eq!(
9716 runner.state.contested_handoff.is_some(),
9717 expect,
9718 "{severity:?} + {vote:?}"
9719 );
9720 }
9721 }
9722
9723 #[tokio::test]
9736 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
9737 crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9738 let home = crate::run::home();
9739
9740 let tmp = tempfile::tempdir().expect("tempdir");
9741 let repo = tmp.path().join("repo");
9742 std::fs::create_dir_all(&repo).unwrap();
9743 init_repo(&repo);
9744 let head = crate::git::rev_parse(&repo, "HEAD")
9745 .await
9746 .expect("rev-parse");
9747 let cache_dir = tmp.path().join("target");
9750
9751 let mut config = Config::default();
9752 config.verify.e2e = vec![format!(
9753 "CARGO_TARGET_DIR='{}' test -f README.md",
9754 cache_dir.display()
9755 )];
9756 config.graph.review_rounds = 1;
9757 config.graph.timeout_verify = Some(2);
9760
9761 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
9762 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
9763 .expect("no io error acquiring directly")
9764 {
9765 crate::cache::AcquireOutcome::Acquired(g) => g,
9766 crate::cache::AcquireOutcome::Busy(b) => {
9767 panic!("expected the direct acquire to win the lease first: {b:?}")
9768 }
9769 };
9770
9771 let mut state = RunState::new(
9772 repo.clone(),
9773 "main".to_owned(),
9774 head.clone(),
9775 "task".to_owned(),
9776 config,
9777 );
9778 state.candidates = vec![Candidate {
9779 index: 0,
9780 label: 'A',
9781 agent: "alpha".to_owned(),
9782 branch: "does-not-exist".to_owned(),
9783 worktree: repo.clone(),
9784 summary: String::new(),
9785 stat: String::new(),
9786 files: 0,
9787 commits: 0,
9788 empty: false,
9789 failed: None,
9790 verified_noop: None,
9791 duration_ms: 0,
9792 folded: false,
9793 }];
9794 state.tally = Some(Tally {
9795 first_choice: BTreeMap::from([('A', 1)]),
9796 borda: BTreeMap::new(),
9797 winner: 'A',
9798 rankings: 1,
9799 unanimous_initial: true,
9800 deliberated: false,
9801 changed_votes: 0,
9802 unanimous_final: true,
9803 tie_break: None,
9804 judges: 0,
9805 present: 0,
9806 quorum: 0,
9807 met_quorum: true,
9808 uncontested: Some("only candidate A produced a change".to_owned()),
9809 });
9810 state.reviews = vec![ReviewRound {
9814 round: 1,
9815 head: head.clone(),
9816 verified_head: None,
9817 verified_at: None,
9818 reviews: Vec::new(),
9819 e2e: Vec::new(),
9820 fix: None,
9821 blocking: 1,
9822 answered: 1,
9823 expected: 1,
9824 clean: false,
9825 verify_retried: false,
9826 e2e_deferred: true,
9827 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
9828 progressed: false,
9829 vote_split: false,
9830 reconsideration: Vec::new(),
9831 verdict: None,
9832 }];
9833
9834 let mut runner = Runner {
9835 state,
9836 roles: ResolvedRoles {
9837 implementers: Vec::new(),
9838 judges: Vec::new(),
9839 reviewers: Vec::new(),
9840 fixer: None,
9841 conductor: conductor(),
9842 implementer_roster: Vec::new(),
9843 judge_roster: Vec::new(),
9844 reviewer_roster: Vec::new(),
9845 },
9846 sem: Arc::new(Semaphore::new(1)),
9847 pause: Pause::new(),
9848 interrupt: Pause::new(),
9849 };
9850
9851 let shell = runner.state.config.shell();
9852 runner
9853 .stop_reviewing("round budget spent", &shell, &repo)
9854 .await
9855 .expect("stop_reviewing");
9856
9857 let last = runner.state.reviews.last().expect("round record");
9858 assert_eq!(
9859 last.e2e_status(),
9860 E2eStatus::ResourceBlocked,
9861 "the shared cache is still held; the attempt must read as blocked, not deferred or \
9862 failed: {last:?}"
9863 );
9864 assert_eq!(
9865 last.verified_head.as_deref(),
9866 Some(head.as_str()),
9867 "which commit this attempt targeted is known even though nothing finished checking \
9868 it"
9869 );
9870 let first_attempt_at = last
9871 .verified_at
9872 .expect("when this attempt ran is known too");
9873 assert_ne!(
9874 runner.state.status,
9875 RunStatus::Blocked,
9876 "contention is evidence about the machine, not the patch — it must not settle the \
9877 run as blocked: {:?}",
9878 runner.state.status
9879 );
9880 assert!(
9881 !runner
9882 .state
9883 .events
9884 .iter()
9885 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
9886 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
9887 runner.state.events
9888 );
9889
9890 runner
9895 .stop_reviewing("round budget spent", &shell, &repo)
9896 .await
9897 .expect("stop_reviewing retry");
9898 assert_eq!(
9899 runner.state.reviews.len(),
9900 1,
9901 "no new round was started: {:?}",
9902 runner.state.reviews
9903 );
9904 let last = runner.state.reviews.last().expect("round record");
9905 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
9906 assert!(
9907 last.verified_at.expect("still known") > first_attempt_at,
9908 "a second reentry must be a fresh attempt, not a stale copy of the first"
9909 );
9910 assert_ne!(runner.state.status, RunStatus::Blocked);
9911
9912 held.release();
9913 }
9914
9915 #[tokio::test]
9927 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
9928 crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9929 let home = crate::run::home();
9930
9931 let tmp = tempfile::tempdir().expect("tempdir");
9932 let repo = tmp.path().join("repo");
9933 std::fs::create_dir_all(&repo).unwrap();
9934 init_repo(&repo);
9935 let head = crate::git::rev_parse(&repo, "HEAD")
9936 .await
9937 .expect("rev-parse");
9938 let cache_dir = tmp.path().join("target");
9939
9940 let mut config = Config::default();
9941 config.verify.e2e = vec![format!(
9942 "CARGO_TARGET_DIR='{}' test -f README.md",
9943 cache_dir.display()
9944 )];
9945 config.graph.review_rounds = 1;
9946 config.graph.timeout_verify = Some(2);
9947
9948 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
9949 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
9950 .expect("no io error acquiring directly")
9951 {
9952 crate::cache::AcquireOutcome::Acquired(g) => g,
9953 crate::cache::AcquireOutcome::Busy(b) => {
9954 panic!("expected the direct acquire to win the lease first: {b:?}")
9955 }
9956 };
9957
9958 let mut state = RunState::new(
9959 repo.clone(),
9960 "main".to_owned(),
9961 head.clone(),
9962 "task".to_owned(),
9963 config,
9964 );
9965 state.candidates = vec![Candidate {
9966 index: 0,
9967 label: 'A',
9968 agent: "alpha".to_owned(),
9969 branch: "does-not-exist".to_owned(),
9970 worktree: repo.clone(),
9971 summary: String::new(),
9972 stat: String::new(),
9973 files: 0,
9974 commits: 0,
9975 empty: false,
9976 failed: None,
9977 verified_noop: None,
9978 duration_ms: 0,
9979 folded: false,
9980 }];
9981 state.tally = Some(Tally {
9982 first_choice: BTreeMap::from([('A', 1)]),
9983 borda: BTreeMap::new(),
9984 winner: 'A',
9985 rankings: 1,
9986 unanimous_initial: true,
9987 deliberated: false,
9988 changed_votes: 0,
9989 unanimous_final: true,
9990 tie_break: None,
9991 judges: 0,
9992 present: 0,
9993 quorum: 0,
9994 met_quorum: true,
9995 uncontested: Some("only candidate A produced a change".to_owned()),
9996 });
9997 state.reviews = vec![ReviewRound {
10001 round: 1,
10002 head: head.clone(),
10003 verified_head: Some(head.clone()),
10004 verified_at: Some(jiff::Timestamp::now()),
10005 reviews: Vec::new(),
10006 e2e: vec![CommandOutcome {
10007 command: format!(
10008 "CARGO_TARGET_DIR='{}' test -f README.md",
10009 cache_dir.display()
10010 ),
10011 code: None,
10012 output_tail: "waiting for the shared build cache".to_owned(),
10013 duration_ms: 0,
10014 resource_blocked: true,
10015 }],
10016 fix: None,
10017 blocking: 1,
10018 answered: 1,
10019 expected: 1,
10020 clean: false,
10021 verify_retried: false,
10022 e2e_deferred: false,
10023 e2e_defer_reason: None,
10024 progressed: false,
10025 vote_split: false,
10026 reconsideration: Vec::new(),
10027 verdict: None,
10028 }];
10029
10030 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
10031 let mut runner = Runner {
10032 state,
10033 roles: ResolvedRoles {
10034 implementers: Vec::new(),
10035 judges: Vec::new(),
10036 reviewers: Vec::new(),
10037 fixer: None,
10038 conductor: conductor(),
10039 implementer_roster: Vec::new(),
10040 judge_roster: Vec::new(),
10041 reviewer_roster: Vec::new(),
10042 },
10043 sem: Arc::new(Semaphore::new(1)),
10044 pause: Pause::new(),
10045 interrupt: Pause::new(),
10046 };
10047
10048 runner.review_loop().await.expect("review_loop");
10053
10054 assert_eq!(
10055 runner.state.reviews.len(),
10056 1,
10057 "no new round was started on top of the unresolved one: {:?}",
10058 runner.state.reviews
10059 );
10060 let last = &runner.state.reviews[0];
10061 assert_eq!(
10062 last.e2e_status(),
10063 E2eStatus::ResourceBlocked,
10064 "still contended: {last:?}"
10065 );
10066 assert!(
10067 last.verified_at.expect("still known") > first_attempt_at,
10068 "review_loop must have actually retried the check, not left it exactly as found"
10069 );
10070 assert_ne!(
10071 runner.state.status,
10072 RunStatus::Blocked,
10073 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
10074 runner.state.status
10075 );
10076
10077 held.release();
10078 }
10079
10080 #[tokio::test]
10081 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
10082 crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
10083 let tmp = tempfile::tempdir().expect("tempdir");
10084 let repo = tmp.path().join("repo");
10085 std::fs::create_dir_all(&repo).unwrap();
10086 init_repo(&repo);
10087
10088 let mut config = Config::default();
10089 config.merge.mode = MergeMode::Pr;
10090 config.graph.land = true;
10091 config.graph.land_approval = false;
10092
10093 let mut state = RunState::new(
10094 repo.clone(),
10095 "main".to_owned(),
10096 "deadbeef".to_owned(),
10097 "task".to_owned(),
10098 config,
10099 );
10100 state.candidates = vec![Candidate {
10101 index: 0,
10102 label: 'A',
10103 agent: "alpha".to_owned(),
10104 branch: "does-not-exist".to_owned(),
10105 worktree: repo.clone(),
10106 summary: String::new(),
10107 stat: String::new(),
10108 files: 0,
10109 commits: 0,
10110 empty: false,
10111 failed: None,
10112 verified_noop: None,
10113 duration_ms: 0,
10114 folded: false,
10115 }];
10116 state.tally = Some(Tally {
10117 first_choice: BTreeMap::from([('A', 1)]),
10118 borda: BTreeMap::new(),
10119 winner: 'A',
10120 rankings: 1,
10121 unanimous_initial: true,
10122 deliberated: false,
10123 changed_votes: 0,
10124 unanimous_final: true,
10125 tie_break: None,
10126 judges: 0,
10127 present: 0,
10128 quorum: 0,
10129 met_quorum: true,
10130 uncontested: Some("only candidate A produced a change".to_owned()),
10131 });
10132 state.reviews = vec![ReviewRound {
10133 round: 1,
10134 head: "deadbeef".to_owned(),
10135 verified_head: None,
10136 verified_at: None,
10137 reviews: Vec::new(),
10138 e2e: Vec::new(),
10139 fix: None,
10140 blocking: 0,
10141 answered: 0,
10142 expected: 0,
10143 clean: true,
10144 verify_retried: false,
10145 e2e_deferred: false,
10146 e2e_defer_reason: None,
10147 progressed: false,
10148 vote_split: false,
10149 reconsideration: Vec::new(),
10150 verdict: None,
10151 }];
10152 state.gate = vec![CommandOutcome {
10153 command: "test".to_owned(),
10154 code: Some(0),
10155 output_tail: String::new(),
10156 duration_ms: 0,
10157 resource_blocked: false,
10158 }];
10159 state.gate_ran = true;
10160 state.status = RunStatus::Landing;
10164 state.merge = Some(MergeOutcome {
10165 mode: MergeMode::Pr,
10166 ok: true,
10167 detail: "https://example.invalid/x/y/pull/1".to_owned(),
10168 empty: false,
10169 });
10170
10171 ask_test_home();
10175 let store = ask::Questions::open();
10176 let q = ask_open_question(&store, &state.id);
10177
10178 let mut runner = Runner {
10179 state,
10180 roles: ResolvedRoles {
10181 implementers: Vec::new(),
10182 judges: Vec::new(),
10183 reviewers: Vec::new(),
10184 fixer: None,
10185 conductor: conductor(),
10186 implementer_roster: Vec::new(),
10187 judge_roster: Vec::new(),
10188 reviewer_roster: Vec::new(),
10189 },
10190 sem: Arc::new(Semaphore::new(1)),
10191 pause: Pause::new(),
10192 interrupt: Pause::new(),
10193 };
10194
10195 runner.execute().await.expect("execute");
10200
10201 assert_eq!(
10202 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10203 Some("https://example.invalid/x/y/pull/1"),
10204 "reentry must not push again or open a second pull request over the \
10205 one `land` is already watching"
10206 );
10207 assert_ne!(
10208 runner.state.status,
10209 RunStatus::Landing,
10210 "land could not actually reach the fake pull request, so it must \
10211 have given up rather than left the run silently parked forever"
10212 );
10213 assert_eq!(runner.state.status, RunStatus::Blocked);
10217 assert!(
10218 store.get(&q.id).unwrap().status.open(),
10219 "Blocked is still alive; settle_questions must have been a no-op here"
10220 );
10221 }
10222
10223 fn state_with_round(round: ReviewRound) -> RunState {
10224 let mut s = RunState::new(
10225 PathBuf::from("/repo"),
10226 "main".to_owned(),
10227 "abc1234".to_owned(),
10228 "add retries".to_owned(),
10229 Config::default(),
10230 );
10231 s.reviews = vec![round];
10232 s
10233 }
10234
10235 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
10236 crate::verdict::Finding {
10237 id: id.to_owned(),
10238 severity,
10239 file: None,
10240 line: None,
10241 title: title.to_owned(),
10242 detail: String::new(),
10243 }
10244 }
10245
10246 #[test]
10247 fn pr_body_names_open_findings_and_declined_ones() {
10248 let round = ReviewRound {
10249 round: 2,
10250 head: "deadbee".to_owned(),
10251 verified_head: None,
10252 verified_at: None,
10253 reviews: vec![ReviewRecord {
10254 attempts: 0,
10255 reviewer: 1,
10256 agent: "alpha".to_owned(),
10257 summary: String::new(),
10258 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
10259 vote: None,
10260 failed: None,
10261 duration_ms: 0,
10262 }],
10263 e2e: vec![CommandOutcome {
10264 command: "cargo test".to_owned(),
10265 code: Some(0),
10266 output_tail: String::new(),
10267 duration_ms: 0,
10268 resource_blocked: false,
10269 }],
10270 verify_retried: false,
10271 e2e_deferred: false,
10272 e2e_defer_reason: None,
10273 fix: Some(FixRecord {
10274 agent: "alpha".to_owned(),
10275 addressed: Vec::new(),
10276 rejected: vec![crate::verdict::Rejection {
10277 id: "R1-1-1".to_owned(),
10278 why: "not reachable from any caller".to_owned(),
10279 }],
10280 notes: String::new(),
10281 committed: true,
10282 failed: None,
10283 duration_ms: 0,
10284 continuation: None,
10285 }),
10286 blocking: 0,
10287 answered: 1,
10288 expected: 1,
10289 clean: false,
10290 progressed: true,
10291 vote_split: false,
10292 reconsideration: Vec::new(),
10293 verdict: None,
10294 };
10295 let state = state_with_round(round);
10296 let body = pr_message(&state, 'A').body;
10297
10298 assert!(body.contains("add retries"), "the task must still be there");
10299 assert!(body.contains("R2-1-1"), "{body}");
10300 assert!(body.contains("unused import"), "{body}");
10301 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
10302 assert!(
10303 body.contains("not reachable from any caller"),
10304 "the reason it was declined: {body}"
10305 );
10306 }
10307
10308 #[test]
10309 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
10310 let round = ReviewRound {
10311 round: 1,
10312 head: "deadbee".to_owned(),
10313 verified_head: None,
10314 verified_at: None,
10315 reviews: vec![ReviewRecord {
10316 attempts: 0,
10317 reviewer: 1,
10318 agent: "alpha".to_owned(),
10319 summary: String::new(),
10320 findings: Vec::new(),
10321 vote: None,
10322 failed: None,
10323 duration_ms: 0,
10324 }],
10325 e2e: Vec::new(),
10326 verify_retried: false,
10327 e2e_deferred: false,
10328 e2e_defer_reason: None,
10329 fix: None,
10330 blocking: 0,
10331 answered: 1,
10332 expected: 1,
10333 clean: true,
10334 progressed: false,
10335 vote_split: false,
10336 reconsideration: Vec::new(),
10337 verdict: None,
10338 };
10339 let state = state_with_round(round);
10340 let body = pr_message(&state, 'A').body;
10341 assert!(!body.contains("Open review findings"), "{body}");
10342 assert!(!body.contains("Declined"), "{body}");
10343 }
10344
10345 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
10346 let mut state = RunState::new(
10347 PathBuf::from("/repo"),
10348 "main".to_owned(),
10349 "abc1234".to_owned(),
10350 instruction.to_owned(),
10351 Config::default(),
10352 );
10353 state.candidates.push(Candidate {
10354 index: 0,
10355 label: 'A',
10356 agent: "alpha".to_owned(),
10357 branch: "magi/x/A".to_owned(),
10358 worktree: PathBuf::from("/wt"),
10359 summary: summary.to_owned(),
10360 stat: String::new(),
10361 files: 1,
10362 commits: 1,
10363 empty: false,
10364 failed: None,
10365 verified_noop: None,
10366 folded: false,
10367 duration_ms: 0,
10368 });
10369 state
10370 }
10371
10372 fn review_state(subjects: &[&str]) -> RunState {
10373 let mut state = state_with_summary(
10374 "Review the work already on branch `magi/x/A`. There is no task statement: what the change claims to do is whatever its commits say.\n\nfirst\nsecond",
10375 "",
10376 );
10377 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
10378 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
10379 state
10380 }
10381
10382 #[test]
10383 fn pr_message_review_single_commit_uses_its_subject() {
10384 let state = review_state(&["feat(nats): per-role user"]);
10385 let m = pr_message(&state, 'A');
10386 assert_eq!(m.title, "feat(nats): per-role user");
10387 assert!(!m.body.contains("Review the work already"), "{}", m.body);
10388 assert!(m.body.contains("## Commits under review"), "{}", m.body);
10389 }
10390
10391 #[test]
10392 fn pr_message_review_multi_commit_takes_the_oldest() {
10393 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
10394 let m = pr_message(&state, 'A');
10395 assert_eq!(m.title, "feat: the change");
10396 for s in ["feat: the change", "fix: typo", "fix: again"] {
10397 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
10398 }
10399 }
10400
10401 #[test]
10402 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
10403 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
10404 let mut state = review_state(&[first, "fix: later fixup"]);
10405 state.candidates[0].branch = "機能/ログイン".to_owned();
10406 let m = pr_message(&state, 'A');
10407 assert!(
10408 m.title.starts_with("chore: land candidate A of run"),
10409 "{}",
10410 m.title
10411 );
10412 }
10413 }
10414
10415 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
10416 BranchFacts {
10417 commits: commits
10418 .iter()
10419 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
10420 .collect(),
10421 stat: stat.to_owned(),
10422 }
10423 }
10424
10425 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
10426 let mut r = review_round(true, 0, 1, 1, true, true);
10427 r.round = round;
10428 r.fix = notes.map(|n| FixRecord {
10429 agent: "fixer".to_owned(),
10430 addressed: Vec::new(),
10431 rejected: Vec::new(),
10432 notes: n.to_owned(),
10433 committed: true,
10434 failed: None,
10435 duration_ms: 0,
10436 continuation: None,
10437 });
10438 r
10439 }
10440
10441 #[test]
10442 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
10443 let state = review_state(&["ignored"]);
10444 let f = facts(
10445 &[
10446 (
10447 "fix(login): resolve PATH on macOS",
10448 "Login shells skip rc files.",
10449 ),
10450 ("fix: address review", ""),
10451 ],
10452 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
10453 );
10454 let m = pr_message_with(&state, 'A', Some(&f));
10455 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
10456 assert!(
10457 m.body
10458 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
10459 "{}",
10460 m.body
10461 );
10462 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
10463 assert!(m.body.contains("## Diff stat"), "{}", m.body);
10464 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
10465 for banned in [
10466 "Review the work already",
10467 "no task statement",
10468 "Original task",
10469 ] {
10470 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
10471 }
10472 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
10473 }
10474
10475 #[test]
10476 fn pr_message_review_truncates_a_huge_first_commit_body() {
10477 let state = review_state(&["ignored"]);
10478 let f = facts(
10479 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
10480 "s",
10481 );
10482 let m = pr_message_with(&state, 'A', Some(&f));
10483 assert!(m.body.len() < 30_000, "{}", m.body.len());
10484 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
10485 assert!(
10486 m.body.contains("- ... 1 more commit(s)"),
10487 "{}",
10488 m.body.len()
10489 );
10490 assert!(m.body.ends_with("magi:candidate-a\n"));
10491 }
10492
10493 #[test]
10494 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
10495 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
10496 assert_eq!(m.title, "feat: x");
10497 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
10498 assert!(!m.body.contains("Diff stat"), "{}", m.body);
10499 assert!(!m.body.contains("no task statement"), "{}", m.body);
10500 }
10501
10502 #[test]
10503 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
10504 let mut state = review_state(&["日本語の件名"]);
10505 state.candidates[0].branch = "fix/macos-login-path".to_owned();
10506 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
10507 state.candidates[0].branch = "機能/ログイン".to_owned();
10508 assert!(
10509 pr_message(&state, 'A')
10510 .title
10511 .starts_with("chore: land candidate A")
10512 );
10513 }
10514
10515 #[test]
10516 fn pr_message_review_fixes_survive_a_clean_final_round() {
10517 let mut state = review_state(&["feat: x"]);
10518 state.reviews = vec![
10519 round_with_notes(1, Some("handled the PATH case")),
10520 round_with_notes(2, None),
10521 ];
10522 let body = pr_message(&state, 'A').body;
10523 assert!(
10524 body.contains("## Review fixes\n\nhandled the PATH case\n"),
10525 "{body}"
10526 );
10527 assert!(!body.contains("### Round"), "{body}");
10528
10529 state.reviews = vec![
10530 round_with_notes(1, Some("first fix")),
10531 round_with_notes(2, Some("")),
10532 round_with_notes(3, Some("second fix")),
10533 round_with_notes(4, None),
10534 ];
10535 let body = pr_message(&state, 'A').body;
10536 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
10537 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
10538 assert!(!body.contains("### Round 2"), "{body}");
10539 }
10540
10541 #[test]
10542 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
10543 let state = state_with_summary(
10544 "add retries to the client",
10545 "TITLE: feat: retries\n\nDid it.",
10546 );
10547 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
10548 assert_eq!(m.title, "feat: retries");
10549 assert!(
10550 m.body.contains("<summary>Original task</summary>"),
10551 "{}",
10552 m.body
10553 );
10554 assert!(!m.body.contains("Commits under review"), "{}", m.body);
10555 assert!(
10556 m.body
10557 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
10558 "{}",
10559 m.body
10560 );
10561 }
10562
10563 #[test]
10564 fn pr_message_review_bounds_a_long_english_subject() {
10565 let long = format!("feat: {}", "word ".repeat(100));
10566 let m = pr_message(&review_state(&[&long]), 'A');
10567 assert!(m.title.starts_with("feat: word"), "{}", m.title);
10568 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
10569 }
10570
10571 #[test]
10572 fn pr_message_implementation_run_is_unchanged_by_review_support() {
10573 let state = state_with_summary("add retries\n\ndetails", "- did some things");
10574 let m = pr_message(&state, 'A');
10575 assert_eq!(m.title, "add retries");
10576 assert!(m.body.contains("<summary>Original task</summary>"));
10577 assert!(!m.body.contains("Commits under review"));
10578 assert_eq!(landing_subject_source(&state), state.instruction);
10579 }
10580
10581 #[test]
10582 fn review_run_squash_subject_is_the_change_not_the_prompt() {
10583 let state = review_state(&["feat: the change", "fix: typo"]);
10584 let source = landing_subject_source(&state);
10585 assert_eq!(land::merge_subject("", &source), "feat: the change");
10586 assert_eq!(
10587 land::merge_subject("magi: candidate A (uncommitted work)", &source),
10588 "feat: the change"
10589 );
10590 assert_eq!(
10592 land::merge_subject("feat: renamed by hand", &source),
10593 "feat: renamed by hand"
10594 );
10595 let blank = review_state(&["日本語"]);
10596 assert!(
10597 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
10598 );
10599 }
10600
10601 #[test]
10602 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
10603 let state = review_state(&["feat: the change"]);
10604 let source = landing_subject_source(&state);
10605 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
10606 assert_eq!(
10607 land::merge_subject(landing_title(&state, old), &source),
10608 "feat: the change"
10609 );
10610 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
10611 let task = state_with_summary("add retries", "");
10612 assert_eq!(landing_title(&task, old), old);
10613 }
10614
10615 #[test]
10616 fn pr_message_describes_the_change_not_the_task() {
10617 let state = state_with_summary(
10618 "今回やってほしいこと: results projector を直す",
10619 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
10620 );
10621 let m = pr_message(&state, 'A');
10622 assert_eq!(m.title, "fix(web): batch the runs list reads");
10623 assert!(
10624 m.body.starts_with("## Summary\n\n- reads run.json once"),
10625 "{}",
10626 m.body
10627 );
10628 assert!(!m.body.contains("TITLE:"), "{}", m.body);
10629 let task_at = m.body.find("今回やってほしいこと").unwrap();
10630 let details_at = m.body.find("<details>").unwrap();
10631 assert!(
10632 details_at < task_at,
10633 "the task lives inside <details>: {}",
10634 m.body
10635 );
10636 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
10637 assert!(m.body.contains("magi:candidate-a"));
10638 }
10639
10640 #[test]
10641 fn pr_message_falls_back_to_the_task_without_a_title_line() {
10642 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
10643 let m = pr_message(&state, 'A');
10644 assert_eq!(m.title, "add retries");
10645 assert!(
10646 m.body.contains("## Summary\n\n- did some things"),
10647 "{}",
10648 m.body
10649 );
10650
10651 let none = RunState::new(
10652 PathBuf::from("/repo"),
10653 "main".to_owned(),
10654 "abc1234".to_owned(),
10655 "add retries".to_owned(),
10656 Config::default(),
10657 );
10658 let m = pr_message(&none, 'A');
10659 assert_eq!(m.title, "add retries");
10660 assert!(!m.body.contains("## Summary"), "{}", m.body);
10661 }
10662
10663 #[test]
10664 fn pr_message_refuses_the_candidate_commit_subject() {
10665 for bad in [
10666 "TITLE: magi: candidate A (uncommitted work)",
10667 "TITLE: chore: stuff (uncommitted work)",
10668 "TITLE: ",
10669 ] {
10670 let state = state_with_summary("add retries", bad);
10671 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
10672 }
10673 }
10674
10675 #[test]
10676 fn pr_message_bounds_a_very_long_task_and_title() {
10677 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
10678 let state = state_with_summary(&long, "- nothing");
10679 let m = pr_message(&state, 'A');
10680 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
10681 assert!(!m.title.contains('\n'));
10682
10683 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
10684 let m = pr_message(&state, 'A');
10685 assert!(m.title.starts_with("feat: "));
10686 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
10687 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
10688 }
10689
10690 #[test]
10691 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
10692 let mut state = state_with_summary(
10696 "add retries",
10697 "TITLE: fix(web): batch reads\n- reads run.json once",
10698 );
10699 state.config.graph.language = "ja".to_owned();
10700 let m = pr_message(&state, 'A');
10701 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
10702
10703 let task = "今回やってほしいこと: results projector を直す";
10706 let mut state = state_with_summary(task, "- no title line");
10707 state.config.graph.language = "ja".to_owned();
10708 let m = pr_message(&state, 'A');
10709 assert_eq!(
10710 m.title,
10711 format!("chore: land candidate A of run {}", state.id)
10712 );
10713 assert!(
10714 m.body.contains(&format!(
10715 "<summary>Original task</summary>\n\n{task}\n\n</details>"
10716 )),
10717 "{}",
10718 m.body
10719 );
10720 }
10721
10722 #[test]
10723 fn pr_message_scrubs_home_paths_and_addresses() {
10724 let state = state_with_summary(
10725 "fix it in /Users/someone/src/x",
10726 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
10727 );
10728 let m = pr_message(&state, 'A');
10729 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
10730 assert!(!m.body.contains(leak), "{}", m.body);
10731 }
10732 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
10733 }
10734
10735 #[test]
10736 fn pr_message_survives_a_task_that_closes_details() {
10737 let state = state_with_summary("a </details> b", "TITLE: fix: x");
10738 let m = pr_message(&state, 'A');
10739 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
10740 }
10741
10742 #[test]
10743 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
10744 let cmd = manual_merge_command(
10745 MergeStyle::Squash,
10746 Path::new("/repo"),
10747 "b",
10748 "fix: \"quoted\" $(x) `y`\n\nbody",
10749 );
10750 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
10751 }
10752
10753 #[test]
10754 fn manual_merge_command_matches_the_configured_style() {
10755 let repo = Path::new("/repo");
10756 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
10757
10758 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
10759 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
10760
10761 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
10762 assert_eq!(
10763 squash,
10764 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
10765 \"Merge magi run 0832 (candidate A)\""
10766 );
10767
10768 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
10769 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
10770 }
10771
10772 #[test]
10773 fn a_nudge_gets_a_quarter_of_the_budget() {
10774 assert_eq!(retry_budget(secs(1200), true), secs(300));
10776 assert_eq!(retry_budget(secs(3600), true), secs(900));
10777 }
10778
10779 #[test]
10780 fn a_resent_prompt_keeps_the_whole_budget() {
10781 assert_eq!(retry_budget(secs(1200), false), secs(1200));
10784 assert_eq!(retry_budget(secs(60), false), secs(60));
10785 }
10786
10787 #[test]
10788 fn the_floor_never_exceeds_the_original_budget() {
10789 assert_eq!(retry_budget(secs(60), true), secs(60));
10793 assert_eq!(retry_budget(secs(480), true), secs(120));
10794 assert_eq!(retry_budget(secs(0), true), secs(0));
10795 }
10796
10797 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
10798 agent::CommandEvidence {
10799 id: "item1".to_owned(),
10800 description: "cargo test".to_owned(),
10801 exit_code,
10802 result_summary: String::new(),
10803 source: "codex".to_owned(),
10804 }
10805 }
10806
10807 #[test]
10808 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
10809 assert!(!has_unconfirmed_command(&[]));
10813 }
10814
10815 #[test]
10816 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
10817 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
10821 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
10822 assert!(!has_unconfirmed_command(&[
10823 evidence(Some(0)),
10824 evidence(Some(101))
10825 ]));
10826 }
10827
10828 #[test]
10829 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
10830 assert!(has_unconfirmed_command(&[
10831 evidence(Some(0)),
10832 evidence(None)
10833 ]));
10834 }
10835
10836 #[test]
10837 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
10838 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
10839 assert_eq!(
10840 verified_noop_claim(true, &[], text).as_deref(),
10841 Some("already fixed by b32cfc4, on main.")
10842 );
10843 }
10844
10845 #[test]
10846 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
10847 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
10850 assert!(verified_noop_claim(false, &[], text).is_none());
10851 }
10852
10853 #[test]
10854 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
10855 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
10856 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
10857 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
10859 }
10860
10861 #[test]
10862 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
10863 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
10864 }
10865
10866 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
10869 runner.state.candidates = shape
10870 .iter()
10871 .enumerate()
10872 .map(|(i, &(empty, verified))| Candidate {
10873 index: i,
10874 label: (b'A' + i as u8) as char,
10875 agent: "sonnet".to_owned(),
10876 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
10877 worktree: PathBuf::from(format!("/wt/{i}")),
10878 summary: String::new(),
10879 stat: String::new(),
10880 files: 0,
10881 commits: 0,
10882 empty,
10883 failed: None,
10884 verified_noop: verified.map(str::to_owned),
10885 duration_ms: 0,
10886 folded: false,
10887 })
10888 .collect();
10889 }
10890
10891 #[test]
10892 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
10893 ask_test_home();
10894 let mut runner = runner_at(RunStatus::Implementing);
10895 set_candidates(
10896 &mut runner,
10897 &[
10898 (true, Some("already on main at b32cfc4")),
10899 (true, Some("same fix, see the existing test")),
10900 ],
10901 );
10902
10903 runner
10904 .after_implement()
10905 .expect("a verified no-op is not an error");
10906
10907 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
10908 }
10909
10910 #[test]
10911 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
10912 ask_test_home();
10913 let mut runner = runner_at(RunStatus::Implementing);
10914 set_candidates(
10918 &mut runner,
10919 &[(true, Some("already on main at b32cfc4")), (true, None)],
10920 );
10921
10922 let err = runner
10923 .after_implement()
10924 .expect_err("an unverified empty candidate must still fail the run");
10925
10926 assert!(
10927 err.to_string().contains("no candidate produced a change"),
10928 "{err}"
10929 );
10930 assert_eq!(runner.state.status, RunStatus::Failed);
10931 }
10932
10933 #[test]
10934 fn after_implement_still_fails_an_ordinary_all_empty_run() {
10935 ask_test_home();
10936 let mut runner = runner_at(RunStatus::Implementing);
10937 set_candidates(&mut runner, &[(true, None), (true, None)]);
10938
10939 let err = runner
10940 .after_implement()
10941 .expect_err("no candidate declared anything; this is an ordinary failure");
10942
10943 assert!(
10944 err.to_string().contains("no candidate produced a change"),
10945 "{err}"
10946 );
10947 assert_eq!(runner.state.status, RunStatus::Failed);
10948 }
10949}