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::fixer;
39use crate::git;
40use crate::land;
41use crate::proc::Quiet as _;
42use crate::prompt::{
43 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
44};
45use crate::queue;
46use crate::refs;
47use crate::run::{
48 BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
49 DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord, Handover,
50 JobRecord, JobStatus, Judgement, MergeOutcome, OperatorFixFinding, OperatorFixOutcome,
51 OperatorFixRequest, Origin, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState,
52 RunStatus, SeatHistory, Tally, VoteRecord, tail, write_artifact,
53};
54use crate::verdict::{
55 self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
56 ReviewVote, Severity,
57};
58use crate::worktree_setup;
59
60const OUTPUT_TAIL: usize = 8_000;
62
63const EVENT_OUTPUT_TAIL: usize = 2_000;
66
67const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
70
71const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
88
89pub(crate) const STAGNANT_LIMIT: usize = 2;
103
104const BASE_SYNC_ROUNDS: usize = 4;
117
118const MAX_FIX_CONTINUATIONS: usize = 2;
134
135#[derive(Clone)]
141struct SeatJob {
142 spec: AgentSpec,
143 seat: SeatState,
144 cwd: PathBuf,
145 prompt: String,
146 timeout: Duration,
147 allow_write: bool,
148 sessions: bool,
149 artifacts: PathBuf,
150 stem: String,
151 handover: Option<String>,
156}
157
158enum AgentOutcome {
170 Ok(AgentOutput),
172 Quota(AgentOutput),
174 Dropped(AgentOutput),
177 Failed(String),
179}
180
181#[derive(Debug, Clone, Default)]
208pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
209
210impl Pause {
211 #[must_use]
213 pub fn new() -> Self {
214 Self::default()
215 }
216
217 pub fn park(&self) {
219 self.0.store(true, Ordering::SeqCst);
220 }
221
222 pub fn park_because(&self, reason: impl Into<String>) {
228 let mut reason_guard = self
229 .1
230 .lock()
231 .unwrap_or_else(std::sync::PoisonError::into_inner);
232 if reason_guard.is_none() {
233 *reason_guard = Some(reason.into());
234 }
235 drop(reason_guard);
236 self.park();
237 }
238
239 #[must_use]
241 pub fn parked(&self) -> bool {
242 self.0.load(Ordering::SeqCst)
243 }
244
245 #[must_use]
247 pub fn reason(&self) -> Option<String> {
248 self.1
249 .lock()
250 .unwrap_or_else(std::sync::PoisonError::into_inner)
251 .clone()
252 }
253}
254
255pub struct Runner {
257 pub state: RunState,
259 roles: ResolvedRoles,
260 sem: Arc<Semaphore>,
261 pause: Pause,
265 interrupt: Pause,
271}
272
273async fn review_base(
278 repo: &Path,
279 remote: &str,
280 base_branch: &str,
281 base_commit: &str,
282 branch: &str,
283) -> String {
284 review_base_checked(repo, remote, base_branch, base_commit, branch)
285 .await
286 .0
287}
288
289async fn review_base_checked(
294 repo: &Path,
295 remote: &str,
296 base_branch: &str,
297 base_commit: &str,
298 branch: &str,
299) -> (String, bool) {
300 let tracking = format!("{remote}/{base_branch}");
301 let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
302 if git::rev_exists(repo, &tracking).await
303 && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
304 && !mb.is_empty()
305 {
306 return (mb, fresh);
307 }
308 let mb = git::merge_base(repo, base_commit, branch)
309 .await
310 .ok()
311 .filter(|mb| !mb.is_empty())
312 .unwrap_or_else(|| base_commit.to_owned());
313 (mb, false)
314}
315
316pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
320 if !is_review_run(state) {
321 return;
322 }
323 let base = review_base(
324 &state.repo,
325 &state.config.merge.remote,
326 &state.base_branch,
327 &state.base_commit,
328 branch,
329 )
330 .await;
331 if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
332 && !subjects.is_empty()
333 && state.reviewed_commits.as_ref() != Some(&subjects)
334 {
335 state.reviewed_commits = Some(subjects);
336 state.save().ok();
337 }
338}
339
340async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
344 let (base, trusted) = review_base_checked(
345 &state.repo,
346 &state.config.merge.remote,
347 &state.base_branch,
348 &state.base_commit,
349 branch,
350 )
351 .await;
352 if !trusted {
353 return None;
354 }
355 git::subjects(&state.repo, &state.base_commit, &base)
356 .await
357 .ok()
358}
359
360fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
364 let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
365 if is_leak(computed) {
366 return false;
367 }
368 let cur = current.trim();
369 cur.is_empty()
370 || cur.starts_with(REVIEW_PROMPT_OPENING)
371 || cur.starts_with("chore: land candidate")
372 || cur.starts_with("magi: candidate")
373 || is_leak(cur)
374}
375
376async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
405 let tracking = format!("{remote}/{branch}");
406 let fetched = git::fetch(repo, remote, branch).await;
407 let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
408 let local_exists = git::branch_exists(repo, branch).await?;
409 if !fresh {
410 if !local_exists {
411 bail!("no branch `{branch}` in {} or on {remote}", repo.display());
412 }
413 tracing::warn!(
414 "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
415 );
416 return Ok(());
417 }
418 let remote_sha = git::rev_parse(repo, &tracking).await?;
419 if !local_exists {
420 git::git(repo, &["branch", branch, &tracking]).await?;
421 return Ok(());
422 }
423 let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
424 if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
425 return Ok(());
426 }
427 if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
428 match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
434 .await?
435 {
436 crate::reconcile::Reconciliation::Pushed => {
437 tracing::warn!(
438 "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
439 short(&local_sha),
440 short(&remote_sha)
441 );
442 return Ok(());
443 }
444 crate::reconcile::Reconciliation::Placeholder => {}
445 crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
446 }
447 }
448 let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
449 if !out.ok() {
450 bail!(
451 "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
452 short(&local_sha),
453 short(&remote_sha),
454 out.stderr
455 );
456 }
457 tracing::warn!(
458 "local `{branch}` was stale: fast-forwarded {} -> {}",
459 short(&local_sha),
460 short(&remote_sha)
461 );
462 Ok(())
463}
464
465async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
466 let tracking = format!("{remote}/{base_branch}");
467 let fetched = git::fetch(repo, remote, base_branch).await;
468 if let Ok(out) = &fetched
469 && out.ok()
470 && git::rev_exists(repo, &tracking).await
471 {
472 return git::rev_parse(repo, &tracking).await;
473 }
474 let why = match &fetched {
475 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
476 Ok(_) => format!("{remote} has no {base_branch}"),
477 Err(e) => e.to_string(),
478 };
479 bail!(
482 "cannot read {tracking} ({why}); refusing to branch off the local \
483 `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
484 base / remote in magi.toml"
485 )
486}
487
488struct FixClaim {
505 path: PathBuf,
506}
507
508impl FixClaim {
509 fn acquire(dir: &Path) -> Result<Self> {
510 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
511 let path = dir.join("fix.lock");
512 match Self::create(&path) {
513 Ok(claim) => Ok(claim),
514 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
515 if Self::reclaim_if_dead(&path) {
516 Self::create(&path).with_context(|| format!("lock {}", path.display()))
517 } else {
518 bail!(
519 "another `magi fix` is already running for this run ({} exists)",
520 path.display()
521 )
522 }
523 }
524 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
525 }
526 }
527
528 fn create(path: &Path) -> std::io::Result<Self> {
529 let mut f = std::fs::OpenOptions::new()
530 .write(true)
531 .create_new(true)
532 .open(path)?;
533 use std::io::Write as _;
534 writeln!(f, "{}", std::process::id())?;
536 Ok(Self {
537 path: path.to_owned(),
538 })
539 }
540
541 fn reclaim_if_dead(path: &Path) -> bool {
545 let dead = std::fs::read_to_string(path)
546 .ok()
547 .and_then(|body| body.trim().parse::<u32>().ok())
548 .is_some_and(|pid| !crate::proc::pid_alive(pid));
549 dead && std::fs::remove_file(path).is_ok()
550 }
551}
552
553impl Drop for FixClaim {
554 fn drop(&mut self) {
555 let _ = std::fs::remove_file(&self.path);
556 }
557}
558
559impl Runner {
560 pub async fn start(
562 repo: &Path,
563 instruction: String,
564 config: Config,
565 origin: Origin,
566 ) -> Result<Self> {
567 Self::start_naming(repo, instruction, "", config, origin).await
568 }
569
570 pub async fn start_naming(
574 repo: &Path,
575 instruction: String,
576 also_scan: &str,
577 config: Config,
578 origin: Origin,
579 ) -> Result<Self> {
580 let repo = git::toplevel(repo).await?;
581 let missing = agent::missing_programs(&config.agents);
582 if !missing.is_empty() {
583 bail!(
584 "these agent programs are not on PATH: {}. Fix the roster in \
585 magi.toml or install them.",
586 missing.join(", ")
587 );
588 }
589 let base_branch =
590 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
591 .await?;
592 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
593 let roles = config.resolve_roles()?;
594 let max_parallel = config.graph.max_parallel.max(1);
595 let seeds = refs::resolve(
598 &repo,
599 &base_commit,
600 &config.merge.remote,
601 &format!("{also_scan}\n{instruction}"),
602 )
603 .await;
604 refs::plan(&repo, &seeds).await?;
605 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
606 state.origin = Some(origin);
610 for seed in &seeds {
611 state.event(
612 "seed",
613 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
614 );
615 }
616 state.seeds = seeds;
617 state.event("start", format!("run {} created", state.id));
618 state.save()?;
619 Ok(Self {
620 state,
621 roles,
622 sem: Arc::new(Semaphore::new(max_parallel)),
623 pause: Pause::new(),
624 interrupt: Pause::new(),
625 })
626 }
627
628 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
642 Self::review_taking_over(repo, branch, config, None, origin).await
643 }
644
645 pub async fn review_taking_over(
652 repo: &Path,
653 branch: &str,
654 config: Config,
655 takeover: Option<crate::handover::Takeover>,
656 origin: Origin,
657 ) -> Result<Self> {
658 let repo = git::toplevel(repo).await?;
659 let missing = agent::missing_programs(&config.agents);
660 if !missing.is_empty() {
661 bail!(
662 "these agent programs are not on PATH: {}. Fix the roster in \
663 magi.toml or install them.",
664 missing.join(", ")
665 );
666 }
667 let base_branch =
668 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
669 .await?;
670 if base_branch == branch {
671 bail!("`{branch}` is the base branch; there is nothing to review against");
672 }
673 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
674
675 let roles = config.resolve_roles()?;
676 let max_parallel = config.graph.max_parallel.max(1);
677 let mut state = RunState::new(
678 repo.clone(),
679 base_branch,
680 base_commit.clone(),
681 String::new(),
682 config,
683 );
684 state.origin = Some(origin);
685
686 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
693 earlier: Vec::new(),
694 home: crate::run::home(),
695 choice: None,
696 });
697 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
698 if let Some(released) = &released {
699 state.event(
700 "release",
701 format!(
702 "took `{branch}` over from run {}: its worktree was released: {}",
703 crate::run::short_of(&released.old_id),
704 released.audit
705 ),
706 );
707 }
708 if let Some(choice) = takeover.choice.as_ref()
712 && let Err(e) =
713 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
714 .await
715 {
716 if let Some(released) = &released {
717 released.restore(&repo, branch).await;
718 }
719 return Err(e.context("applying the owner's answer about the diverged branch"));
720 }
721 let opened =
722 Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
723 if opened.is_err()
724 && let Some(released) = &released
725 {
726 released.restore(&repo, branch).await;
727 }
728 opened
729 }
730
731 async fn open_review(
734 repo: &Path,
735 branch: &str,
736 mut state: RunState,
737 roles: ResolvedRoles,
738 max_parallel: usize,
739 base_commit: String,
740 ) -> Result<Self> {
741 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
742 let start = review_base(
745 repo,
746 &state.config.merge.remote,
747 &state.base_branch,
748 &base_commit,
749 branch,
750 )
751 .await;
752 let log = git::log_oneline(repo, &start, branch)
753 .await
754 .unwrap_or_default();
755 let instruction = format!(
756 "Review the work already on branch `{branch}`. There is no task \
757 statement: what the change claims to do is whatever its commits \
758 say.\n\n{}",
759 if log.trim().is_empty() {
760 "(no commit messages)"
761 } else {
762 log.trim()
763 }
764 );
765 state.instruction = instruction;
766 state.reviewed_commits = Some(
767 git::subjects(repo, &start, branch)
768 .await
769 .unwrap_or_default(),
770 );
771
772 let worktree = state.worktree_root().join("under-review");
775 if let Some(parent) = worktree.parent() {
776 tokio::fs::create_dir_all(parent).await.ok();
777 }
778 let path = worktree.to_string_lossy().to_string();
779 git::git(repo, &["worktree", "add", &path, branch])
780 .await
781 .with_context(|| {
782 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
783 })?;
784
785 let setup = match ensure_setup_config(&mut state, repo).await {
786 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
787 Err(e) => Err(e),
788 };
789 if let Err(e) = setup {
790 git::worktree_remove(repo, &worktree).await.ok();
791 return Err(e);
792 }
793 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
794 .await
795 .unwrap_or(0);
796 if commits == 0 {
797 git::worktree_remove(repo, &worktree).await.ok();
798 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
799 }
800 let files = git::changed_files(&worktree, &base_commit, "HEAD")
801 .await
802 .map(|f| f.len())
803 .unwrap_or(0);
804 if files == 0
805 && let (Ok(head_tree), Ok(base_tree)) = (
806 git::tree_of(&worktree, "HEAD").await,
807 git::tree_of(&worktree, &base_commit).await,
808 )
809 && head_tree == base_tree
810 {
811 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
812 git::worktree_remove(repo, &worktree).await.ok();
813 bail!(
814 "`{branch}` at {} has a tree identical to base {}; this usually means \
815 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
816 against `{}/{branch}`) rather than an empty change",
817 short(&head),
818 short(&base_commit),
819 state.config.merge.remote
820 );
821 }
822 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
823 .await
824 .unwrap_or_default();
825
826 state.candidates.push(Candidate {
827 index: 0,
828 label: 'A',
829 agent: EXISTING_BRANCH.to_owned(),
832 branch: branch.to_owned(),
833 worktree,
834 summary: String::new(),
835 stat,
836 files,
837 commits,
838 empty: false,
839 failed: None,
840 verified_noop: None,
841 duration_ms: 0,
842 folded: false,
843 });
844 state.tally = Some(Tally {
845 first_choice: BTreeMap::from([('A', 0)]),
846 borda: BTreeMap::new(),
847 winner: 'A',
848 rankings: 0,
849 unanimous_initial: false,
850 deliberated: false,
851 changed_votes: 0,
852 unanimous_final: false,
853 tie_break: None,
854 judges: 0,
858 present: 0,
859 quorum: 0,
860 met_quorum: true,
861 uncontested: Some("review-only run: nothing competed".to_owned()),
862 });
863 state.status = RunStatus::Reviewing;
864 state.event(
865 "start",
866 format!(
867 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
868 state.id
869 ),
870 );
871 state.save()?;
872 Ok(Self {
873 state,
874 roles,
875 sem: Arc::new(Semaphore::new(max_parallel)),
876 pause: Pause::new(),
877 interrupt: Pause::new(),
878 })
879 }
880
881 pub fn resume(id: &str) -> Result<Self> {
883 let state = RunState::load(id)?;
884 if let Some(to) = &state.released_to {
885 bail!(
886 "run {} cannot be resumed: its worktree was released to run {}",
887 state.short(),
888 crate::run::short_of(to)
889 );
890 }
891 let roles = state.config.resolve_roles()?;
892 let max_parallel = state.config.graph.max_parallel.max(1);
893 Ok(Self {
894 state,
895 roles,
896 sem: Arc::new(Semaphore::new(max_parallel)),
897 pause: Pause::new(),
898 interrupt: Pause::new(),
899 })
900 }
901
902 pub async fn execute(&mut self) -> Result<()> {
909 let result = self.execute_graph().await;
910 self.mark_driver_exited();
911 let ended = if result.is_err() {
912 Some(crate::notices::run_stopped(&self.state.id, &self.state))
913 } else {
914 crate::notices::run_ended(&self.state)
915 };
916 if let Some(notice) = ended {
917 crate::notices::raise(notice);
918 }
919 result
920 }
921
922 fn mark_driver_exited(&mut self) {
931 self.state.driver_exited = true;
932 let pid = std::process::id();
933 let Ok(mut disk) = RunState::load(&self.state.id) else {
934 return;
935 };
936 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
937 return;
938 }
939 disk.driver_exited = true;
940 if let Err(e) = disk.save() {
941 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
942 }
943 }
944
945 async fn execute_graph(&mut self) -> Result<()> {
946 self.state.parked = false;
951 self.state.clear_active();
958 if let Ok(disk) = RunState::load(&self.state.id)
976 && let Some(to) = &disk.released_to
977 {
978 bail!(
979 "run {} cannot continue: its worktree was released to run {}",
980 self.state.short(),
981 crate::run::short_of(to)
982 );
983 }
984 let pid = std::process::id();
985 self.state.driver_pid = Some(pid);
986 self.state.driver_started_at = crate::proc::process_started_at(pid);
987 self.state.driver_exited = false;
988 self.state.save()?;
989 if self.state.status == RunStatus::Stalled {
1002 if self.recover_stall().await? {
1003 self.finish_after_tally().await?;
1004 } else {
1005 self.state.save()?;
1007 }
1008 return Ok(());
1009 }
1010 if self.state.status == RunStatus::Landing {
1020 self.run_land().await?;
1021 self.settle_questions();
1026 return Ok(());
1027 }
1028 self.prep().await?;
1029 if self.park_here()? {
1030 return Ok(());
1031 }
1032 self.advise().await?;
1033 if self.park_here()? {
1034 return Ok(());
1035 }
1036 self.implement().await?;
1037 if self.park_here()? {
1038 return Ok(());
1039 }
1040 if self.state.status == RunStatus::VerifiedNoop {
1044 return Ok(());
1045 }
1046 self.judge().await?;
1047 if self.park_here()? {
1048 return Ok(());
1049 }
1050 self.deliberate().await?;
1051 if self.park_here()? {
1052 return Ok(());
1053 }
1054 self.vote().await?;
1055 if self.park_here()? {
1056 return Ok(());
1057 }
1058 self.tally()?;
1059 if self.state.status == RunStatus::Stalled {
1064 self.state.save()?;
1068 return Ok(());
1069 }
1070 self.finish_after_tally().await?;
1071 Ok(())
1072 }
1073
1074 fn park_here(&mut self) -> Result<bool> {
1081 if !self.pause.parked() && !self.interrupt.parked() {
1086 return Ok(false);
1087 }
1088 let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1089 Some(reason) => format!(
1090 "parked after `{}` ({reason}) — resume to carry on from here",
1091 self.state.status.as_str()
1092 ),
1093 None => format!(
1094 "parked after `{}` — resume to carry on from here",
1095 self.state.status.as_str()
1096 ),
1097 };
1098 self.state.event("park", why);
1099 self.state.parked = true;
1100 self.state.save()?;
1101 Ok(true)
1102 }
1103
1104 pub fn on_pause(&mut self, pause: Pause) {
1106 self.pause = pause;
1107 }
1108
1109 pub fn watch_interrupt(&mut self, pause: Pause) {
1115 self.interrupt = pause;
1116 }
1117
1118 fn settle_questions(&mut self) {
1138 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1139 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1140 }
1141 }
1142
1143 async fn finish_after_tally(&mut self) -> Result<()> {
1146 self.fold_losers().await?;
1147 self.sync_to_base().await?;
1152 if self.state.status == RunStatus::AlreadyInBase {
1153 return Ok(());
1154 }
1155 self.review_loop().await?;
1156 self.sync_to_base().await?;
1157 if self.state.status == RunStatus::AlreadyInBase {
1158 return Ok(());
1159 }
1160 self.gate().await?;
1161 self.merge().await?;
1162 self.state.save()?;
1163 Ok(())
1164 }
1165
1166 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1173 let result = match ensure_setup_config(&mut self.state, repo).await {
1174 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1175 Err(e) => Err(e),
1176 };
1177 if let Err(e) = result {
1178 git::worktree_remove(repo, wt).await.ok();
1179 if in_prep {
1180 self.rollback_prep(repo).await;
1181 }
1182 self.state.status = RunStatus::Blocked;
1183 self.state.event(
1184 "setup",
1185 format!("worktree setup failed in {}: {e:#}", wt.display()),
1186 );
1187 self.state.save()?;
1188 return Err(e);
1189 }
1190 Ok(())
1191 }
1192
1193 async fn rollback_prep(&mut self, repo: &Path) {
1195 let root = self.state.worktree_root();
1196 for c in std::mem::take(&mut self.state.candidates) {
1197 git::worktree_remove(repo, &c.worktree).await.ok();
1198 git::branch_delete(repo, &c.branch).await.ok();
1199 }
1200 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1201 for j in 1..=seats {
1202 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1203 .await
1204 .ok();
1205 }
1206 for k in 1..=self.state.config.graph.advisors {
1207 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1208 .await
1209 .ok();
1210 }
1211 }
1212
1213 async fn prep(&mut self) -> Result<()> {
1214 if !self.state.candidates.is_empty() {
1215 return Ok(());
1216 }
1217 self.state.status = RunStatus::Prep;
1218 let repo = self.state.repo.clone();
1219 let base = self.state.base_commit.clone();
1220 let plan = refs::plan(&repo, &self.state.seeds).await?;
1221 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1222 let root = self.state.worktree_root();
1223 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1224
1225 let hooks_dir = self.state.dir().join("hooks");
1228 if self.state.config.blind.commit_msg_hook {
1229 std::fs::create_dir_all(&hooks_dir)
1230 .with_context(|| format!("create {}", hooks_dir.display()))?;
1231 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1232 let path = hooks_dir.join("commit-msg");
1233 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1234 make_executable(&path)?;
1235 git::acquire_worktree_config(&repo).await?;
1243 self.state.enabled_worktree_config = true;
1244 }
1245
1246 ensure_setup_config(&mut self.state, &repo).await?;
1249
1250 for (index, (spec, label)) in self
1251 .roles
1252 .implementers
1253 .clone()
1254 .into_iter()
1255 .zip(labels)
1256 .enumerate()
1257 {
1258 let branch = self.state.branch_for(label);
1259 let worktree = root.join(format!("cand-{label}"));
1260 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1261 if self.state.config.blind.commit_msg_hook {
1262 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1263 }
1264 git::local_exclude(&worktree, "/.magi/").await?;
1265 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1266 git::worktree_remove(&repo, &worktree).await.ok();
1269 git::branch_delete(&repo, &branch).await.ok();
1270 self.rollback_prep(&repo).await;
1271 self.state.status = RunStatus::Blocked;
1272 self.state
1273 .event("prep", format!("worktree setup failed: {e:#}"));
1274 self.state.save()?;
1275 return Err(e);
1276 }
1277 for pick in &plan.picks {
1278 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1279 self.state.status = RunStatus::Blocked;
1280 self.state
1281 .event("prep", format!("cannot apply referenced commit: {e}"));
1282 self.state.save()?;
1283 return Err(e);
1284 }
1285 }
1286 self.state.candidates.push(Candidate {
1287 index,
1288 label,
1289 agent: spec.id.clone(),
1290 branch,
1291 worktree,
1292 summary: String::new(),
1293 stat: String::new(),
1294 files: 0,
1295 commits: 0,
1296 empty: false,
1297 failed: None,
1298 verified_noop: None,
1299 duration_ms: 0,
1300 folded: false,
1301 });
1302 }
1303
1304 for j in 1..=self.roles.judges.len() {
1305 let wt = root.join(format!("judge-{j}"));
1306 if !wt.exists() {
1307 git::worktree_add_detached(&repo, &wt, &base).await?;
1308 self.setup_seat_worktree(&repo, &wt, true).await?;
1309 }
1310 }
1311
1312 if self.state.config.graph.advise {
1320 for k in 1..=self.state.config.graph.advisors {
1321 let wt = root.join(format!("advisor-{k}"));
1322 if !wt.exists() {
1323 git::worktree_add_detached(&repo, &wt, &base).await?;
1324 self.setup_seat_worktree(&repo, &wt, true).await?;
1325 }
1326 }
1327 }
1328
1329 let authors: Vec<&str> = self
1334 .roles
1335 .implementers
1336 .iter()
1337 .map(|a| a.id.as_str())
1338 .collect();
1339 let overlap: Vec<String> = self
1340 .roles
1341 .judges
1342 .iter()
1343 .enumerate()
1344 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1345 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1346 .collect();
1347 if !overlap.is_empty() {
1348 let note = format!(
1349 "{} also authored a candidate; blind, but the panel is less \
1350 independent than {} distinct agents would be",
1351 overlap.join(", "),
1352 self.roles.judges.len()
1353 );
1354 self.state.event("prep", note);
1355 }
1356
1357 self.state.event(
1358 "prep",
1359 format!(
1360 "{} candidates, {} judges, base {} ({})",
1361 self.state.candidates.len(),
1362 self.roles.judges.len(),
1363 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1364 self.state.base_branch
1365 ),
1366 );
1367 self.state.status = RunStatus::Implementing;
1368 self.state.save()?;
1369 Ok(())
1370 }
1371
1372 async fn advise(&mut self) -> Result<()> {
1405 let implement_untouched = self
1406 .state
1407 .candidates
1408 .iter()
1409 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1410 if !self.state.config.graph.advise || self.state.advise_attempted {
1411 return Ok(());
1412 }
1413 if !implement_untouched {
1414 self.state.event(
1415 "advise",
1416 "skipping the design-deliberation stage: at least one \
1417 candidate already shows implementation progress, so this \
1418 run is past the point the stage exists to run before"
1419 .to_owned(),
1420 );
1421 self.state.advise_attempted = true;
1422 self.state.save()?;
1423 return Ok(());
1424 }
1425 let run_id = self.state.id.clone();
1426 let prompts = self.state.config.prompts.clone();
1427 let instruction = self.state.instruction.clone();
1428 let language = self.state.config.graph.language.clone();
1429 let root = self.state.worktree_root();
1430 let n = self.state.config.graph.advisors;
1431 let where_recorded = self.state.dir().join("run.json");
1432
1433 let seats = match self.state.config.advisors() {
1434 Ok(seats) if !seats.is_empty() => seats,
1435 Ok(_) => {
1436 self.state.event(
1437 "advise",
1438 format!(
1439 "[graph] advisors is 0; skipping the design-deliberation \
1440 stage and continuing without a synthesis brief (see {})",
1441 where_recorded.display()
1442 ),
1443 );
1444 self.state.advise_attempted = true;
1445 self.state.save()?;
1446 return Ok(());
1447 }
1448 Err(e) => {
1449 self.state.event(
1450 "advise",
1451 format!(
1452 "could not resolve advisor seats ({e:#}); continuing \
1453 without a design-deliberation brief (see {})",
1454 where_recorded.display()
1455 ),
1456 );
1457 self.state.advise_attempted = true;
1458 self.state.save()?;
1459 return Ok(());
1460 }
1461 };
1462
1463 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1464 let artifacts = agent::artifacts_dir(&self.state.dir());
1465 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1466
1467 let mut jobs = Vec::new();
1468 for (i, spec) in seats.iter().cloned().enumerate() {
1469 let seat_key = format!("advisor-{}", i + 1);
1470 let seat = self.seat(&seat_key, &spec.id);
1471 jobs.push(SeatJob {
1472 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1473 spec,
1474 seat,
1475 cwd: worktrees[i % worktrees.len()].clone(),
1476 timeout,
1477 allow_write: false,
1478 sessions: false,
1479 artifacts: artifacts.clone(),
1480 stem: seat_key,
1481 handover: None,
1482 });
1483 }
1484
1485 self.state.event(
1486 "advise",
1487 format!(
1488 "{} advisor seat(s) sketching a design in parallel",
1489 jobs.len()
1490 ),
1491 );
1492 let mut quota_losses = Vec::new();
1493 let cache = self.state.config.cache_dir();
1494 let ctx = WaveCtx {
1495 carry_seats: false,
1496 run: &run_id,
1497 node: "advise",
1498 prompts: &prompts,
1499 cache: cache.as_deref(),
1500 round: None,
1501 };
1502 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1503 let results = ask_json_wave::<Proposal>(
1504 jobs,
1505 Arc::clone(&self.sem),
1506 self.state.config.graph.retries,
1507 &advisor_roster,
1508 &ctx,
1509 &mut quota_losses,
1510 &mut self.state,
1511 &|p: &Proposal| p.validate(),
1512 )
1513 .await;
1514 self.state.quota.extend(quota_losses);
1515
1516 let mut records = Vec::with_capacity(results.len());
1517 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1518 let agent_id = seat.agent.clone();
1519 self.state.seats.insert(seat.key.clone(), seat);
1520 match res {
1521 Ok((proposal, out)) => {
1522 self.state
1523 .event("advise", format!("advisor-{} proposed a design", i + 1));
1524 records.push(advise::AdvisorRecord::proposed(
1525 i + 1,
1526 agent_id,
1527 proposal,
1528 out.duration_ms,
1529 ));
1530 }
1531 Err(e) => {
1532 self.state.event(
1533 "advise",
1534 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1535 );
1536 records.push(advise::AdvisorRecord::failed(
1537 i + 1,
1538 agent_id,
1539 e.to_string(),
1540 ));
1541 }
1542 }
1543 }
1544
1545 let mut advice = advise::Advice {
1546 records,
1547 synthesis: None,
1548 };
1549 if advice.proposals().is_empty() {
1550 self.state.event(
1551 "advise",
1552 "no advisor produced a usable proposal; continuing without a \
1553 synthesis brief"
1554 .to_owned(),
1555 );
1556 } else {
1557 match self
1558 .synthesize_brief(
1559 &advice,
1560 &instruction,
1561 &language,
1562 &worktrees[0],
1563 &artifacts,
1564 &run_id,
1565 &prompts,
1566 cache.as_deref(),
1567 )
1568 .await
1569 {
1570 Ok(Some(text)) => {
1571 self.state.event(
1572 "advise",
1573 "synthesized a design brief for the implementer".to_owned(),
1574 );
1575 advice.synthesis = Some(text);
1576 }
1577 Ok(None) => {
1578 self.state.event(
1579 "advise",
1580 "the synthesis seat produced nothing usable; continuing \
1581 without a design brief"
1582 .to_owned(),
1583 );
1584 }
1585 Err(e) => {
1586 self.state.event(
1587 "advise",
1588 format!("could not synthesize a design brief: {e:#}"),
1589 );
1590 }
1591 }
1592 }
1593 advise::apply_reflection(&mut advice);
1594
1595 self.state.advice = Some(advice);
1596 self.state.advise_attempted = true;
1597 self.state.save()?;
1598 Ok(())
1599 }
1600
1601 #[allow(clippy::too_many_arguments)]
1613 async fn synthesize_brief(
1614 &mut self,
1615 advice: &advise::Advice,
1616 instruction: &str,
1617 language: &str,
1618 cwd: &Path,
1619 artifacts: &Path,
1620 run_id: &str,
1621 prompts: &Prompts,
1622 cache: Option<&Path>,
1623 ) -> Result<Option<String>> {
1624 let chain = agent::pick_chain(
1625 &self.state.config.agents,
1626 self.state.config.roles.synthesizer.as_ref(),
1627 &agent::installed,
1628 "synthesizer",
1629 )?;
1630 let proposals = advice.proposals();
1631 let mut prompt = prompt::with_overlay(
1632 prompt::synthesize_brief(instruction, &proposals, language),
1633 prompts.overlay("advise"),
1634 );
1635 if cache.is_some() {
1636 prompt.push('\n');
1641 prompt.push_str(&prompt::build_cache_note("advise", false));
1642 }
1643 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1644 let mut last = None;
1649 for (n, spec) in chain.iter().enumerate() {
1650 if n > 0 {
1651 self.state
1652 .event("advise", format!("synthesis falling back to {}", spec.id));
1653 }
1654 let mut seat = self.seat("advise-synthesis", &spec.id);
1655 let outcome = agent::invoke(
1656 spec,
1657 &mut seat,
1658 &Invocation {
1659 cwd,
1660 prompt: &prompt,
1661 timeout,
1662 allow_write: false,
1663 unsandboxed: false,
1664 sessions: false,
1665 artifacts,
1666 stem: &if n == 0 {
1667 "advise-synthesis".to_owned()
1668 } else {
1669 format!("advise-synthesis-{}", spec.id)
1670 },
1671 run: run_id,
1672 node: "advise",
1673 cache_dir: None,
1674 attachments: &[],
1675 writable: &[],
1676 },
1677 )
1678 .await;
1679 if outcome.is_ok() {
1680 self.state.seats.insert(seat.key.clone(), seat);
1681 }
1682 let advance = agent::chain_advances(&outcome);
1683 last = Some(outcome);
1684 if !advance {
1685 break;
1686 }
1687 }
1688 let out = last.expect("a chain holds at least one agent")?;
1691 if !out.usable() {
1692 return Ok(None);
1693 }
1694 let text =
1695 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1696 Ok((!text.trim().is_empty()).then_some(text))
1697 }
1698
1699 async fn implement(&mut self) -> Result<()> {
1702 let run_id = self.state.id.clone();
1707 let prompts = self.state.config.prompts.clone();
1708 let todo: Vec<usize> = self
1709 .state
1710 .candidates
1711 .iter()
1712 .enumerate()
1713 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1714 .map(|(i, _)| i)
1715 .collect();
1716 if todo.is_empty() {
1717 return self.after_implement();
1718 }
1719 self.state.status = RunStatus::Implementing;
1720
1721 let language = self.state.config.graph.language.clone();
1722 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1723 let sessions = self.state.config.graph.sessions;
1724 let artifacts = agent::artifacts_dir(&self.state.dir());
1725 let brief = self
1729 .state
1730 .advice
1731 .as_ref()
1732 .and_then(|a| a.synthesis.as_deref())
1733 .map(str::to_owned);
1734 let attachments = self.state.attachments.clone();
1735
1736 let mut jobs = Vec::new();
1737 for &i in &todo {
1738 let (index, label, worktree) = {
1739 let c = &self.state.candidates[i];
1740 (c.index, c.label, c.worktree.clone())
1741 };
1742 let spec = self.roles.implementers[index].clone();
1743 let seat_key = format!("impl-{label}");
1744 let seat = self.seat(&seat_key, &spec.id);
1745 let instruction = seeded_instruction(&self.state);
1746 jobs.push(SeatJob {
1747 spec,
1748 seat,
1749 prompt: prompt::implement(
1750 &instruction,
1751 &worktree.to_string_lossy(),
1752 &language,
1753 brief.as_deref(),
1754 &attachments,
1755 ),
1756 cwd: worktree,
1757 timeout,
1758 allow_write: true,
1759 sessions,
1760 artifacts: artifacts.clone(),
1761 stem: format!("impl-{label}"),
1762 handover: None,
1763 });
1764 }
1765
1766 self.state.event(
1767 "implement",
1768 format!("{} candidates in parallel", jobs.len()),
1769 );
1770 let mut sent = jobs.clone();
1776 let cache = self.state.config.cache_dir();
1777 let ctx = WaveCtx {
1778 carry_seats: false,
1779 run: &run_id,
1780 node: "implement",
1781 prompts: &prompts,
1782 cache: cache.as_deref(),
1783 round: None,
1784 };
1785 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1786 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1787 .await;
1788 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1789 .await;
1790 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1791 .await;
1792
1793 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1794 let seat_key = seat.key.clone();
1795 let agent = seat.agent.clone();
1804 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1805 self.state.seats.insert(seat.key.clone(), seat);
1806 let label = self.state.candidates[i].label;
1807 let worktree = self.state.candidates[i].worktree.clone();
1808 let base = self.state.base_commit.clone();
1809
1810 let (summary, duration, failed, verified_claim) = match out {
1811 AgentOutcome::Ok(o) => {
1812 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1813 let failed = (!o.usable()).then(|| {
1814 if o.timed_out {
1815 "agent timed out".to_owned()
1816 } else {
1817 format!("agent exited with {:?}", o.exit_code)
1818 }
1819 });
1820 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1821 (text, o.duration_ms, failed, verified_claim)
1822 }
1823 AgentOutcome::Dropped(o) => {
1829 let why = o
1830 .dropped
1831 .as_ref()
1832 .map(|d| d.why.as_str())
1833 .unwrap_or("the CLI ended the stream without delivering its answer");
1834 (
1835 String::new(),
1836 o.duration_ms,
1837 Some(format!("the CLI dropped the stream ({why})")),
1838 None,
1839 )
1840 }
1841 AgentOutcome::Quota(o) => {
1842 self.state.quota.push(QuotaLoss {
1843 seat: seat_key,
1844 node: "implement".to_owned(),
1845 at: Timestamp::now(),
1846 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1847 });
1848 (
1849 String::new(),
1850 o.duration_ms,
1851 Some("rate limited (quota); produced no change".to_owned()),
1852 None,
1853 )
1854 }
1855 AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1856 };
1857
1858 let rescued = match git::rescue_commit(
1861 &worktree,
1862 &format!("magi: candidate {label} (uncommitted work)"),
1863 )
1864 .await
1865 {
1866 Ok(r) => {
1867 self.state.note_withheld("implement", &r.withheld);
1868 r.committed
1869 }
1870 Err(_) => false,
1871 };
1872 let commits = git::commits_ahead(&worktree, &base, "HEAD")
1873 .await
1874 .unwrap_or(0);
1875 let patch = git::diff(&worktree, &base, "HEAD")
1876 .await
1877 .unwrap_or_default();
1878 let stat = git::diff_stat(&worktree, &base, "HEAD")
1879 .await
1880 .unwrap_or_default();
1881 let files = git::changed_files(&worktree, &base, "HEAD")
1882 .await
1883 .map(|f| f.len())
1884 .unwrap_or(0);
1885 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1886
1887 let c = &mut self.state.candidates[i];
1888 if !exhausted_the_fallback_chain {
1889 c.agent = agent;
1890 }
1891 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1892 c.stat = stat;
1893 c.files = files;
1894 c.commits = commits;
1895 c.duration_ms = duration;
1896 c.empty = commits == 0 || patch.trim().is_empty();
1897 c.failed = match failed {
1900 Some(_) if c.empty => failed,
1901 _ => None,
1902 };
1903 c.verified_noop = if c.empty { verified_claim } else { None };
1908 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1909 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1910 (None, true, Some(_), _) => {
1911 format!("candidate {label}: no change produced (agent-verified no-op)")
1912 }
1913 (None, true, None, _) => format!("candidate {label}: no change produced"),
1914 (None, false, _, true) => {
1915 format!(
1916 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1917 )
1918 }
1919 (None, false, _, false) => {
1920 format!("candidate {label}: {files} files, {commits} commits")
1921 }
1922 };
1923 self.state.event("implement", note);
1924 self.state.save()?;
1925 }
1926
1927 self.after_implement()
1928 }
1929
1930 async fn resume_undelivered(
1958 &mut self,
1959 results: &mut [(usize, SeatState, AgentOutcome)],
1960 sent: &[SeatJob],
1961 prompts: &Prompts,
1962 run_id: &str,
1963 ) {
1964 for (wi, seat, out) in results.iter_mut() {
1965 let Some(dropped) = (match &*out {
1966 AgentOutcome::Dropped(o) => o.dropped.clone(),
1967 _ => None,
1968 }) else {
1969 continue;
1970 };
1971 let Some(job) = sent.get(*wi) else { continue };
1972 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1974 self.state.event(
1975 "implement",
1976 format!(
1977 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1978 work is in the tree",
1979 seat.key, dropped.output_tokens, dropped.why
1980 ),
1981 );
1982 continue;
1983 }
1984 if !has_context(&job.spec, seat, job.sessions) {
1992 self.state.event(
1993 "implement",
1994 format!(
1995 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
1996 is no session left to resume",
1997 seat.key, dropped.output_tokens, dropped.why
1998 ),
1999 );
2000 continue;
2001 }
2002 self.state.event(
2003 "implement",
2004 format!(
2005 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2006 conversation",
2007 seat.key, dropped.output_tokens, dropped.why
2008 ),
2009 );
2010 let mut retry = job.clone();
2011 retry.seat = seat.clone();
2012 retry.prompt = prompt::resume_after_drop(&dropped.why);
2013 retry.timeout = retry_budget(job.timeout, true);
2014 retry.stem = format!("{}-resume", job.stem);
2015 let cache = self.state.config.cache_dir();
2016 let ctx = WaveCtx {
2017 carry_seats: false,
2018 run: run_id,
2019 node: "implement",
2020 prompts,
2021 cache: cache.as_deref(),
2022 round: None,
2023 };
2024 let (resumed_seat, resumed) =
2025 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2026 *seat = resumed_seat;
2027 *out = resumed;
2028 }
2029 }
2030
2031 async fn resume_seat_handovers(
2096 &mut self,
2097 results: &mut [(usize, SeatState, AgentOutcome)],
2098 sent: &mut [SeatJob],
2099 prompts: &Prompts,
2100 run_id: &str,
2101 ) {
2102 let instruction = seeded_instruction(&self.state);
2103 let language = self.state.config.graph.language.clone();
2104 let brief = self
2105 .state
2106 .advice
2107 .as_ref()
2108 .and_then(|a| a.synthesis.as_deref())
2109 .map(str::to_owned);
2110 let attachments = self.state.attachments.clone();
2111 for (wi, seat, out) in results.iter_mut() {
2112 let others: BTreeSet<String> = sent
2115 .iter()
2116 .enumerate()
2117 .filter(|(j, _)| j != wi)
2118 .map(|(_, j)| j.spec.id.clone())
2119 .collect();
2120 let Some(job) = sent.get_mut(*wi) else {
2121 continue;
2122 };
2123 let start = self
2128 .roles
2129 .implementer_roster
2130 .iter()
2131 .position(|s| s.id == job.spec.id)
2132 .unwrap_or(0);
2133 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2134 let mut fallback_attempt = 0usize;
2135 let mut prev: Option<FailClass> = None;
2136 while let Some(cur) = FailClass::of(&*out) {
2137 if !should_hand_over(prev.as_ref(), &cur) {
2138 break;
2139 }
2140 let Some(next) =
2141 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2142 .cloned()
2143 else {
2144 break;
2145 };
2146 tried.insert(next.id.clone());
2147 fallback_attempt += 1;
2148
2149 if let Ok(r) = git::rescue_commit(
2150 &job.cwd,
2151 &format!(
2152 "magi: candidate {} (uncommitted work before {} fallback)",
2153 seat.key,
2154 if cur == FailClass::Quota {
2155 "quota"
2156 } else {
2157 "handover"
2158 }
2159 ),
2160 )
2161 .await
2162 {
2163 self.state.note_withheld("implement", &r.withheld);
2164 }
2165
2166 record_handover(
2167 &mut self.state,
2168 "implement",
2169 &seat.key,
2170 &seat.agent,
2171 &next.id,
2172 &cur,
2173 &fail_reason(&*out),
2174 );
2175 prev = Some(cur.clone());
2176
2177 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2178 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2179 job.spec = next.clone();
2187 let mut retry = job.clone();
2188 retry.seat = new_seat;
2189 retry.prompt = prompt::implement(
2190 &instruction,
2191 &job.cwd.to_string_lossy(),
2192 &language,
2193 brief.as_deref(),
2194 &attachments,
2195 );
2196 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2197 let cache = self.state.config.cache_dir();
2198 let ctx = WaveCtx {
2199 carry_seats: false,
2200 run: run_id,
2201 node: "implement",
2202 prompts,
2203 cache: cache.as_deref(),
2204 round: None,
2205 };
2206 let (fallback_seat, fallback_out) = run_one(
2207 retry,
2208 Arc::clone(&self.sem),
2209 &ctx,
2210 &mut self.state,
2211 fallback_attempt,
2212 )
2213 .await;
2214 *seat = fallback_seat;
2215 *out = fallback_out;
2216 }
2217 }
2218 }
2219
2220 async fn resume_unconfirmed_commands(
2244 &mut self,
2245 results: &mut [(usize, SeatState, AgentOutcome)],
2246 sent: &[SeatJob],
2247 prompts: &Prompts,
2248 run_id: &str,
2249 ) {
2250 for (wi, seat, out) in results.iter_mut() {
2251 let AgentOutcome::Ok(o) = &*out else {
2252 continue;
2253 };
2254 if !has_unconfirmed_command(&o.commands) {
2255 continue;
2256 }
2257 let Some(job) = sent.get(*wi) else { continue };
2258 if !has_context(&job.spec, seat, job.sessions) {
2259 self.state.event(
2260 "implement",
2261 format!(
2262 "{}: the reply named a command whose own CLI never confirmed the exit \
2263 status of, but there is no session left to resume",
2264 seat.key
2265 ),
2266 );
2267 continue;
2268 }
2269 self.state.event(
2270 "implement",
2271 format!(
2272 "{}: the reply named a command whose own CLI never confirmed the exit \
2273 status of; resuming the conversation",
2274 seat.key
2275 ),
2276 );
2277 let mut retry = job.clone();
2278 retry.seat = seat.clone();
2279 retry.prompt = prompt::resume_incomplete(
2280 "a command in your last reply had no confirmed exit status",
2281 );
2282 retry.timeout = retry_budget(job.timeout, true);
2283 retry.stem = format!("{}-confirm", job.stem);
2284 let cache = self.state.config.cache_dir();
2285 let ctx = WaveCtx {
2286 carry_seats: false,
2287 run: run_id,
2288 node: "implement",
2289 prompts,
2290 cache: cache.as_deref(),
2291 round: None,
2292 };
2293 let (resumed_seat, resumed) =
2294 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2295 *seat = resumed_seat;
2296 *out = resumed;
2297 }
2298 }
2299
2300 async fn continue_fix_report(
2321 &mut self,
2322 mut seat: SeatState,
2323 parse_err: String,
2324 job: &SeatJob,
2325 prompts: &Prompts,
2326 run_id: &str,
2327 round: usize,
2328 ) -> (
2329 SeatState,
2330 Option<FixReport>,
2331 Option<String>,
2332 ContinuationRecord,
2333 ) {
2334 let mut last_err = parse_err;
2335 let mut cumulative_wait_ms = 0u64;
2336 let mut attempts = 0usize;
2337 loop {
2338 if !has_context(&job.spec, &seat, job.sessions) {
2339 self.state.event(
2340 "fix",
2341 format!(
2342 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2343 session left to resume into"
2344 ),
2345 );
2346 let outcome = if attempts == 0 {
2347 ContinuationOutcome::NoSession
2348 } else {
2349 ContinuationOutcome::Exhausted
2350 };
2351 return (
2352 seat,
2353 None,
2354 Some(format!("unparsable fix report: {last_err}")),
2355 ContinuationRecord {
2356 attempts,
2357 cumulative_wait_ms,
2358 outcome,
2359 },
2360 );
2361 }
2362 if attempts >= MAX_FIX_CONTINUATIONS {
2363 self.state.event(
2364 "fix",
2365 format!(
2366 "round {round}: fixer's reply still had no adoption report after \
2367 {attempts} continuation(s) ({last_err}); giving up"
2368 ),
2369 );
2370 return (
2371 seat,
2372 None,
2373 Some(format!(
2374 "unparsable fix report after {attempts} continuation(s): {last_err}"
2375 )),
2376 ContinuationRecord {
2377 attempts,
2378 cumulative_wait_ms,
2379 outcome: ContinuationOutcome::Exhausted,
2380 },
2381 );
2382 }
2383 attempts += 1;
2384 self.state.event(
2385 "fix",
2386 format!(
2387 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2388 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2389 ),
2390 );
2391 let mut retry = job.clone();
2392 retry.seat = seat.clone();
2393 retry.prompt = prompt::resume_incomplete(&last_err);
2394 retry.timeout = retry_budget(job.timeout, true);
2395 retry.stem = format!("{}-continue{attempts}", job.stem);
2396 let cache = self.state.config.cache_dir();
2397 let ctx = WaveCtx {
2398 carry_seats: false,
2399 run: run_id,
2400 node: "fix",
2401 prompts,
2402 cache: cache.as_deref(),
2403 round: Some(round),
2404 };
2405 let (resumed_seat, resumed_out) = run_one(
2406 retry,
2407 Arc::clone(&self.sem),
2408 &ctx,
2409 &mut self.state,
2410 attempts,
2411 )
2412 .await;
2413 seat = resumed_seat;
2414 match resumed_out {
2415 AgentOutcome::Ok(o) => {
2416 cumulative_wait_ms += o.duration_ms;
2417 match verdict::extract_json::<FixReport>(&o.text) {
2418 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2419 self.state.event(
2420 "fix",
2421 format!(
2422 "round {round}: fixer's adoption report recovered after \
2423 {attempts} continuation(s)"
2424 ),
2425 );
2426 return (
2427 seat,
2428 Some(report),
2429 None,
2430 ContinuationRecord {
2431 attempts,
2432 cumulative_wait_ms,
2433 outcome: ContinuationOutcome::Resumed,
2434 },
2435 );
2436 }
2437 Ok(_) => {
2445 last_err = "the reply parsed, but it reported a command whose own CLI \
2446 never confirmed an exit status"
2447 .to_owned();
2448 }
2449 Err(e) => last_err = e.to_string(),
2450 }
2451 }
2452 AgentOutcome::Quota(o) => {
2453 cumulative_wait_ms += o.duration_ms;
2454 self.state.quota.push(QuotaLoss {
2455 seat: seat.key.clone(),
2456 node: "fix".to_owned(),
2457 at: Timestamp::now(),
2458 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2459 });
2460 self.state.event(
2461 "fix",
2462 format!(
2463 "round {round}: continuation rate limited (quota); not retrying now"
2464 ),
2465 );
2466 return (
2467 seat,
2468 None,
2469 Some("rate limited (quota) while recovering the fix report".to_owned()),
2470 ContinuationRecord {
2471 attempts,
2472 cumulative_wait_ms,
2473 outcome: ContinuationOutcome::QuotaLost,
2474 },
2475 );
2476 }
2477 AgentOutcome::Dropped(o) => {
2478 cumulative_wait_ms += o.duration_ms;
2479 let why = o
2480 .dropped
2481 .as_ref()
2482 .map(|d| d.why.as_str())
2483 .unwrap_or("the CLI ended the stream without delivering its answer");
2484 last_err = format!("the CLI dropped the stream ({why})");
2485 }
2486 AgentOutcome::Failed(e) => last_err = e,
2487 }
2488 }
2489 }
2490
2491 fn after_implement(&mut self) -> Result<()> {
2492 if self.state.leaks.is_empty() {
2494 let cfg = self.state.config.blind.clone();
2495 let mut leaks = Vec::new();
2496 for c in &self.state.candidates {
2497 let Some(patch) =
2498 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2499 else {
2500 continue;
2501 };
2502 leaks.extend(blind::scan(
2503 &format!("candidate {} patch", c.label),
2504 &patch,
2505 &cfg.vendor_tokens,
2506 ));
2507 }
2508 if !leaks.is_empty() {
2509 let summary = leaks
2510 .iter()
2511 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2512 .collect::<Vec<_>>()
2513 .join(", ");
2514 match cfg.on_leak {
2515 LeakPolicy::Fail => {
2516 self.state.status = RunStatus::Failed;
2517 self.state
2518 .event("blind", format!("vendor text in a patch: {summary}"));
2519 self.state.leaks = leaks;
2520 self.state.save()?;
2521 self.settle_questions();
2522 bail!(
2523 "blind.on_leak = \"fail\" and vendor text reached a \
2524 judged patch: {summary}"
2525 );
2526 }
2527 LeakPolicy::Redact => self.state.event(
2528 "blind",
2529 format!("redacting vendor text for judging: {summary}"),
2530 ),
2531 LeakPolicy::Warn => self.state.event(
2532 "blind",
2533 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2534 ),
2535 }
2536 self.state.leaks = leaks;
2537 }
2538 }
2539
2540 if self.state.viable().is_empty() {
2541 if self.state.all_candidates_verified_noop() {
2542 self.state.status = RunStatus::VerifiedNoop;
2553 self.state.save()?;
2554 self.settle_questions();
2555 return Ok(());
2556 }
2557 self.state.status = RunStatus::Failed;
2558 self.state.save()?;
2559 self.settle_questions();
2560 bail!("no candidate produced a change; nothing to judge");
2561 }
2562 self.state.status = RunStatus::Judging;
2563 self.state.save()?;
2564 Ok(())
2565 }
2566
2567 async fn judge(&mut self) -> Result<()> {
2570 let run_id = self.state.id.clone();
2575 let prompts = self.state.config.prompts.clone();
2576 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2577 return Ok(());
2578 }
2579 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2580 if viable.len() == 1 {
2581 self.state.judge_skipped = true;
2588 self.state.event(
2589 "judge",
2590 format!(
2591 "only candidate {} produced a change; judging skipped",
2592 viable[0].label
2593 ),
2594 );
2595 self.state.save()?;
2596 return Ok(());
2597 }
2598 self.state.status = RunStatus::Judging;
2599
2600 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2601 let language = self.state.config.graph.language.clone();
2602 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2603 let sessions = self.state.config.graph.sessions;
2604 let artifacts = agent::artifacts_dir(&self.state.dir());
2605 let root = self.state.worktree_root();
2606 let base_short = short(&self.state.base_commit);
2607
2608 let mut jobs = Vec::new();
2609 let mut orders = Vec::new();
2610 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2611 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2612 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2613 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2614 let seat_key = format!("judge-{}", j + 1);
2615 let seat = self.seat(&seat_key, &spec.id);
2616 jobs.push(SeatJob {
2617 prompt: prompt::judge(
2618 &self.state.instruction,
2619 &views,
2620 self.roles.judges.len(),
2621 &base_short,
2622 &language,
2623 ),
2624 spec,
2625 seat,
2626 cwd: root.join(format!("judge-{}", j + 1)),
2627 timeout,
2628 allow_write: false,
2629 sessions,
2630 artifacts: artifacts.clone(),
2631 stem: format!("judge-{}", j + 1),
2632 handover: None,
2633 });
2634 }
2635
2636 self.state.event(
2637 "judge",
2638 format!(
2639 "{} judges ranking {} candidates blind",
2640 jobs.len(),
2641 viable.len()
2642 ),
2643 );
2644 let labels_for_check = labels.clone();
2645 let mut quota_losses = Vec::new();
2646 let cache = self.state.config.cache_dir();
2647 let ctx = WaveCtx {
2648 carry_seats: false,
2649 run: &run_id,
2650 node: "judge",
2651 prompts: &prompts,
2652 cache: cache.as_deref(),
2653 round: None,
2654 };
2655 let results = ask_json_wave::<Ranking>(
2656 jobs,
2657 Arc::clone(&self.sem),
2658 self.state.config.graph.retries,
2659 &self.roles.judge_roster,
2660 &ctx,
2661 &mut quota_losses,
2662 &mut self.state,
2663 &move |r: &Ranking| r.validate(&labels_for_check),
2664 )
2665 .await;
2666 self.state.quota.extend(quota_losses);
2667
2668 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2669 let agent_id = seat.agent.clone();
2670 self.state.seats.insert(seat.key.clone(), seat);
2671 let mut record = Judgement {
2672 judge: j + 1,
2673 seat: format!("judge-{}", j + 1),
2674 agent: agent_id,
2675 ranking: Vec::new(),
2676 reasons: BTreeMap::new(),
2677 confidence: None,
2678 order: orders[j].clone(),
2679 failed: None,
2680 duration_ms: 0,
2681 };
2682 match res {
2683 Ok((ranking, out)) => {
2684 record.ranking = ranking.normalized();
2685 record.reasons = ranking.reasons;
2686 record.confidence = ranking.confidence;
2687 record.duration_ms = out.duration_ms;
2688 self.state.event(
2689 "judge",
2690 format!(
2691 "judge {} ranked {}",
2692 j + 1,
2693 record.ranking.iter().collect::<String>()
2694 ),
2695 );
2696 }
2697 Err(e) => {
2698 record.failed = Some(e.to_string());
2699 self.state
2700 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2701 }
2702 }
2703 self.state.judgements.push(record);
2704 self.state.save()?;
2705 }
2706 Ok(())
2707 }
2708
2709 async fn deliberate(&mut self) -> Result<()> {
2712 let run_id = self.state.id.clone();
2717 let prompts = self.state.config.prompts.clone();
2718 if !self.state.deliberation.is_empty() {
2719 return Ok(());
2720 }
2721 let tops: Vec<char> = self
2722 .state
2723 .judgements
2724 .iter()
2725 .filter_map(|j| j.ranking.first().copied())
2726 .collect();
2727 let rounds = self.state.config.graph.deliberate_rounds;
2728 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2729 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2730 self.state.event(
2731 "deliberate",
2732 format!("judges agreed on {} outright; no deliberation", tops[0]),
2733 );
2734 }
2735 self.state.status = RunStatus::Voting;
2736 self.state.save()?;
2737 return Ok(());
2738 }
2739
2740 self.state.status = RunStatus::Deliberating;
2741 self.state.event(
2742 "deliberate",
2743 format!(
2744 "split: first choices were {} — opening {rounds} round(s)",
2745 tops.iter().collect::<String>()
2746 ),
2747 );
2748
2749 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2750 let language = self.state.config.graph.language.clone();
2751 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2752 let sessions = self.state.config.graph.sessions;
2753 let artifacts = agent::artifacts_dir(&self.state.dir());
2754 let root = self.state.worktree_root();
2755 let base_short = short(&self.state.base_commit);
2756
2757 for round in 1..=rounds {
2761 let mut turns: Vec<DeliberationTurn> = Vec::new();
2762 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2763 if self.state.judgements[j].failed.is_some() {
2764 continue;
2765 }
2766 let seat_key = format!("judge-{}", j + 1);
2767 let spec = self.occupant(&seat_key, spec);
2768 let mut seat = self.seat(&seat_key, &spec.id);
2769 let transcript = self.transcript(&turns, j);
2770 let build = |context: Option<&str>| {
2771 prompt::deliberate(
2772 &self.state.instruction,
2773 context,
2774 &transcript,
2775 round,
2776 rounds,
2777 &language,
2778 )
2779 };
2780 let block = self.candidate_block(&viable, &base_short);
2781 let full = build(Some(&block));
2782 let text = if has_context(&spec, &seat, sessions) {
2783 build(None)
2784 } else {
2785 full.clone()
2786 };
2787 let job = SeatJob {
2788 spec,
2789 seat: seat.clone(),
2790 prompt: text,
2791 cwd: root.join(format!("judge-{}", j + 1)),
2792 timeout,
2793 allow_write: false,
2794 sessions,
2795 artifacts: artifacts.clone(),
2796 stem: format!("delib-{round}-judge-{}", j + 1),
2797 handover: Some(full),
2798 };
2799 let cache = self.state.config.cache_dir();
2800 let ctx = WaveCtx {
2801 carry_seats: false,
2802 run: &run_id,
2803 node: "deliberate",
2804 prompts: &prompts,
2805 cache: cache.as_deref(),
2806 round: None,
2807 };
2808 let mut losses = Vec::new();
2812 let mut results = ask_wave_with::<String>(
2813 vec![job],
2814 Arc::clone(&self.sem),
2815 0,
2816 &self.roles.judge_roster,
2817 &ctx,
2818 &mut losses,
2819 &mut self.state,
2820 &|text: &str| {
2821 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2822 },
2823 )
2824 .await;
2825 self.state.quota.extend(losses);
2826 let (updated, res, _) = results.pop().expect("one job in, one result out");
2827 seat = updated;
2828 let agent_id = seat.agent.clone();
2829 self.state.seats.insert(seat.key.clone(), seat);
2830 let body = match res {
2831 Ok((body, _)) => body,
2832 Err(e) => {
2835 self.state
2836 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2837 continue;
2838 }
2839 };
2840 let tentative = verdict::extract_json::<Position>(&body)
2841 .ok()
2842 .and_then(|p| p.tentative)
2843 .and_then(|s| s.trim().chars().next())
2844 .map(|c| c.to_ascii_uppercase());
2845 self.state.event(
2846 "deliberate",
2847 format!(
2848 "round {round}: judge {} now favours {}",
2849 j + 1,
2850 tentative.map_or("—".to_owned(), |c| c.to_string())
2851 ),
2852 );
2853 turns.push(DeliberationTurn {
2854 judge: j + 1,
2855 agent: agent_id,
2856 body: blind::sanitize_prose(&body, &self.state.config.blind),
2857 tentative,
2858 });
2859 }
2860 self.state
2861 .deliberation
2862 .push(DeliberationRound { round, turns });
2863 self.state.save()?;
2864 }
2865
2866 self.state.status = RunStatus::Voting;
2867 self.state.save()?;
2868 Ok(())
2869 }
2870
2871 async fn vote(&mut self) -> Result<()> {
2874 let run_id = self.state.id.clone();
2879 let prompts = self.state.config.prompts.clone();
2880 if !self.state.votes.is_empty() {
2881 return Ok(());
2882 }
2883 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2884 if viable.len() == 1 {
2885 return Ok(());
2886 }
2887 self.state.status = RunStatus::Voting;
2888
2889 let language = self.state.config.graph.language.clone();
2890 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2891 let sessions = self.state.config.graph.sessions;
2892 let artifacts = agent::artifacts_dir(&self.state.dir());
2893 let root = self.state.worktree_root();
2894 let base_short = short(&self.state.base_commit);
2895 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2896
2897 let mut jobs = Vec::new();
2898 let mut seats_at = Vec::new();
2899 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2900 if self
2901 .state
2902 .judgements
2903 .get(j)
2904 .is_some_and(|r| r.failed.is_some())
2905 {
2906 continue;
2907 }
2908 let seat_key = format!("judge-{}", j + 1);
2909 let spec = self.occupant(&seat_key, spec);
2910 let seat = self.seat(&seat_key, &spec.id);
2911 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2912 let text = if has_context(&spec, &seat, sessions) {
2913 prompt::final_vote(&viable, &language)
2914 } else {
2915 full.clone()
2916 };
2917 jobs.push(SeatJob {
2918 spec,
2919 seat,
2920 prompt: text,
2921 cwd: root.join(format!("judge-{}", j + 1)),
2922 timeout,
2923 allow_write: false,
2924 sessions,
2925 artifacts: artifacts.clone(),
2926 stem: format!("vote-judge-{}", j + 1),
2927 handover: Some(full),
2928 });
2929 seats_at.push(j);
2930 }
2931
2932 self.state.event(
2933 "vote",
2934 format!(
2935 "collecting {} final votes one by one, privately",
2936 jobs.len()
2937 ),
2938 );
2939 let allowed = viable.clone();
2940 let mut quota_losses = Vec::new();
2941 let cache = self.state.config.cache_dir();
2942 let ctx = WaveCtx {
2943 carry_seats: false,
2944 run: &run_id,
2945 node: "vote",
2946 prompts: &prompts,
2947 cache: cache.as_deref(),
2948 round: None,
2949 };
2950 let results = ask_json_wave::<FinalVote>(
2951 jobs,
2952 Arc::clone(&self.sem),
2953 self.state.config.graph.retries,
2954 &self.roles.judge_roster,
2955 &ctx,
2956 &mut quota_losses,
2957 &mut self.state,
2958 &move |v: &FinalVote| match v.label() {
2959 Some(c) if allowed.contains(&c) => Ok(()),
2960 other => bail!("vote {other:?} is not one of {allowed:?}"),
2961 },
2962 )
2963 .await;
2964 self.state.quota.extend(quota_losses);
2965
2966 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2967 let agent_id = seat.agent.clone();
2968 self.state.seats.insert(seat.key.clone(), seat);
2969 let initial = self
2970 .state
2971 .judgements
2972 .get(j)
2973 .and_then(|r| r.ranking.first().copied());
2974 let mut record = VoteRecord {
2975 judge: j + 1,
2976 agent: agent_id,
2977 vote: None,
2978 reason: String::new(),
2979 changed: false,
2980 };
2981 match res {
2982 Ok((v, _)) => {
2983 record.vote = v.label();
2984 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2985 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2986 self.state.event(
2987 "vote",
2988 format!(
2989 "judge {} voted {}{}",
2990 j + 1,
2991 record.vote.unwrap_or('?'),
2992 if record.changed { " (changed)" } else { "" }
2993 ),
2994 );
2995 }
2996 Err(e) => {
2997 self.state
2998 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
2999 }
3000 }
3001 self.state.votes.push(record);
3002 self.state.save()?;
3003 }
3004 Ok(())
3005 }
3006
3007 fn tally(&mut self) -> Result<()> {
3010 if self.state.tally.is_some() {
3011 return Ok(());
3012 }
3013 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3014 let tops: Vec<char> = self
3015 .state
3016 .judgements
3017 .iter()
3018 .filter_map(|j| j.ranking.first().copied())
3019 .collect();
3020 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3021
3022 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3025 let mut cast: Vec<char> = Vec::new();
3026 for (i, j) in self.state.judgements.iter().enumerate() {
3027 let vote = self
3028 .state
3029 .votes
3030 .iter()
3031 .find(|v| v.judge == i + 1)
3032 .and_then(|v| v.vote)
3033 .or_else(|| j.ranking.first().copied());
3034 if let Some(v) = vote {
3035 *first_choice.entry(v).or_insert(0) += 1;
3036 cast.push(v);
3037 }
3038 }
3039
3040 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3041 for j in &self.state.judgements {
3042 let n = j.ranking.len();
3043 for (pos, label) in j.ranking.iter().enumerate() {
3044 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3045 }
3046 }
3047
3048 let best = first_choice.values().copied().max().unwrap_or(0);
3049 let mut leaders: Vec<char> = first_choice
3050 .iter()
3051 .filter(|(_, v)| **v == best)
3052 .map(|(k, _)| *k)
3053 .collect();
3054 let mut tie_break = None;
3055 if leaders.len() > 1 {
3056 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3057 let borda_leaders: Vec<char> = leaders
3058 .iter()
3059 .copied()
3060 .filter(|l| borda[l] == top_borda)
3061 .collect();
3062 tie_break = Some(if borda_leaders.len() == 1 {
3063 format!(
3064 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3065 leaders.len()
3066 )
3067 } else {
3068 format!(
3069 "{} way tie on both first-choice votes and Borda points, broken by label order",
3070 leaders.len()
3071 )
3072 });
3073 leaders = borda_leaders;
3074 leaders.sort_unstable();
3075 }
3076 let winner = *leaders
3077 .first()
3078 .or(viable.first())
3079 .context("no candidate to declare a winner from")?;
3080
3081 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3082 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3083 let deliberated = !self.state.deliberation.is_empty();
3084
3085 let quota_seats: std::collections::BTreeSet<&str> =
3089 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3090 let mut present = 0usize;
3091 for (i, j) in self.state.judgements.iter().enumerate() {
3092 if quota_seats.contains(j.seat.as_str()) {
3093 continue;
3094 }
3095 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3096 let voted = self
3097 .state
3098 .votes
3099 .iter()
3100 .any(|v| v.judge == i + 1 && v.vote.is_some());
3101 if ranked || voted {
3102 present += 1;
3103 }
3104 }
3105 let needs_quorum = viable.len() > 1;
3111 let judges_total = if needs_quorum {
3112 self.roles.judges.len()
3113 } else {
3114 0
3115 };
3116 let quorum = if needs_quorum {
3117 judges_total / 2 + 1
3118 } else {
3119 0
3120 };
3121 let met_quorum = !needs_quorum || present >= quorum;
3122 let uncontested = (!needs_quorum).then(|| {
3123 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3124 });
3125
3126 self.state.event(
3127 "tally",
3128 match &uncontested {
3129 Some(reason) => format!("winner {winner} — {reason}"),
3130 None => format!(
3131 "winner {winner} — votes {} | initial {} | {} changed | \
3132 {present}/{judges_total} judges{}",
3133 first_choice
3134 .iter()
3135 .map(|(k, v)| format!("{k}:{v}"))
3136 .collect::<Vec<_>>()
3137 .join(" "),
3138 if unanimous_initial {
3139 "unanimous"
3140 } else {
3141 "split"
3142 },
3143 changed_votes,
3144 if met_quorum {
3145 String::new()
3146 } else {
3147 format!(" — below quorum ({quorum} required)")
3148 },
3149 ),
3150 },
3151 );
3152 if !met_quorum {
3153 self.state.event(
3154 "stall",
3155 format!(
3156 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3157 the run stops here, resumable"
3158 ),
3159 );
3160 }
3161 self.state.tally = Some(Tally {
3162 first_choice,
3163 borda,
3164 winner,
3165 rankings: tops.len(),
3166 unanimous_initial,
3167 deliberated,
3168 changed_votes,
3169 unanimous_final,
3170 tie_break,
3171 judges: judges_total,
3172 present,
3173 quorum,
3174 met_quorum,
3175 uncontested,
3176 });
3177 self.state.status = if met_quorum {
3178 RunStatus::Reviewing
3179 } else {
3180 RunStatus::Stalled
3181 };
3182 self.state.save()?;
3183 Ok(())
3184 }
3185
3186 #[allow(clippy::too_many_lines)]
3207 async fn recover_stall(&mut self) -> Result<bool> {
3208 let run_id = self.state.id.clone();
3213 let prompts = self.state.config.prompts.clone();
3214 let quota_seats: BTreeSet<&str> =
3219 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3220 let absent: Vec<String> = self
3221 .state
3222 .judgements
3223 .iter()
3224 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3225 .map(|j| j.seat.clone())
3226 .collect();
3227 if absent.is_empty() {
3228 return Ok(false);
3229 }
3230 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3231 if viable.len() <= 1 {
3232 return Ok(false);
3233 }
3234 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3235 let language = self.state.config.graph.language.clone();
3236 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3237 let sessions = self.state.config.graph.sessions;
3238 let artifacts = agent::artifacts_dir(&self.state.dir());
3239 let root = self.state.worktree_root();
3240 let base_short = short(&self.state.base_commit);
3241 let candidates: Vec<Candidate> = viable.clone();
3242
3243 let mut positions: Vec<usize> = absent
3245 .iter()
3246 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3247 .collect();
3248 if positions.is_empty() {
3249 return Ok(false);
3250 }
3251 positions.sort_unstable();
3252 positions.dedup();
3253
3254 let mut judge_jobs = Vec::new();
3256 for &j in &positions {
3257 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3258 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3259 let seat_key = format!("judge-{}", j + 1);
3260 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3261 let seat = self.seat(&seat_key, &spec.id);
3262 judge_jobs.push(SeatJob {
3263 spec,
3264 seat,
3265 prompt: prompt::judge(
3266 &self.state.instruction,
3267 &views,
3268 self.roles.judges.len(),
3269 &base_short,
3270 &language,
3271 ),
3272 cwd: root.join(seat_key),
3273 timeout,
3274 allow_write: false,
3275 sessions,
3276 artifacts: artifacts.clone(),
3277 stem: format!("judge-{}-recover", j + 1),
3278 handover: None,
3279 });
3280 }
3281
3282 let labels_for_check = labels.clone();
3283 let mut judge_losses = Vec::new();
3284 let retries = self.state.config.graph.retries;
3285 let cache = self.state.config.cache_dir();
3286 let ctx = WaveCtx {
3287 carry_seats: false,
3288 run: &run_id,
3289 node: "judge",
3290 prompts: &prompts,
3291 cache: cache.as_deref(),
3292 round: None,
3293 };
3294 let results = ask_json_wave::<Ranking>(
3295 judge_jobs,
3296 Arc::clone(&self.sem),
3297 retries,
3298 &self.roles.judge_roster,
3299 &ctx,
3300 &mut judge_losses,
3301 &mut self.state,
3302 &move |r: &Ranking| r.validate(&labels_for_check),
3303 )
3304 .await;
3305
3306 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3308 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3309 let agent_id = seat.agent.clone();
3310 self.state.seats.insert(seat.key.clone(), seat);
3311 let record = &mut self.state.judgements[j];
3312 match res {
3313 Ok((ranking, out)) => {
3314 record.agent = agent_id;
3315 record.ranking = ranking.normalized();
3316 record.reasons = ranking.reasons;
3317 record.confidence = ranking.confidence;
3318 record.failed = None;
3319 record.duration_ms = out.duration_ms;
3320 recovered.insert(j);
3321 self.state.event(
3322 "recover",
3323 format!("judge {} ranked again after the limit", j + 1),
3324 );
3325 }
3326 Err(e) => {
3327 self.state
3328 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3329 }
3330 }
3331 }
3332
3333 let mut vote_jobs = Vec::new();
3335 let mut vote_pos: Vec<usize> = Vec::new();
3336 for &j in &recovered {
3337 let seat_key = format!("judge-{}", j + 1);
3338 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3339 let seat = self.seat(&seat_key, &spec.id);
3340 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3341 let text = if has_context(&spec, &seat, sessions) {
3342 prompt::final_vote(&labels, &language)
3343 } else {
3344 full.clone()
3345 };
3346 vote_jobs.push(SeatJob {
3347 spec,
3348 seat,
3349 prompt: text,
3350 cwd: root.join(seat_key),
3351 timeout,
3352 allow_write: false,
3353 sessions,
3354 artifacts: artifacts.clone(),
3355 stem: format!("vote-judge-{}-recover", j + 1),
3356 handover: Some(full),
3357 });
3358 vote_pos.push(j);
3359 }
3360 let allowed = labels.clone();
3361 let mut vote_losses = Vec::new();
3362 let vote_retries = self.state.config.graph.retries;
3363 let vote_cache = self.state.config.cache_dir();
3364 let ctx = WaveCtx {
3365 carry_seats: false,
3366 run: &run_id,
3367 node: "vote",
3368 prompts: &prompts,
3369 cache: vote_cache.as_deref(),
3370 round: None,
3371 };
3372 let votes = ask_json_wave::<FinalVote>(
3373 vote_jobs,
3374 Arc::clone(&self.sem),
3375 vote_retries,
3376 &self.roles.judge_roster,
3377 &ctx,
3378 &mut vote_losses,
3379 &mut self.state,
3380 &move |v: &FinalVote| match v.label() {
3381 Some(c) if allowed.contains(&c) => Ok(()),
3382 other => bail!("vote {other:?} is not one of {allowed:?}"),
3383 },
3384 )
3385 .await;
3386 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3387 let agent_id = seat.agent.clone();
3388 self.state.seats.insert(seat.key.clone(), seat);
3389 match res {
3390 Ok((v, _)) => {
3391 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3392 rec.vote = v.label();
3393 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3394 } else {
3395 self.state.votes.push(VoteRecord {
3396 judge: j + 1,
3397 agent: agent_id,
3398 vote: v.label(),
3399 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3400 changed: false,
3401 });
3402 }
3403 self.state.event(
3404 "recover",
3405 format!("judge {} voted again after the limit", j + 1),
3406 );
3407 }
3408 Err(e) => {
3409 self.state
3410 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3411 }
3412 }
3413 }
3414
3415 let recovered_keys: BTreeSet<String> = recovered
3419 .iter()
3420 .map(|&j| format!("judge-{}", j + 1))
3421 .collect();
3422 self.state
3423 .quota
3424 .retain(|q| !recovered_keys.contains(&q.seat));
3425 for loss in judge_losses.into_iter().chain(vote_losses) {
3429 if recovered_keys.contains(&loss.seat) {
3430 continue;
3431 }
3432 self.state.quota.retain(|q| q.seat != loss.seat);
3433 self.state.quota.push(loss);
3434 }
3435
3436 self.state.tally = None;
3438 self.tally()?;
3439 Ok(self
3440 .state
3441 .tally
3442 .as_ref()
3443 .map(|t| t.met_quorum)
3444 .unwrap_or(false))
3445 }
3446
3447 async fn fold_losers(&mut self) -> Result<()> {
3450 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3451 return Ok(());
3452 };
3453 let repo = self.state.repo.clone();
3454 let mut folded = Vec::new();
3455 for i in 0..self.state.candidates.len() {
3456 let c = &self.state.candidates[i];
3457 if c.label == winner || c.folded {
3458 continue;
3459 }
3460 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3461 git::worktree_remove(&repo, &wt).await.ok();
3462 git::branch_delete(&repo, &branch).await.ok();
3463 self.state.candidates[i].folded = true;
3464 folded.push(label.to_string());
3465 }
3466 let root = self.state.worktree_root();
3468 for j in 1..=self.roles.judges.len() {
3469 let wt = root.join(format!("judge-{j}"));
3470 if wt.exists() {
3471 git::worktree_remove(&repo, &wt).await.ok();
3472 }
3473 }
3474 if self.state.config.graph.advise {
3477 for k in 1..=self.state.config.graph.advisors {
3478 let wt = root.join(format!("advisor-{k}"));
3479 if wt.exists() {
3480 git::worktree_remove(&repo, &wt).await.ok();
3481 }
3482 }
3483 }
3484 if !folded.is_empty() {
3485 self.state
3486 .event("fold", format!("folded candidates {}", folded.join(", ")));
3487 self.state.save()?;
3488 }
3489 Ok(())
3490 }
3491
3492 async fn sync_to_base(&mut self) -> Result<()> {
3532 if self.state.status == RunStatus::AlreadyInBase {
3533 return Ok(());
3534 }
3535 let conflicted = self
3536 .state
3537 .base_sync
3538 .as_ref()
3539 .is_some_and(|s| s.conflict.is_some());
3540 let Some(winner) = self.state.winner().cloned() else {
3541 return Ok(());
3542 };
3543
3544 let repo = self.state.repo.clone();
3545 let remote = self.state.config.merge.remote.clone();
3546 let base_branch = self.state.base_branch.clone();
3547 let tracking = format!("{remote}/{base_branch}");
3548
3549 git::fetch(&repo, &remote, &base_branch).await.ok();
3550 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3555 return Ok(());
3556 };
3557
3558 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3559 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3560 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3561
3562 if (behind > 0 || conflicted || head == tip)
3571 && self
3572 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3573 .await?
3574 {
3575 return Ok(());
3576 }
3577 if conflicted {
3578 return Ok(());
3579 }
3580
3581 if behind == 0 {
3582 if let Some(from) = self
3591 .state
3592 .rebase_fixes
3593 .iter()
3594 .rev()
3595 .find_map(|r| r.from.clone())
3596 && from != head
3597 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3598 .await
3599 .is_ok_and(|o| o.ok())
3600 {
3601 git::sync_to_head(&winner.worktree).await?;
3602 }
3603 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3609 if let Some(why) = &conflict {
3610 self.state.status = RunStatus::Blocked;
3611 self.state.event("land", why.clone());
3612 }
3613 self.state.base_sync = Some(BaseSync {
3614 tip,
3615 behind: 0,
3616 attempts,
3617 conflict,
3618 already_in: None,
3619 });
3620 self.state.save()?;
3621 return Ok(());
3622 }
3623
3624 if attempts >= BASE_SYNC_ROUNDS {
3625 let why = format!(
3626 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3627 rebase(s); rebasing again would only race it",
3628 winner.branch
3629 );
3630 self.state.status = RunStatus::Blocked;
3631 self.state.base_sync = Some(BaseSync {
3632 tip,
3633 behind,
3634 attempts,
3635 conflict: Some(why.clone()),
3636 already_in: None,
3637 });
3638 self.state.event("land", why);
3639 self.state.save()?;
3640 return Ok(());
3641 }
3642
3643 self.state.event(
3644 "land",
3645 format!(
3646 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3647 winner.branch
3648 ),
3649 );
3650 self.state.save()?;
3651
3652 let branch_tracking = format!("{remote}/{}", winner.branch);
3658 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3659 let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3660 git::rev_parse(&repo, &branch_tracking).await.ok()
3661 } else {
3662 None
3663 };
3664 if let Some(theirs) = &remote_tip
3668 && !git::is_ancestor(&repo, theirs, &head).await
3669 && !crate::reconcile::origin_missing(&repo, &head, theirs)
3670 .await
3671 .is_ok_and(|missing| missing.is_empty())
3672 {
3673 let why = format!(
3674 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3675 them",
3676 short(theirs),
3677 winner.branch
3678 );
3679 self.state.status = RunStatus::Blocked;
3680 self.state.base_sync = Some(BaseSync {
3681 tip,
3682 behind,
3683 attempts,
3684 conflict: Some(why.clone()),
3685 already_in: None,
3686 });
3687 self.state.event("land", why);
3688 self.state.save()?;
3689 return Ok(());
3690 }
3691
3692 let scratch = self.state.dir().join("base-sync");
3693 let rebased = match crate::rebase::rebase_with_fixer(
3694 &mut self.state,
3695 &scratch,
3696 &winner.branch,
3697 &tracking,
3698 )
3699 .await
3700 {
3701 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3702 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3703 Err(e) => Err(e),
3704 };
3705 let attempts = attempts + 1;
3706 match rebased {
3707 Ok(None) => {
3708 git::sync_to_head(&winner.worktree).await?;
3712 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3713 let mut conflict = None;
3714 if let Some(pinned) = &remote_tip {
3715 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3716 match pushed {
3717 Ok(o) if o.ok() => self.state.event(
3718 "land",
3719 format!("pushed rebased {} to {remote}", winner.branch),
3720 ),
3721 Ok(o) => {
3722 conflict = Some(format!(
3723 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
3724 winner.branch,
3725 short(pinned),
3726 o.stderr.chars().take(600).collect::<String>()
3727 ));
3728 }
3729 Err(e) => {
3730 conflict = Some(format!(
3731 "rebased {} locally but could not push it: {e:#}",
3732 winner.branch
3733 ));
3734 }
3735 }
3736 }
3737 if let Some(why) = &conflict {
3738 self.state.status = RunStatus::Blocked;
3739 self.state.event("land", why.clone());
3740 }
3741 self.state.base_sync = Some(BaseSync {
3742 tip: tip.clone(),
3743 behind: 0,
3744 attempts,
3745 conflict,
3746 already_in: None,
3747 });
3748 self.state
3749 .event("land", format!("rebased {} onto {tracking}", winner.branch));
3750 }
3751 Ok(Some(conflict)) => {
3752 let why = format!(
3753 "{} conflicts with {tracking} and did not rebase: {}",
3754 winner.branch,
3755 conflict.chars().take(600).collect::<String>()
3756 );
3757 self.state.status = RunStatus::Blocked;
3758 self.state.base_sync = Some(BaseSync {
3759 tip,
3760 behind,
3761 attempts,
3762 conflict: Some(why.clone()),
3763 already_in: None,
3764 });
3765 self.state.event("land", why);
3766 }
3767 Err(e) => {
3768 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3769 self.state.status = RunStatus::Blocked;
3770 self.state.base_sync = Some(BaseSync {
3771 tip,
3772 behind,
3773 attempts,
3774 conflict: Some(why.clone()),
3775 already_in: None,
3776 });
3777 self.state.event("land", why);
3778 }
3779 }
3780 self.state.save()?;
3781 Ok(())
3782 }
3783
3784 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3790 let repo = self.state.repo.clone();
3791 let remote = self.state.config.merge.remote.clone();
3792 let fetched = git::fetch(&repo, &remote, branch).await;
3793 if !matches!(&fetched, Ok(o) if o.ok()) {
3794 return None;
3795 }
3796 let branch_tracking = format!("{remote}/{branch}");
3797 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3798 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3799 return None;
3800 }
3801 if !crate::reconcile::origin_missing(&repo, head, &theirs)
3802 .await
3803 .is_ok_and(|missing| missing.is_empty())
3804 {
3805 return Some(format!(
3806 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3807 them",
3808 short(&theirs)
3809 ));
3810 }
3811 match git::push_pinned(&repo, &remote, branch, &theirs).await {
3812 Ok(o) if o.ok() => {
3813 self.state
3814 .event("land", format!("pushed rebased {branch} to {remote}"));
3815 None
3816 }
3817 Ok(o) => Some(format!(
3818 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3819 someone may have pushed): {}",
3820 short(&theirs),
3821 o.stderr.chars().take(600).collect::<String>()
3822 )),
3823 Err(e) => Some(format!(
3824 "{branch} is rebased locally but could not be pushed: {e:#}"
3825 )),
3826 }
3827 }
3828
3829 async fn settle_already_in(
3846 &mut self,
3847 branch: &str,
3848 tip: &str,
3849 head: &str,
3850 attempts: usize,
3851 behind: usize,
3852 ) -> Result<bool> {
3853 let repo = self.state.repo.clone();
3854 let remote = self.state.config.merge.remote.clone();
3855 let start = self.state.base_commit.clone();
3856 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3857 Ok(Some(e)) => e,
3858 Ok(None) => return Ok(false),
3859 Err(e) => {
3860 tracing::warn!("already-in-base check for {branch}: {e:#}");
3861 return Ok(false);
3862 }
3863 };
3864 let mut verified = vec![head.to_owned()];
3865 let fetched = git::fetch(&repo, &remote, branch).await;
3866 if matches!(&fetched, Ok(o) if o.ok())
3867 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3868 && theirs != head
3869 {
3870 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3871 Ok(Some(_)) => verified.push(theirs),
3872 _ => return Ok(false),
3873 }
3874 }
3875 let base_branch = self.state.base_branch.clone();
3876 let message = format!(
3877 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3878 land",
3879 evidence.names(),
3880 evidence.proof.as_str()
3881 );
3882 let closed =
3883 crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3884 match closed {
3885 Ok(Ok(url)) => self
3886 .state
3887 .event("land", format!("closed {url}: superseded on {base_branch}")),
3888 Ok(Err(why)) => self
3889 .state
3890 .event("land", format!("did not close a pull request: {why}")),
3891 Err(e) => {
3892 let why = format!(
3893 "{branch} is already in {remote}/{base_branch}, but its pull request could \
3894 not be closed ({e:#}); resume to retry"
3895 );
3896 self.state.status = RunStatus::Blocked;
3897 self.state.base_sync = Some(BaseSync {
3898 tip: tip.to_owned(),
3899 behind,
3900 attempts,
3901 conflict: Some(why.clone()),
3902 already_in: None,
3903 });
3904 self.state.event("land", why);
3905 self.state.save()?;
3906 return Ok(true);
3907 }
3908 }
3909 self.state.status = RunStatus::AlreadyInBase;
3910 self.state.base_sync = Some(BaseSync {
3911 tip: tip.to_owned(),
3912 behind,
3913 attempts,
3914 conflict: None,
3915 already_in: Some(evidence),
3916 });
3917 self.state.event("land", message);
3918 self.state.save()?;
3919 self.settle_questions();
3920 Ok(true)
3921 }
3922
3923 fn landing_base(&self) -> String {
3933 self.state
3934 .base_sync
3935 .as_ref()
3936 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3937 }
3938
3939 pub async fn fix_selected(
3972 &mut self,
3973 ids: &[String],
3974 reason: &str,
3975 allow_stale: bool,
3976 ) -> Result<()> {
3977 let reason = reason.trim();
3978 if reason.is_empty() {
3979 bail!("a fix request needs a reason — that is the operator's own record of why");
3980 }
3981 if ids.is_empty() {
3982 bail!("no finding id given");
3983 }
3984 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3985 bail!(
3986 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3987 has already concluded — can be given a targeted fix. A run still \
3988 in progress should simply be resumed; a `merged` run's branch has \
3989 already landed, so its answer is a fresh `magi review <branch>`, \
3990 not reopening this run's own record",
3991 self.state.id,
3992 self.state.status.as_str()
3993 );
3994 }
3995 let Some(winner) = self.state.winner().cloned() else {
3996 bail!("run {} has no winning candidate to fix", self.state.id);
3997 };
3998 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
3999 bail!(
4000 "branch `{}` no longer exists; this run cannot be extended",
4001 winner.branch
4002 );
4003 }
4004 let home = crate::run::home();
4005 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4006 bail!(
4007 "run {} is currently being worked on by another magi process",
4008 self.state.id
4009 );
4010 }
4011 let _claim = FixClaim::acquire(&self.state.dir())?;
4017
4018 let mut seen = BTreeSet::new();
4022 let mut findings = Vec::new();
4023 let mut missing = Vec::new();
4024 for id in ids {
4025 if !seen.insert(id.clone()) {
4026 continue;
4027 }
4028 match self.state.finding(id) {
4029 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4030 id: f.id.clone(),
4031 severity: f.severity,
4032 reviewer_vote: rec.vote,
4033 round: round.round,
4034 round_head: round.head.clone(),
4035 reviewer: rec.reviewer,
4036 agent: rec.agent.clone(),
4037 file: f.file.clone(),
4038 line: f.line,
4039 title: f.title.clone(),
4040 detail: f.detail.clone(),
4041 outcome: OperatorFixOutcome::Pending,
4042 }),
4043 None => missing.push(id.clone()),
4044 }
4045 }
4046 if !missing.is_empty() {
4047 bail!(
4048 "unknown finding id(s): {}; nothing was changed",
4049 missing.join(", ")
4050 );
4051 }
4052
4053 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4054 let stale_details: Vec<(String, String)> = findings
4055 .iter()
4056 .filter(|f| f.round_head != head_at_request)
4057 .map(|f| (f.id.clone(), f.round_head.clone()))
4058 .collect();
4059 let stale = !stale_details.is_empty();
4060 if stale && !allow_stale {
4061 bail!(
4062 "the branch has moved since some finding(s) were raised — {} — now \
4063 at {}; pass --allow-stale to fix anyway, or re-run review first",
4064 stale_details
4065 .iter()
4066 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4067 .collect::<Vec<_>>()
4068 .join(", "),
4069 short(&head_at_request)
4070 );
4071 }
4072
4073 let request = OperatorFixRequest {
4074 requested_at: Timestamp::now(),
4075 reason: reason.to_owned(),
4076 findings,
4077 head_at_request: head_at_request.clone(),
4078 allow_stale,
4079 stale,
4080 fix: None,
4081 result_head: None,
4082 follow_up_review_run: None,
4083 };
4084 self.state.event(
4085 "fix",
4086 format!(
4087 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4088 request.findings.len(),
4089 request
4090 .findings
4091 .iter()
4092 .map(|f| f.id.as_str())
4093 .collect::<Vec<_>>()
4094 .join(", "),
4095 ),
4096 );
4097 self.state.operator_fixes.push(request);
4104 self.state.save()?;
4105 let request_index = self.state.operator_fixes.len() - 1;
4106
4107 if winner.worktree.exists() {
4116 let dirty = git::git(
4119 &winner.worktree,
4120 &["status", "--porcelain", "--untracked-files=all"],
4121 )
4122 .await?;
4123 let only_withheld = dirty.lines().all(|l| {
4124 l.strip_prefix("?? ")
4125 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4126 });
4127 if !only_withheld {
4128 bail!(
4129 "`{}` has uncommitted changes; refusing to touch it — commit or \
4130 discard them first",
4131 winner.worktree.display()
4132 );
4133 }
4134 git::worktree_remove(&self.state.repo, &winner.worktree)
4135 .await
4136 .ok();
4137 }
4138 let fix_worktree = self.state.worktree_root().join("operator-fix");
4139 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4140 git::git(
4141 &self.state.repo,
4142 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4143 )
4144 .await
4145 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4146 if !git::is_clean(&fix_worktree).await? {
4147 git::worktree_remove(&self.state.repo, &fix_worktree)
4148 .await
4149 .ok();
4150 bail!(
4151 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4152 winner.branch
4153 );
4154 }
4155 let repo_for_setup = self.state.repo.clone();
4156 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4157 Ok(()) => {
4158 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4159 }
4160 Err(e) => Err(e),
4161 };
4162 if let Err(e) = setup {
4163 git::worktree_remove(&self.state.repo, &fix_worktree)
4164 .await
4165 .ok();
4166 return Err(e);
4167 }
4168
4169 let run_id = self.state.id.clone();
4170 let prompts = self.state.config.prompts.clone();
4171 let language = self.state.config.graph.language.clone();
4172 let sessions = self.state.config.graph.sessions;
4173 let artifacts = agent::artifacts_dir(&self.state.dir());
4174 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4175 .findings
4176 .iter()
4177 .map(|f| Finding {
4178 id: f.id.clone(),
4179 severity: f.severity,
4180 file: f.file.clone(),
4181 line: f.line,
4182 title: f.title.clone(),
4183 detail: f.detail.clone(),
4184 })
4185 .collect();
4186 let fix_prompt = prompt::operator_fix(
4187 &self.state.instruction,
4188 &finding_list,
4189 reason,
4190 &stale_details,
4191 &head_at_request,
4192 &language,
4193 );
4194 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4195 let (job, seat, out) = self
4196 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4197 prompt: fix_prompt.clone(),
4198 spec,
4199 seat,
4200 cwd: fix_worktree.clone(),
4201 timeout,
4202 allow_write: true,
4203 sessions,
4204 artifacts: artifacts.clone(),
4205 stem: "operator-fix".to_owned(),
4206 handover: None,
4207 })
4208 .await;
4209 let agent_id = seat.agent.clone();
4210
4211 let mut fix = FixRecord {
4212 agent: agent_id,
4213 addressed: Vec::new(),
4214 rejected: Vec::new(),
4215 notes: String::new(),
4216 committed: false,
4217 failed: None,
4218 duration_ms: 0,
4219 continuation: None,
4220 };
4221 let mut final_seat = seat.clone();
4222 match out {
4223 AgentOutcome::Ok(o) => {
4224 fix.duration_ms = o.duration_ms;
4225 let parsed = verdict::extract_json::<FixReport>(&o.text);
4226 let incomplete_reason = match &parsed {
4227 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4228 "the reply parsed, but it reported a command whose own CLI \
4229 never confirmed an exit status"
4230 .to_owned(),
4231 ),
4232 Ok(_) => None,
4233 Err(e) => Some(e.to_string()),
4234 };
4235 match incomplete_reason {
4236 None => {
4237 let report = parsed.expect("checked Ok above");
4238 fix.addressed = report.addressed;
4239 fix.rejected = report.rejected;
4240 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4241 }
4242 Some(reason) => {
4243 let (resumed_seat, resolved, failure, cont) = self
4244 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4245 .await;
4246 fix.duration_ms += cont.cumulative_wait_ms;
4247 fix.continuation = Some(cont);
4248 final_seat = resumed_seat;
4249 match resolved {
4250 Some(report) => {
4251 fix.addressed = report.addressed;
4252 fix.rejected = report.rejected;
4253 fix.notes =
4254 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4255 }
4256 None => fix.failed = failure,
4257 }
4258 }
4259 }
4260 }
4261 AgentOutcome::Dropped(o) => {
4262 fix.duration_ms = o.duration_ms;
4263 let why = o
4264 .dropped
4265 .as_ref()
4266 .map(|d| d.why.as_str())
4267 .unwrap_or("the CLI ended the stream without delivering its answer");
4268 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4269 }
4270 AgentOutcome::Quota(o) => {
4271 self.state.quota.push(QuotaLoss {
4272 seat: final_seat.key.clone(),
4273 node: "fix".to_owned(),
4274 at: Timestamp::now(),
4275 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4276 });
4277 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4278 }
4279 AgentOutcome::Failed(e) => fix.failed = Some(e),
4280 }
4281 if fix.continuation.is_none() {
4282 fix.continuation = Some(ContinuationRecord::not_needed());
4283 }
4284 self.state.seats.insert(final_seat.key.clone(), final_seat);
4285
4286 let rescue_message = format!(
4287 "magi: operator-selected fix ({}) (uncommitted work)",
4288 self.state.operator_fixes[request_index]
4289 .findings
4290 .iter()
4291 .map(|f| f.id.as_str())
4292 .collect::<Vec<_>>()
4293 .join(", ")
4294 );
4295 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4296 self.state.note_withheld("fix", &r.withheld);
4297 }
4298 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4299 fix.committed = after != head_at_request;
4300 git::worktree_remove(&self.state.repo, &fix_worktree)
4301 .await
4302 .ok();
4303
4304 self.state.event(
4305 "fix",
4306 match &fix.failed {
4307 Some(reason) => format!(
4308 "operator fix: adoption report was lost ({reason}); {}",
4309 if fix.committed {
4310 "committed"
4311 } else {
4312 "NO new commit"
4313 }
4314 ),
4315 None => format!(
4316 "operator fix: {} addressed, {} rejected, {}",
4317 fix.addressed.len(),
4318 fix.rejected.len(),
4319 if fix.committed {
4320 "committed"
4321 } else {
4322 "NO new commit"
4323 }
4324 ),
4325 },
4326 );
4327
4328 for f in &mut self.state.operator_fixes[request_index].findings {
4335 f.outcome = if fix.failed.is_some() {
4336 OperatorFixOutcome::Unreported
4337 } else if fix.addressed.contains(&f.id) {
4338 OperatorFixOutcome::Addressed
4339 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4340 OperatorFixOutcome::Rejected { why: r.why.clone() }
4341 } else {
4342 OperatorFixOutcome::Unreported
4343 };
4344 }
4345
4346 let committed = fix.committed;
4347 if committed {
4348 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4349 }
4350 self.state.operator_fixes[request_index].fix = Some(fix);
4351 self.state.save()?;
4354
4355 if committed {
4356 self.state.event(
4357 "fix",
4358 format!(
4359 "operator fix committed {}; opening a follow-up review-only run",
4360 short(&after)
4361 ),
4362 );
4363 let origin =
4366 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4367 match Self::review(
4368 &self.state.repo,
4369 &winner.branch,
4370 self.state.config.clone(),
4371 origin,
4372 )
4373 .await
4374 {
4375 Ok(mut follow_up) => {
4376 follow_up.state.event(
4377 "start",
4378 format!(
4379 "requested by an operator fix on run {} for finding(s) {}",
4380 self.state.id,
4381 self.state.operator_fixes[request_index]
4382 .findings
4383 .iter()
4384 .map(|f| f.id.as_str())
4385 .collect::<Vec<_>>()
4386 .join(", "),
4387 ),
4388 );
4389 follow_up.state.save()?;
4390 let follow_up_id = follow_up.state.id.clone();
4391 let adopted = match crate::direct::adopt(
4394 &follow_up.state,
4395 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4396 ) {
4397 Ok(a) => a,
4398 Err(e) => {
4399 self.state.event(
4402 "fix",
4403 format!(
4404 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4405 ),
4406 );
4407 self.state.save()?;
4408 return Ok(());
4409 }
4410 };
4411 let executed = follow_up.execute().await;
4412 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4413 if let Some(a) = adopted {
4414 a.finish(&follow_up.state, executed);
4415 }
4416 if let Some(e) = failure {
4417 self.state.event(
4418 "fix",
4419 format!(
4420 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4421 ),
4422 );
4423 }
4424 self.state.operator_fixes[request_index].follow_up_review_run =
4425 Some(follow_up_id);
4426 }
4427 Err(e) => {
4428 self.state.event(
4429 "fix",
4430 format!("committed the fix but could not open a follow-up review: {e:#}"),
4431 );
4432 }
4433 }
4434 self.state.save()?;
4435 }
4436
4437 Ok(())
4438 }
4439
4440 async fn ask_fixer(
4460 &mut self,
4461 winner: &Candidate,
4462 node: &'static str,
4463 round: Option<usize>,
4464 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4465 ) -> (SeatJob, SeatState, AgentOutcome) {
4466 let run_id = self.state.id.clone();
4467 let prompts = self.state.config.prompts.clone();
4468 let cache = self.state.config.cache_dir();
4469 let attempts = fixer::attempts(&self.state, &self.roles, winner);
4470 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4471 let mut last = None;
4472 for (i, (spec, key)) in attempts.into_iter().enumerate() {
4473 let seat = self.seat(&key, &spec.id);
4474 let mut job = build(spec.clone(), seat);
4475 if i > 0 {
4476 job.stem = format!("{}-{}", job.stem, spec.id);
4477 }
4478 let ctx = WaveCtx {
4479 carry_seats: false,
4480 run: &run_id,
4481 node,
4482 prompts: &prompts,
4483 cache: cache.as_deref(),
4484 round,
4485 };
4486 let (seat, out) =
4487 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4488 let advances = match &out {
4489 AgentOutcome::Ok(o) => agent::output_advances(o),
4490 _ => true,
4491 };
4492 if let Some(next) = ids.get(i + 1)
4493 && advances
4494 {
4495 self.state.seats.insert(seat.key.clone(), seat.clone());
4496 let class =
4497 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
4498 let reason = match &out {
4499 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
4500 other => fail_reason(other),
4501 };
4502 record_handover(
4503 &mut self.state,
4504 node,
4505 &seat.key,
4506 &spec.id,
4507 next,
4508 &class,
4509 &reason,
4510 );
4511 continue;
4512 }
4513 last = Some((job, seat, out));
4514 break;
4515 }
4516 last.expect("the fixer chain always has an entry")
4517 }
4518
4519 async fn review_loop(&mut self) -> Result<()> {
4520 if self
4525 .state
4526 .base_sync
4527 .as_ref()
4528 .is_some_and(|s| s.conflict.is_some())
4529 {
4530 return Ok(());
4531 }
4532 let run_id = self.state.id.clone();
4537 let prompts = self.state.config.prompts.clone();
4538 let Some(winner) = self.state.winner().cloned() else {
4539 return Ok(());
4540 };
4541 let max_rounds = self.state.config.graph.review_rounds;
4542 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4552 if status == RunStatus::Gating {
4555 self.record_contested_handoff();
4556 }
4557 self.state.status = status;
4558 self.state.save()?;
4559 return Ok(());
4560 }
4561 self.state.status = RunStatus::Reviewing;
4562 if self
4572 .state
4573 .reviews
4574 .last()
4575 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4576 {
4577 let shell = self.state.config.shell();
4578 return self
4579 .stop_reviewing(
4580 "the last round's own verification never resolved",
4581 &shell,
4582 &winner.worktree,
4583 )
4584 .await;
4585 }
4586
4587 let repo = self.state.repo.clone();
4588 let root = self.state.worktree_root();
4589 let language = self.state.config.graph.language.clone();
4590 let sessions = self.state.config.graph.sessions;
4591 let artifacts = agent::artifacts_dir(&self.state.dir());
4592 let base = self.landing_base();
4593 let base_short = short(&base);
4594 let reviewers = self.roles.reviewers.clone();
4595 let shell = self.state.config.shell();
4596
4597 for round in (self.state.reviews.len() + 1)..=max_rounds {
4598 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4599 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4600 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4601 let prev_verification = self
4610 .state
4611 .reviews
4612 .last()
4613 .and_then(|r| r.verification_summary(&head));
4614
4615 let mut jobs = Vec::new();
4619 for (r, spec) in reviewers.iter().cloned().enumerate() {
4620 let wt = root.join(format!("review-{}", r + 1));
4621 if wt.exists() {
4624 git::reset_detached(&wt, &head).await?;
4625 } else {
4626 git::worktree_add_detached(&repo, &wt, &head).await?;
4627 }
4628 self.setup_seat_worktree(&repo, &wt, false).await?;
4629 let seat_key = format!("review-{}", r + 1);
4630 let spec = pick_start_spec(
4633 &self.roles.reviewer_roster,
4634 spec,
4635 self.state.seat_history.get(&seat_key),
4636 );
4637 let seat = self.seat(&seat_key, &spec.id);
4638 jobs.push(SeatJob {
4639 prompt: prompt::review(&prompt::ReviewCtx {
4640 instruction: &self.state.instruction,
4641 branch: &winner.branch,
4642 base_short: &base_short,
4643 stat: &stat,
4644 patch: &patch,
4645 verification: prev_verification.as_ref(),
4646 reviewers: reviewers.len(),
4647 round,
4648 rounds: max_rounds,
4649 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4652 lens: Lens::for_seat(r),
4653 language: &language,
4654 }),
4655 spec,
4656 seat,
4657 cwd: wt,
4658 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4659 allow_write: false,
4660 sessions,
4661 artifacts: artifacts.clone(),
4662 stem: format!("review-{round}-{}", r + 1),
4663 handover: None,
4664 });
4665 }
4666
4667 self.state.event(
4668 "review",
4669 format!(
4670 "round {round}: {} reviewers on {}",
4671 jobs.len(),
4672 short(&head)
4673 ),
4674 );
4675 let mut quota_losses = Vec::new();
4676 let review_retries = self.state.config.graph.retries;
4677 let review_cache = self.state.config.cache_dir();
4678 let ctx = WaveCtx {
4679 carry_seats: true,
4680 run: &run_id,
4681 node: "review",
4682 prompts: &prompts,
4683 cache: review_cache.as_deref(),
4684 round: Some(round),
4685 };
4686 let results = ask_json_wave::<Review>(
4687 jobs,
4688 Arc::clone(&self.sem),
4689 review_retries,
4690 &self.roles.reviewer_roster,
4691 &ctx,
4692 &mut quota_losses,
4693 &mut self.state,
4694 &|_: &Review| Ok(()),
4695 )
4696 .await;
4697 let round_quota_missing = quota_losses.len();
4701 self.state.quota.extend(quota_losses);
4702
4703 let mut records = Vec::new();
4704 let mut all_findings = Vec::new();
4705 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4706 let agent_id = seat.agent.clone();
4707 self.state.seats.insert(seat.key.clone(), seat);
4708 let mut record = ReviewRecord {
4709 reviewer: r + 1,
4710 agent: agent_id,
4711 summary: String::new(),
4712 findings: Vec::new(),
4713 vote: None,
4714 failed: None,
4715 duration_ms: 0,
4716 attempts,
4722 };
4723 match res {
4724 Ok((review, out)) => {
4725 record.summary =
4733 blind::sanitize_prose(&review.summary, &self.state.config.blind);
4734 record.vote = Some(review.vote);
4735 record.duration_ms = out.duration_ms;
4736 for (n, mut f) in review.findings.into_iter().enumerate() {
4737 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4740 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4741 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4742 f.file = f
4748 .file
4749 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4750 all_findings.push(f.clone());
4751 record.findings.push(f);
4752 }
4753 self.state.event(
4754 "review",
4755 format!(
4756 "round {round}: reviewer {} voted {} with {} finding(s)",
4757 r + 1,
4758 review.vote.label(),
4759 record.findings.len()
4760 ),
4761 );
4762 }
4763 Err(e) => {
4764 record.failed = Some(e.to_string());
4765 self.state.event(
4766 "review",
4767 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4768 );
4769 }
4770 }
4771 records.push(record);
4772 }
4773
4774 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4781 let vote_split =
4782 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4783 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4784 if vote_split {
4785 self.state.event(
4786 "review",
4787 format!(
4788 "round {round}: votes split ({}) — one round of reconsideration",
4789 initial_votes
4790 .iter()
4791 .map(|v| v.label())
4792 .collect::<Vec<_>>()
4793 .join(", ")
4794 ),
4795 );
4796 let panel: Vec<ReviewSeatReport<'_>> = records
4799 .iter()
4800 .filter_map(|r| {
4801 r.vote.map(|vote| ReviewSeatReport {
4802 reviewer: r.reviewer,
4803 vote,
4804 summary: &r.summary,
4805 findings: &r.findings,
4806 })
4807 })
4808 .collect();
4809
4810 let mut jobs = Vec::new();
4811 let mut seats_at = Vec::new();
4812 for (r, spec) in reviewers.iter().cloned().enumerate() {
4813 if records[r].vote.is_none() {
4817 continue;
4818 }
4819 let wt = root.join(format!("review-{}", r + 1));
4820 let seat_key = format!("review-{}", r + 1);
4821 let spec = self.occupant(&seat_key, spec);
4822 let seat = self.seat(&seat_key, &spec.id);
4823 let build = |with_patch: bool| {
4831 prompt::review_reconsider(&ReviewReconsiderCtx {
4832 instruction: &self.state.instruction,
4833 reviewer: r + 1,
4834 lens: Lens::for_seat(r),
4835 panel: &panel,
4836 patch: with_patch.then_some(ReviewPatch {
4837 branch: &winner.branch,
4838 base_short: &base_short,
4839 stat: &stat,
4840 patch: &patch,
4841 }),
4842 round,
4843 rounds: max_rounds,
4844 language: &language,
4845 })
4846 };
4847 let full = build(true);
4848 let prompt = if has_context(&spec, &seat, sessions) {
4849 build(false)
4850 } else {
4851 full.clone()
4852 };
4853 jobs.push(SeatJob {
4854 prompt,
4855 spec,
4856 seat,
4857 cwd: wt,
4858 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4859 allow_write: false,
4860 sessions,
4861 artifacts: artifacts.clone(),
4862 stem: format!("review-{round}-reconsider-{}", r + 1),
4863 handover: Some(full),
4864 });
4865 seats_at.push(r);
4866 }
4867
4868 let mut recon_quota_losses = Vec::new();
4869 let recon_cache = self.state.config.cache_dir();
4870 let recon_ctx = WaveCtx {
4871 carry_seats: true,
4872 run: &run_id,
4873 node: "review",
4874 prompts: &prompts,
4875 cache: recon_cache.as_deref(),
4876 round: Some(round),
4877 };
4878 let recon_results = ask_json_wave::<ReviewRevote>(
4879 jobs,
4880 Arc::clone(&self.sem),
4881 review_retries,
4882 &self.roles.reviewer_roster,
4883 &recon_ctx,
4884 &mut recon_quota_losses,
4885 &mut self.state,
4886 &|_: &ReviewRevote| Ok(()),
4887 )
4888 .await;
4889 self.state.quota.extend(recon_quota_losses);
4890
4891 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4892 let agent_id = seat.agent.clone();
4893 self.state.seats.insert(seat.key.clone(), seat);
4894 let mut rec = ReviewRevoteRecord {
4895 reviewer: r + 1,
4896 agent: agent_id,
4897 vote: None,
4898 reason: String::new(),
4899 failed: None,
4900 };
4901 match res {
4902 Ok((rv, _)) => {
4903 rec.vote = Some(rv.vote);
4904 rec.reason =
4905 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4906 self.state.event(
4907 "review",
4908 format!(
4909 "round {round}: reviewer {} revoted {}",
4910 r + 1,
4911 rv.vote.label()
4912 ),
4913 );
4914 }
4915 Err(e) => {
4916 rec.failed = Some(e.to_string());
4917 self.state.event(
4918 "review",
4919 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4920 );
4921 }
4922 }
4923 reconsideration.push(rec);
4924 }
4925 } else if initial_votes.len() > 1 {
4926 self.state.event(
4927 "review",
4928 format!(
4929 "round {round}: votes agreed ({}) — no reconsideration",
4930 initial_votes[0].label()
4931 ),
4932 );
4933 }
4934
4935 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4936 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4937 let defer_e2e =
4948 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4949 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4950 let reason =
4951 format!("{blocking} blocking finding(s) already required a fix this round");
4952 self.state.event(
4953 "verify",
4954 format!(
4955 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4956 {}); it will run once a round has none left",
4957 short(&head)
4958 ),
4959 );
4960 (Vec::new(), false, true, Some(reason))
4961 } else {
4962 let e2e_commands = self.state.config.verify.e2e.clone();
4963 let cache_dir = self.state.config.cache_dir();
4964 let context = format!("round {round}");
4965 let (e2e, verify_retried) = with_cache_lease(
4966 &mut self.state,
4967 cache_dir.as_deref(),
4968 "e2e",
4969 "e2e",
4970 &winner.worktree,
4971 &head,
4972 verify_timeout,
4973 &context,
4974 |state, budget| {
4975 let shell = shell.clone();
4976 let e2e_commands = e2e_commands.clone();
4977 let worktree = winner.worktree.clone();
4978 let context = context.clone();
4979 async move {
4980 run_e2e_with_retry(
4981 state,
4982 &shell,
4983 &e2e_commands,
4984 &worktree,
4985 budget,
4986 &context,
4987 )
4988 .await
4989 }
4990 },
4991 )
4992 .await;
4993 (e2e, verify_retried, false, None)
4994 };
4995
4996 let expected = records.len();
4997 let answered = records.iter().filter(|r| r.failed.is_none()).count();
4998 let incomplete = answered < expected;
4999 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5000 let policy = self.state.config.graph.incomplete_review;
5001 let clean = round_is_clean(
5002 blocking,
5003 e2e_ok,
5004 answered,
5005 expected,
5006 round_quota_missing,
5007 policy,
5008 );
5009
5010 let mut round_record = ReviewRound {
5011 round,
5012 head: head.clone(),
5013 verified_head: None,
5014 verified_at: None,
5015 reviews: records,
5016 e2e,
5017 verify_retried,
5018 e2e_deferred,
5019 e2e_defer_reason,
5020 fix: None,
5021 blocking,
5022 answered,
5023 expected,
5024 clean,
5025 progressed: false,
5026 vote_split,
5027 reconsideration,
5028 verdict: None,
5029 };
5030 round_record.verdict = ReviewVote::worst(
5034 round_record
5035 .final_votes()
5036 .into_iter()
5037 .map(|(_, _, vote)| vote),
5038 );
5039 if !matches!(
5050 round_record.e2e_status(),
5051 E2eStatus::Deferred | E2eStatus::NotConfigured
5052 ) {
5053 round_record.verified_head = Some(head.clone());
5054 round_record.verified_at = Some(Timestamp::now());
5055 }
5056 let this_round_verification = round_record.verification_summary(&head);
5057
5058 if incomplete {
5059 let missing: Vec<String> = round_record
5060 .reviews
5061 .iter()
5062 .filter(|r| r.failed.is_some())
5063 .map(|r| format!("review-{}", r.reviewer))
5064 .collect();
5065 self.state.event(
5066 "review",
5067 format!(
5068 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5069 missing.join(", ")
5070 ),
5071 );
5072 }
5073
5074 if clean {
5075 self.state.event(
5076 "review",
5077 if incomplete && policy == IncompleteReviewPolicy::Warn {
5078 format!(
5079 "round {round}: clean (warn policy, incomplete panel) — no \
5080 blocking findings from the seats that answered, verification green"
5081 )
5082 } else if incomplete {
5083 format!(
5084 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5085 quorum) — no blocking findings from the seats that answered, \
5086 verification green",
5087 expected - answered
5088 )
5089 } else {
5090 format!("round {round}: clean — no blocking findings, verification green")
5091 },
5092 );
5093 self.state.reviews.push(round_record);
5094 self.state.status = RunStatus::Gating;
5095 self.state.save()?;
5096 return Ok(());
5097 }
5098
5099 if incomplete && blocking == 0 && e2e_ok {
5107 self.state.reviews.push(round_record);
5108 self.state.save()?;
5109 if round == max_rounds {
5110 self.state.status = RunStatus::Blocked;
5111 self.state.event(
5112 "review",
5113 format!(
5114 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5115 refusing to call it clean",
5116 expected - answered
5117 ),
5118 );
5119 return Ok(());
5120 }
5121 continue;
5122 }
5123
5124 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5136 self.state.reviews.push(round_record);
5137 return self
5138 .stop_reviewing(
5139 "the round's own verification could not run",
5140 &shell,
5141 &winner.worktree,
5142 )
5143 .await;
5144 }
5145
5146 if round == max_rounds {
5147 self.state.reviews.push(round_record);
5148 return self
5149 .stop_reviewing(
5150 &format!(
5151 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5152 ),
5153 &shell,
5154 &winner.worktree,
5155 )
5156 .await;
5157 }
5158
5159 let blocking_findings: Vec<_> = all_findings
5162 .iter()
5163 .filter(|f| f.severity.blocks())
5164 .cloned()
5165 .collect();
5166 let fix_prompt = prompt::fix(
5167 &self.state.instruction,
5168 &blocking_findings,
5169 this_round_verification.as_ref(),
5170 round,
5171 max_rounds,
5172 &language,
5173 );
5174 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5175 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5176 let (job, seat, out) = self
5177 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5178 prompt: fix_prompt.clone(),
5179 spec,
5180 seat,
5181 cwd: winner.worktree.clone(),
5182 timeout,
5183 allow_write: true,
5184 sessions,
5185 artifacts: artifacts.clone(),
5186 stem: format!("fix-{round}"),
5187 handover: None,
5188 })
5189 .await;
5190 let agent_id = seat.agent.clone();
5191
5192 let mut fix = FixRecord {
5193 agent: agent_id,
5194 addressed: Vec::new(),
5195 rejected: Vec::new(),
5196 notes: String::new(),
5197 committed: false,
5198 failed: None,
5199 duration_ms: 0,
5200 continuation: None,
5201 };
5202 let mut continuation = ContinuationRecord::not_needed();
5203 let mut final_seat = seat.clone();
5204 match out {
5205 AgentOutcome::Ok(o) => {
5206 fix.duration_ms = o.duration_ms;
5207 let parsed = verdict::extract_json::<FixReport>(&o.text);
5208 let incomplete_reason = match &parsed {
5215 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5216 "the reply parsed, but it reported a command whose own CLI never \
5217 confirmed an exit status"
5218 .to_owned(),
5219 ),
5220 Ok(_) => None,
5221 Err(e) => Some(e.to_string()),
5222 };
5223 match incomplete_reason {
5224 None => {
5225 let report = parsed.expect("checked Ok above");
5226 fix.addressed = report.addressed;
5227 fix.rejected = report.rejected;
5228 fix.notes =
5229 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5230 }
5231 Some(reason) => {
5232 let (resumed_seat, resolved, failure, cont) = self
5233 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5234 .await;
5235 fix.duration_ms += cont.cumulative_wait_ms;
5236 continuation = cont;
5237 final_seat = resumed_seat;
5238 match resolved {
5239 Some(report) => {
5240 fix.addressed = report.addressed;
5241 fix.rejected = report.rejected;
5242 fix.notes = blind::sanitize_prose(
5243 &report.notes,
5244 &self.state.config.blind,
5245 );
5246 }
5247 None => fix.failed = failure,
5248 }
5249 }
5250 }
5251 }
5252 AgentOutcome::Dropped(o) => {
5254 fix.duration_ms = o.duration_ms;
5255 let why = o
5256 .dropped
5257 .as_ref()
5258 .map(|d| d.why.as_str())
5259 .unwrap_or("the CLI ended the stream without delivering its answer");
5260 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5261 }
5262 AgentOutcome::Quota(o) => {
5263 self.state.quota.push(QuotaLoss {
5264 seat: final_seat.key.clone(),
5265 node: "fix".to_owned(),
5266 at: Timestamp::now(),
5267 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5268 });
5269 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5270 }
5271 AgentOutcome::Failed(e) => fix.failed = Some(e),
5272 }
5273 fix.continuation = Some(continuation);
5274 self.state.seats.insert(final_seat.key.clone(), final_seat);
5275 if let Ok(r) = git::rescue_commit(
5276 &winner.worktree,
5277 &format!("magi: review round {round} fixes (uncommitted work)"),
5278 )
5279 .await
5280 {
5281 self.state.note_withheld("fix", &r.withheld);
5282 }
5283 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5284 fix.committed = after != before;
5285 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5293 let progressed = diff_after != patch;
5294 let commit_note = if fix.committed {
5295 "committed"
5296 } else {
5297 "NO new commit"
5298 };
5299 let tree_note = if progressed {
5300 "changed vs base"
5301 } else {
5302 "unchanged vs base"
5303 };
5304 self.state.event(
5305 "fix",
5306 match &fix.failed {
5307 Some(reason) => {
5313 format!(
5314 "round {round}: fixer's adoption report was lost ({reason}); \
5315 {commit_note}, tree {tree_note}"
5316 )
5317 }
5318 None => format!(
5319 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5320 {tree_note}{}",
5321 fix.addressed.len(),
5322 fix.rejected.len(),
5323 if continuation.outcome == ContinuationOutcome::Resumed {
5324 format!(
5325 " (adoption report recovered after {} continuation(s))",
5326 continuation.attempts
5327 )
5328 } else {
5329 String::new()
5330 },
5331 ),
5332 },
5333 );
5334 round_record.fix = Some(fix);
5335 round_record.progressed = progressed;
5336 self.state.reviews.push(round_record);
5337 self.state.save()?;
5338
5339 if matches!(
5352 continuation.outcome,
5353 ContinuationOutcome::Exhausted
5354 | ContinuationOutcome::QuotaLost
5355 | ContinuationOutcome::NoSession
5356 ) {
5357 return self
5358 .stop_reviewing(
5359 "the fixer's adoption report never came back, even after resuming its \
5360 own seat; refusing to start another round against the same worktree \
5361 while that is unresolved",
5362 &shell,
5363 &winner.worktree,
5364 )
5365 .await;
5366 }
5367
5368 let streak = self
5369 .state
5370 .reviews
5371 .iter()
5372 .rev()
5373 .take_while(|r| !r.progressed)
5374 .count();
5375 if streak >= STAGNANT_LIMIT {
5376 return self
5377 .stop_reviewing(
5378 &format!(
5379 "the tree has not moved against base for {streak} round(s) in a row"
5380 ),
5381 &shell,
5382 &winner.worktree,
5383 )
5384 .await;
5385 }
5386 }
5387 Ok(())
5388 }
5389
5390 fn record_contested_handoff(&mut self) {
5424 if self.state.contested_handoff.is_some() {
5425 return;
5426 }
5427 let Some(contested) = self
5428 .state
5429 .reviews
5430 .last()
5431 .and_then(ReviewRound::contested_handoff)
5432 else {
5433 return;
5434 };
5435 self.state.event(
5436 "review",
5437 format!(
5438 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5439 wait for the owner's approval",
5440 contested.findings.len(),
5441 contested.rejecters.len()
5442 ),
5443 );
5444 self.state.contested_handoff = Some(contested);
5445 }
5446
5447 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5448 let round_idx = self.state.reviews.len() - 1;
5449 let needs_catchup_run = matches!(
5457 self.state.reviews[round_idx].e2e_status(),
5458 E2eStatus::Deferred | E2eStatus::ResourceBlocked
5459 );
5460 if needs_catchup_run {
5461 let round = self.state.reviews[round_idx].round;
5462 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5463 let commands = self.state.config.verify.e2e.clone();
5464 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5465 let cache_dir = self.state.config.cache_dir();
5466 let context = format!(
5467 "round {round}: verification unresolved, catching up before the final decision"
5468 );
5469 let (outcomes, verify_retried) = with_cache_lease(
5470 &mut self.state,
5471 cache_dir.as_deref(),
5472 "e2e",
5473 "e2e",
5474 worktree,
5475 &attempted_head,
5476 timeout,
5477 &context,
5478 |state, budget| {
5479 let shell = shell.to_vec();
5480 let commands = commands.clone();
5481 let context = context.clone();
5482 async move {
5483 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5484 .await
5485 }
5486 },
5487 )
5488 .await;
5489 let last = &mut self.state.reviews[round_idx];
5490 last.e2e = outcomes;
5491 last.verify_retried = verify_retried;
5492 last.verified_head = Some(attempted_head);
5499 last.verified_at = Some(Timestamp::now());
5500 if verify_inconclusive(&last.e2e) {
5501 self.state.save()?;
5508 return Ok(());
5509 }
5510 last.e2e_deferred = false;
5511 }
5512 let last = &self.state.reviews[round_idx];
5513 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5514
5515 match last.e2e_status() {
5516 E2eStatus::Failed => {
5517 let red: Vec<String> = last
5518 .e2e
5519 .iter()
5520 .filter(|o| !o.ok())
5521 .map(|o| {
5522 format!(
5523 "`{}` -> {:?}\n{}",
5524 o.command,
5525 o.code,
5526 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5527 )
5528 })
5529 .collect();
5530 self.state
5531 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5532 self.state.status = RunStatus::Blocked;
5533 }
5534 E2eStatus::ResourceBlocked => {
5539 self.state.event(
5540 "review",
5541 format!(
5542 "{why}; e2e could not run (shared build cache unavailable); not \
5543 deciding yet"
5544 ),
5545 );
5546 }
5547 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5548 self.state.event(
5549 "review",
5550 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5551 );
5552 self.record_contested_handoff();
5553 self.state.status = RunStatus::Gating;
5554 }
5555 }
5556 self.state.save()?;
5557 Ok(())
5558 }
5559
5560 async fn gate(&mut self) -> Result<()> {
5563 if self.state.status == RunStatus::Failed
5575 || self
5576 .state
5577 .base_sync
5578 .as_ref()
5579 .is_some_and(|s| s.conflict.is_some())
5580 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5581 != Some(RunStatus::Gating)
5582 {
5583 return Ok(());
5584 }
5585 if self.state.gate_ran {
5586 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5597 self.state.status = RunStatus::Blocked;
5598 self.state.save()?;
5599 }
5600 return Ok(());
5601 }
5602 let Some(winner) = self.state.winner().cloned() else {
5603 return Ok(());
5604 };
5605 self.state.status = RunStatus::Gating;
5606 let mut outcomes = self.run_gate(&winner).await?;
5607 loop {
5608 if verify_inconclusive(&outcomes) {
5619 self.state.save()?;
5620 return Ok(());
5621 }
5622 if outcomes.iter().all(CommandOutcome::ok) {
5623 break;
5624 }
5625 match self.gate_fix_round(&winner, &outcomes).await? {
5626 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5627 GateFix::Stop => break,
5628 GateFix::Defer => {
5629 self.state.save()?;
5630 return Ok(());
5631 }
5632 }
5633 }
5634 let passed = outcomes.iter().all(CommandOutcome::ok);
5635 self.state.gate = outcomes;
5636 self.state.gate_ran = true;
5637 if !passed {
5638 self.state.status = RunStatus::Blocked;
5639 let spent = self.state.gate_fixes.len();
5640 self.state.event(
5641 "gate",
5642 if spent == 0 {
5643 "gate failed; not merging".to_owned()
5644 } else {
5645 format!("gate failed after {spent} gate-fix round(s); not merging")
5646 },
5647 );
5648 }
5649 self.state.save()?;
5650 Ok(())
5651 }
5652
5653 async fn run_pre_gate(&mut self, winner: &Candidate) {
5663 let commands = self.state.config.verify.pre_gate.clone();
5664 if commands.is_empty() {
5665 return;
5666 }
5667 let shell = self.state.config.shell();
5668 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5669 let (outcomes, _) = run_commands(
5670 &mut self.state,
5671 "pre_gate",
5672 "pre_gate",
5673 0,
5674 &shell,
5675 &commands,
5676 &winner.worktree,
5677 timeout,
5678 )
5679 .await;
5680 for o in &outcomes {
5681 if !o.ok() {
5682 tracing::warn!(
5683 "pre_gate `{}` failed ({:?}); the gate decides",
5684 o.command,
5685 o.code
5686 );
5687 }
5688 self.state.event(
5689 "pre_gate",
5690 format!(
5691 "`{}` -> {}",
5692 o.command,
5693 if o.ok() {
5694 "pass".to_owned()
5695 } else {
5696 format!(
5697 "FAIL ({:?})\n{}",
5698 o.code,
5699 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5700 )
5701 }
5702 ),
5703 );
5704 }
5705 self.state.pre_gate = outcomes;
5706 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5707 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5708 Ok(head) => {
5709 self.state
5710 .event("pre_gate", format!("committed mechanical fixes ({head})"));
5711 self.state.pre_gate_commit = Some(head);
5712 }
5713 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5714 },
5715 Ok(false) => {}
5716 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5717 }
5718 if let Err(e) = self.state.save() {
5719 tracing::warn!("could not persist the pre_gate record: {e:#}");
5720 }
5721 }
5722
5723 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5726 self.run_pre_gate(winner).await;
5727 let shell = self.state.config.shell();
5728 let gate_commands = self.state.config.verify.gate.clone();
5729 let outcomes = if gate_commands.is_empty() {
5738 Vec::new()
5739 } else {
5740 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5741 let cache_dir = self.state.config.cache_dir();
5742 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5743 let (outcomes, _) = with_cache_lease(
5744 &mut self.state,
5745 cache_dir.as_deref(),
5746 "gate",
5747 "gate",
5748 &winner.worktree,
5749 &head,
5750 timeout,
5751 "final gate",
5752 |state, budget| {
5753 let shell = shell.clone();
5754 let gate_commands = gate_commands.clone();
5755 let worktree = winner.worktree.clone();
5756 async move {
5757 let (outcomes, timed_out_pids) = run_commands(
5758 state,
5759 "gate",
5760 "gate",
5761 0,
5762 &shell,
5763 &gate_commands,
5764 &worktree,
5765 budget,
5766 )
5767 .await;
5768 (outcomes, false, timed_out_pids)
5769 }
5770 },
5771 )
5772 .await;
5773 outcomes
5774 };
5775 if outcomes.is_empty() {
5776 self.state.event(
5781 "gate",
5782 "no gate commands configured; nothing to check, passing",
5783 );
5784 }
5785 for o in &outcomes {
5786 self.state.event(
5787 "gate",
5788 format!(
5789 "`{}` -> {}",
5790 o.command,
5791 if o.ok() {
5792 "pass".to_owned()
5793 } else {
5794 format!(
5795 "FAIL ({:?})\n{}",
5796 o.code,
5797 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5798 )
5799 }
5800 ),
5801 );
5802 }
5803 Ok(outcomes)
5804 }
5805
5806 async fn gate_fix_round(
5819 &mut self,
5820 winner: &Candidate,
5821 outcomes: &[CommandOutcome],
5822 ) -> Result<GateFix> {
5823 let cap = self.state.config.graph.gate_fix_rounds;
5824 let spent = self.state.gate_fixes.len();
5825 if spent >= cap {
5826 if cap > 0 {
5827 self.state.event(
5828 "gate",
5829 format!("{spent} gate-fix round(s) spent and the gate still fails"),
5830 );
5831 }
5832 return Ok(GateFix::Stop);
5833 }
5834 if !gate_fixable(outcomes) {
5835 self.state.event(
5836 "gate",
5837 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5838 command or similar); not spending a fix round on it",
5839 );
5840 return Ok(GateFix::Stop);
5841 }
5842 let min_free = self.state.config.disk.min_free_bytes;
5843 if min_free > 0 {
5844 match crate::disk::free_bytes(&winner.worktree) {
5845 Ok(free) if crate::disk::enough_space(free, min_free) => {}
5846 Ok(free) => {
5847 self.state.event(
5848 "gate",
5849 format!(
5850 "only {free} bytes free ({min_free} required by `[disk] \
5851 min_free_bytes`); not spending a fix round on a failure the disk \
5852 may explain"
5853 ),
5854 );
5855 return Ok(GateFix::Stop);
5856 }
5857 Err(e) => {
5858 self.state.event(
5859 "gate",
5860 format!("free disk space could not be measured ({e:#}); no fix round"),
5861 );
5862 return Ok(GateFix::Stop);
5863 }
5864 }
5865 }
5866
5867 let attempt = spent + 1;
5868 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5869 let base = self.landing_base();
5870 let fix_prompt = prompt::gate_fix(
5871 &self.state.instruction,
5872 &failed,
5873 attempt,
5874 cap,
5875 &self.state.config.graph.language,
5876 );
5877 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5878 let sessions = self.state.config.graph.sessions;
5879 let artifacts = agent::artifacts_dir(&self.state.dir());
5880 self.state.event(
5881 "gate",
5882 format!("gate failed; gate-fix round {attempt} of {cap}"),
5883 );
5884 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5885 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5886 let (_, seat, out) = self
5887 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
5888 prompt: fix_prompt.clone(),
5889 spec,
5890 seat,
5891 cwd: winner.worktree.clone(),
5892 timeout,
5893 allow_write: true,
5894 sessions,
5895 artifacts: artifacts.clone(),
5896 stem: format!("gate-fix-{attempt}"),
5897 handover: None,
5898 })
5899 .await;
5900 let mut record = GateFixRecord {
5901 agent: seat.agent.clone(),
5902 failed,
5903 notes: String::new(),
5904 committed: false,
5905 error: None,
5906 };
5907 match out {
5908 AgentOutcome::Ok(o) => {
5909 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5912 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5913 }
5914 }
5915 AgentOutcome::Dropped(_) => {
5916 record.error = Some("the CLI dropped the stream".to_owned());
5917 }
5918 AgentOutcome::Quota(o) => {
5919 self.state.quota.push(QuotaLoss {
5920 seat: seat.key.clone(),
5921 node: "gate-fix".to_owned(),
5922 at: Timestamp::now(),
5923 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5924 });
5925 record.error = Some("rate limited (quota); fixer could not run".to_owned());
5926 }
5927 AgentOutcome::Failed(e) => record.error = Some(e),
5928 }
5929 self.state.seats.insert(seat.key.clone(), seat);
5930 if let Ok(r) = git::rescue_commit(
5931 &winner.worktree,
5932 &format!("magi: gate fix {attempt} (uncommitted work)"),
5933 )
5934 .await
5935 {
5936 self.state.note_withheld("gate-fix", &r.withheld);
5937 }
5938 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5939 record.committed = after != before;
5940 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5941 let note = record.error.clone();
5942 self.state.gate_fixes.push(record);
5943 self.state.save()?;
5944 if !changed {
5945 self.state.event(
5946 "gate",
5947 match note {
5948 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5949 None => format!("gate-fix round {attempt}: the tree did not change"),
5950 },
5951 );
5952 return Ok(GateFix::Stop);
5953 }
5954 self.state.event(
5955 "gate",
5956 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5957 );
5958
5959 let commands = self.state.config.verify.e2e.clone();
5960 if !commands.is_empty() {
5961 let shell = self.state.config.shell();
5962 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5963 let cache_dir = self.state.config.cache_dir();
5964 let context = format!("gate-fix round {attempt}");
5965 let (e2e, _) = with_cache_lease(
5966 &mut self.state,
5967 cache_dir.as_deref(),
5968 "e2e",
5969 "e2e",
5970 &winner.worktree,
5971 &after,
5972 timeout,
5973 &context,
5974 |state, budget| {
5975 let shell = shell.clone();
5976 let commands = commands.clone();
5977 let context = context.clone();
5978 let worktree = winner.worktree.clone();
5979 async move {
5980 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5981 .await
5982 }
5983 },
5984 )
5985 .await;
5986 if verify_inconclusive(&e2e) {
5987 return Ok(GateFix::Defer);
5988 }
5989 if e2e.iter().any(|o| !o.ok()) {
5990 self.state.event(
5991 "gate",
5992 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
5993 );
5994 return Ok(GateFix::Stop);
5995 }
5996 }
5997 Ok(GateFix::Retry)
5998 }
5999
6000 async fn guarded_pr_message(
6004 &mut self,
6005 winner: &Candidate,
6006 facts: Option<&BranchFacts>,
6007 posting: bool,
6008 ) -> PrMessage {
6009 let mut pr = pr_message_raw(&self.state, winner.label, facts);
6010 if !posting {
6011 let identity = crate::scrub::Identity::current();
6012 return PrMessage {
6013 title: crate::scrub::scrub(&pr.title, &identity),
6014 body: crate::scrub::scrub(&pr.body, &identity),
6015 };
6016 }
6017 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6018 let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6019 let identity = crate::scrub::Identity::current();
6020 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6021 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6022 && !violations.contains(&crate::github_text::Violation::SensitiveData)
6023 {
6024 violations.push(crate::github_text::Violation::SensitiveData);
6025 }
6026 violations.retain(|v| *v != crate::github_text::Violation::SensitiveData);
6030 if self.state.config.graph.github_text_guard && !violations.is_empty() {
6031 self.state.event(
6032 "github-text",
6033 format!("description rejected: {violations:?}; requesting one rewrite"),
6034 );
6035 let mut rewritten = false;
6036 if let Some(spec) = self
6037 .state
6038 .config
6039 .agents
6040 .iter()
6041 .find(|a| a.id == winner.agent)
6042 .cloned()
6043 {
6044 let key = format!("impl-{}", winner.label);
6045 let seat = self.seat(&key, &spec.id);
6046 let prompt = format!(
6047 "Rewrite only the following pull request description. The posting gate reported {violations:?}. Write English prose and remove all machine or operator identifying data and secrets. Do not edit files or run commands. Return TITLE: followed by the title, then the complete Markdown body. Preserve the magi run marker.\n\nTITLE: {}\n{}",
6048 pr.title, pr.body
6049 );
6050 let job = SeatJob {
6051 spec,
6052 seat,
6053 cwd: winner.worktree.clone(),
6054 prompt,
6055 timeout: retry_budget(
6056 Duration::from_secs(self.state.config.graph.timeout_implement),
6057 true,
6058 ),
6059 allow_write: false,
6060 sessions: self.state.config.graph.sessions,
6061 artifacts: agent::artifacts_dir(&self.state.dir()),
6062 stem: "github-text-rewrite".to_owned(),
6063 handover: None,
6064 };
6065 let prompts = self.state.config.prompts.clone();
6066 let cache = self.state.config.cache_dir();
6067 let run = self.state.id.clone();
6068 let ctx = WaveCtx {
6069 run: &run,
6070 node: "github-text",
6071 prompts: &prompts,
6072 cache: cache.as_deref(),
6073 round: None,
6074 carry_seats: false,
6075 };
6076 let (seat, outcome) =
6077 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6078 self.state.seats.insert(seat.key.clone(), seat);
6079 if let AgentOutcome::Ok(output) = outcome
6080 && let Some(title) = summary_title(&output.text)
6081 {
6082 let mut body = summary_without_title(&output.text);
6083 let rewrite = PrMessage {
6084 title: title.clone(),
6085 body: body.clone(),
6086 };
6087 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6088 if !body.trim().is_empty()
6089 && crate::github_text::check_with(&title, &body, redecision).is_empty()
6090 {
6091 decision = redecision;
6092 let marker = format!("magi:run/{}", self.state.id);
6093 if !body.contains(&marker) {
6094 body.push_str(&format!("\n\n{marker}"));
6095 }
6096 pr = PrMessage { title, body };
6097 rewritten = true;
6098 self.state
6099 .event("github-text", "description rewrite passed");
6100 }
6101 }
6102 }
6103 if !rewritten {
6104 self.state.event(
6105 "github-text",
6106 "description rewrite unavailable or rejected; using neutral body",
6107 );
6108 pr = PrMessage {
6109 title: pr.title,
6110 body: format!(
6111 "{}\n\nmagi:run/{}",
6112 crate::github_text::NEUTRAL_BODY,
6113 self.state.id
6114 ),
6115 };
6116 decision = decision.map(|d| crate::github_text::LanguageDecision {
6119 body_english: true,
6120 ..d
6121 });
6122 }
6123 }
6124 let (title, body) =
6125 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6126 PrMessage { title, body }
6127 }
6128
6129 async fn judge_pr_language(
6132 &mut self,
6133 cwd: &Path,
6134 pr: &PrMessage,
6135 ) -> Option<crate::github_text::LanguageDecision> {
6136 self.state.config.roles.language_judge.as_ref()?;
6137 let decision =
6138 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6139 self.state.event(
6140 "github-text",
6141 if decision.is_some() {
6142 "language decided by the language judge"
6143 } else {
6144 "language judge unavailable; using built-in heuristics"
6145 },
6146 );
6147 decision
6148 }
6149
6150 async fn merge(&mut self) -> Result<()> {
6151 if self
6166 .state
6167 .base_sync
6168 .as_ref()
6169 .is_some_and(|s| s.conflict.is_some())
6170 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6171 != Some(RunStatus::Gating)
6172 || !self.state.gate_status().ok()
6181 {
6182 return Ok(());
6183 }
6184 if self.state.merge.is_some() {
6193 return Ok(());
6194 }
6195 let Some(winner) = self.state.winner().cloned() else {
6196 return Ok(());
6197 };
6198 let repo = self.state.repo.clone();
6199 let base = self.state.base_branch.clone();
6200 let mode = self.state.config.merge.mode;
6201 let style = self.state.config.merge.style;
6202 let facts = if is_review_run(&self.state) {
6203 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6204 let start = review_base(
6205 &repo,
6206 &self.state.config.merge.remote,
6207 &base,
6208 &self.state.base_commit,
6209 &winner.branch,
6210 )
6211 .await;
6212 branch_facts(&repo, &start, &winner.branch).await
6213 } else {
6214 None
6215 };
6216 let leaked = if is_review_run(&self.state) {
6219 leaked_subjects(&self.state, &winner.branch).await
6220 } else {
6221 Some(Vec::new())
6222 };
6223 let pr = self
6224 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6225 .await;
6226 let message = pr.commit_message();
6227
6228 let outcome = match mode {
6229 MergeMode::None => MergeOutcome {
6230 mode,
6231 ok: true,
6232 detail: manual_merge_command(style, &repo, &winner.branch, &message),
6233 empty: false,
6234 },
6235 MergeMode::Pr | MergeMode::Local
6236 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6237 {
6238 MergeOutcome {
6239 mode,
6240 ok: false,
6241 detail: empty_candidate_detail(&self.state, &base),
6242 empty: true,
6243 }
6244 }
6245 MergeMode::Local => {
6246 let on = git::current_branch(&repo).await?;
6247 if on.as_deref() != Some(base.as_str()) {
6248 MergeOutcome {
6249 mode,
6250 ok: false,
6251 detail: format!(
6252 "{} has {} checked out, not the base branch {base}",
6253 repo.display(),
6254 on.unwrap_or_else(|| "a detached HEAD".to_owned())
6255 ),
6256 empty: false,
6257 }
6258 } else if !git::is_clean(&repo).await? {
6259 MergeOutcome {
6260 mode,
6261 ok: false,
6262 detail: format!("{} is dirty; refusing to merge", repo.display()),
6263 empty: false,
6264 }
6265 } else {
6266 let out = match style {
6267 MergeStyle::Merge => {
6268 git::merge_no_ff(&repo, &winner.branch, &message).await?
6269 }
6270 MergeStyle::Squash => {
6271 git::merge_squash(&repo, &winner.branch, &message).await?
6272 }
6273 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6274 };
6275 MergeOutcome {
6276 mode,
6277 ok: out.ok(),
6278 detail: if out.ok() { out.stdout } else { out.stderr },
6279 empty: false,
6280 }
6281 }
6282 }
6283 MergeMode::Pr => {
6284 let remote = self.state.config.merge.remote.clone();
6285 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6286 if !pushed.ok() {
6287 MergeOutcome {
6288 mode,
6289 ok: false,
6290 detail: pushed.stderr,
6291 empty: false,
6292 }
6293 } else {
6294 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6300 let out = match pr_merge_plan(found) {
6301 PrPlan::Create => {
6302 gh_pr_create(
6303 &winner.worktree,
6304 &base,
6305 &winner.branch,
6306 &pr.title,
6307 &pr.body,
6308 )
6309 .await
6310 }
6311 PrPlan::Adopt { url, title } => {
6312 self.state
6313 .event("merge", format!("Pr: adopted open pull request {url}"));
6314 if title != pr.title
6315 && (!is_review_run(&self.state)
6316 || leaked
6317 .as_deref()
6318 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6319 && let Err(e) = land::set_pr_title(
6320 &mut self.state,
6321 &winner.worktree,
6322 &url,
6323 &pr.title,
6324 )
6325 .await
6326 {
6327 tracing::warn!("could not refresh title of {url}: {e:#}");
6328 self.state
6329 .event("merge", format!("Pr: title refresh failed: {e:#}"));
6330 }
6331 Ok(url)
6332 }
6333 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6334 };
6335 match out {
6336 Ok(url) => MergeOutcome {
6337 mode,
6338 ok: true,
6339 detail: url,
6340 empty: false,
6341 },
6342 Err(e) => MergeOutcome {
6343 mode,
6344 ok: false,
6345 detail: e.to_string(),
6346 empty: false,
6347 },
6348 }
6349 }
6350 }
6351 };
6352
6353 self.state.status = match (mode, outcome.ok) {
6354 (MergeMode::None, _) => RunStatus::Ready,
6355 (_, true) => RunStatus::Merged,
6356 (_, false) => RunStatus::Blocked,
6357 };
6358 self.state.event(
6359 "merge",
6360 format!(
6361 "{:?}: {}",
6362 mode,
6363 outcome.detail.lines().next().unwrap_or("")
6364 ),
6365 );
6366 self.state.merge = Some(outcome);
6367 self.state.save()?;
6368
6369 if self.state.config.graph.land
6375 && mode == MergeMode::Pr
6376 && self.state.status == RunStatus::Merged
6377 {
6378 self.run_land().await?;
6379 }
6380 self.settle_questions();
6385 Ok(())
6386 }
6387
6388 async fn run_land(&mut self) -> Result<()> {
6399 let url = self
6400 .state
6401 .merge
6402 .as_ref()
6403 .map(|m| m.detail.clone())
6404 .unwrap_or_default();
6405 let url = url.lines().next().unwrap_or("").trim().to_owned();
6406 if !url.starts_with("http") {
6407 return Ok(());
6408 }
6409 match land::land(&mut self.state, &url).await {
6412 Ok(pr) if self.state.parked => {
6413 let _ = pr;
6417 }
6418 Ok(pr) => {
6419 self.state.status = match pr.state {
6420 land::PrLifecycle::Merged => RunStatus::Merged,
6421 _ => RunStatus::Blocked,
6422 };
6423 if bump::should_release_bump(self.state.status)
6430 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6431 {
6432 self.state
6438 .event("bump", format!("release bump skipped: {e:#}"));
6439 }
6440 if self.state.status == RunStatus::Merged {
6443 crate::followup::after_merge(&mut self.state, &pr.url).await;
6444 }
6445 self.state.save()?;
6446 }
6447 Err(e) => {
6448 self.state.status = RunStatus::Blocked;
6449 self.state.event("land", format!("gave up: {e}"));
6450 self.state.save()?;
6451 }
6452 }
6453 Ok(())
6454 }
6455
6456 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6460 if let Some(existing) = self.state.seats.get(key)
6461 && existing.agent == agent
6462 {
6463 return existing.clone();
6464 }
6465 let fresh = if self.state.seats.contains_key(key) {
6468 handover_seat(key, agent, self.state.next_seat_seed())
6469 } else {
6470 SeatState::new(key, agent, self.state.seed)
6471 };
6472 self.state.seats.insert(key.to_owned(), fresh.clone());
6473 fresh
6474 }
6475
6476 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6482 match self.state.seats.get(key) {
6483 Some(s) if s.agent != spec.id => {
6484 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6485 }
6486 _ => spec,
6487 }
6488 }
6489
6490 fn view(&self, c: &Candidate) -> CandidateView {
6492 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6493 .unwrap_or_default();
6494 let (patch, _) = blind::sanitize_patch(
6495 &format!("candidate {} patch", c.label),
6496 &raw,
6497 &self.state.config.blind,
6498 );
6499 CandidateView {
6500 label: c.label,
6501 branch: c.branch.clone(),
6502 summary: c.summary.clone(),
6503 stat: c.stat.clone(),
6504 patch,
6505 }
6506 }
6507
6508 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6510 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6511 prompt::judge(
6512 "(see above)",
6513 &views,
6514 self.roles.judges.len(),
6515 base_short,
6516 "en",
6517 )
6518 }
6519
6520 fn vote_prompt_full(
6526 &self,
6527 j: usize,
6528 labels: &[char],
6529 language: &str,
6530 candidates: &[Candidate],
6531 base_short: &str,
6532 ) -> String {
6533 let mut text = format!(
6534 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6535 prompt::final_vote(labels, language),
6536 self.state.instruction,
6537 self.candidate_block(candidates, base_short)
6538 );
6539 if let Some(own) = self
6540 .state
6541 .judgements
6542 .get(j)
6543 .filter(|r| !r.ranking.is_empty())
6544 {
6545 let reasons = own
6546 .reasons
6547 .iter()
6548 .map(|(k, v)| format!("- {k}: {v}"))
6549 .collect::<Vec<_>>()
6550 .join("\n");
6551 text.push_str(&format!(
6552 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6553 own.ranking.iter().collect::<String>(),
6554 if reasons.is_empty() {
6555 ""
6556 } else {
6557 ", because:\n"
6558 }
6559 ));
6560 }
6561 if !self.state.deliberation.is_empty() {
6562 text.push_str("\n# What was argued before this vote\n");
6563 for t in self.transcript(&[], j) {
6564 text.push_str(&format!(
6565 "\n## {}{}\n\n{}\n",
6566 t.who,
6567 if t.is_self { " (you)" } else { "" },
6568 t.body.trim()
6569 ));
6570 }
6571 }
6572 text
6573 }
6574
6575 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6582 let mut turns = Vec::new();
6583 for j in &self.state.judgements {
6584 if j.ranking.is_empty() {
6585 continue;
6586 }
6587 let reasons = j
6588 .reasons
6589 .iter()
6590 .map(|(k, v)| format!("- {k}: {v}"))
6591 .collect::<Vec<_>>()
6592 .join("\n");
6593 turns.push(Turn {
6594 who: format!("Judge {} (opening ranking)", j.judge),
6595 is_self: j.judge == self_idx + 1,
6596 body: format!(
6597 "Ranked {}{}{reasons}",
6598 j.ranking.iter().collect::<String>(),
6599 if reasons.is_empty() {
6600 ""
6601 } else {
6602 ", because:\n"
6603 }
6604 ),
6605 });
6606 }
6607 for t in self
6608 .state
6609 .deliberation
6610 .iter()
6611 .flat_map(|r| r.turns.iter())
6612 .chain(current)
6613 {
6614 turns.push(Turn {
6615 who: format!("Judge {}", t.judge),
6616 is_self: t.judge == self_idx + 1,
6617 body: t.body.clone(),
6618 });
6619 }
6620 turns
6621 }
6622}
6623
6624fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6626 agent::has_session(spec.kind, seat, sessions)
6627}
6628
6629fn next_untried_in_roster<'a>(
6650 roster: &'a [AgentSpec],
6651 start: usize,
6652 tried: &BTreeSet<String>,
6653) -> Option<&'a AgentSpec> {
6654 roster
6655 .get(start + 1..)?
6656 .iter()
6657 .find(|s| !tried.contains(&s.id))
6658}
6659
6660fn pick_successor<'a>(
6681 roster: &'a [AgentSpec],
6682 start: usize,
6683 tried: &BTreeSet<String>,
6684 carried: Option<&BTreeSet<String>>,
6685 others: &BTreeSet<String>,
6686) -> Option<&'a AgentSpec> {
6687 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
6688 roster
6689 .get(start + 1..)
6690 .and_then(|tail| {
6691 tail.iter()
6692 .filter(free)
6693 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
6694 })
6695 .or_else(|| {
6696 carried
6697 .is_some()
6698 .then(|| roster.iter().find(free))
6699 .flatten()
6700 })
6701 .or_else(|| match carried {
6702 Some(c) => next_for_seat(roster, start, tried, c),
6703 None => next_untried_in_roster(roster, start, tried),
6704 })
6705}
6706
6707fn next_for_seat<'a>(
6717 roster: &'a [AgentSpec],
6718 start: usize,
6719 round_tried: &BTreeSet<String>,
6720 carried_failed: &BTreeSet<String>,
6721) -> Option<&'a AgentSpec> {
6722 roster
6723 .get(start + 1..)?
6724 .iter()
6725 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6726 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6727}
6728
6729fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6735 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6736 return spec;
6737 };
6738 let ok = |id: &str| !h.failed.contains(id);
6739 if let Some(last) = h.last_ok.as_deref()
6740 && ok(last)
6741 && let Some(s) = roster.iter().find(|s| s.id == last)
6742 {
6743 return s.clone();
6744 }
6745 if ok(&spec.id) {
6746 return spec;
6747 }
6748 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6749 roster
6750 .get(from + 1..)
6751 .into_iter()
6752 .flatten()
6753 .chain(roster.iter())
6754 .find(|s| ok(&s.id))
6755 .cloned()
6756 .unwrap_or(spec)
6757}
6758
6759pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6763 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6764}
6765
6766const TIMED_OUT: &str = "timed out";
6769
6770impl FailClass {
6771 fn of(out: &AgentOutcome) -> Option<Self> {
6773 match out {
6774 AgentOutcome::Ok(_) => None,
6775 AgentOutcome::Quota(_) => Some(Self::Quota),
6776 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6777 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6778 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6779 }
6780 }
6781
6782 fn stem_word(&self) -> &'static str {
6784 match self {
6785 Self::Quota => "quota",
6786 _ => "handover",
6787 }
6788 }
6789}
6790
6791fn failure_signature(msg: &str) -> String {
6795 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
6796 let mut out = Vec::new();
6797 for word in line.split_whitespace() {
6798 if word.contains('/') || word.contains('\\') {
6799 out.push("<path>".to_owned());
6800 continue;
6801 }
6802 let mut w = String::new();
6803 let mut in_digits = false;
6804 for c in word.chars() {
6805 if c.is_ascii_digit() {
6806 if !in_digits {
6807 w.push('#');
6808 }
6809 in_digits = true;
6810 } else {
6811 in_digits = false;
6812 w.push(c);
6813 }
6814 }
6815 out.push(w);
6816 }
6817 out.join(" ").chars().take(120).collect()
6818}
6819
6820fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
6827 match cur {
6828 FailClass::Quota | FailClass::Timeout => true,
6829 FailClass::Other(_) => prev != Some(cur),
6830 }
6831}
6832
6833fn fail_reason(out: &AgentOutcome) -> String {
6835 match out {
6836 AgentOutcome::Ok(_) => String::new(),
6837 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
6838 AgentOutcome::Dropped(o) => format!(
6839 "the CLI dropped the stream ({})",
6840 o.dropped
6841 .as_ref()
6842 .map(|d| d.why.as_str())
6843 .unwrap_or("it ended without delivering its answer")
6844 ),
6845 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
6846 }
6847}
6848
6849pub(crate) fn record_handover(
6852 state: &mut RunState,
6853 node: &str,
6854 seat: &str,
6855 from: &str,
6856 to: &str,
6857 class: &FailClass,
6858 reason: &str,
6859) {
6860 let message = if *class == FailClass::Quota {
6861 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
6862 } else {
6863 format!("{seat}: handed over {from} -> {to} ({reason})")
6864 };
6865 state.event(node, message);
6866 state.handovers.push(Handover {
6867 at: Timestamp::now(),
6868 node: node.to_owned(),
6869 seat: seat.to_owned(),
6870 from: from.to_owned(),
6871 to: to.to_owned(),
6872 reason: reason.to_owned(),
6873 });
6874}
6875
6876fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
6890 commands.iter().any(|c| c.exit_code.is_none())
6891}
6892
6893fn verified_noop_claim(
6906 usable: bool,
6907 commands: &[agent::CommandEvidence],
6908 text: &str,
6909) -> Option<String> {
6910 (usable && !has_unconfirmed_command(commands))
6911 .then(|| verdict::verified_noop(text))
6912 .flatten()
6913}
6914
6915fn short(commit: &str) -> String {
6916 commit.chars().take(7).collect()
6917}
6918
6919fn make_executable(path: &Path) -> Result<()> {
6920 #[cfg(unix)]
6921 {
6922 use std::os::unix::fs::PermissionsExt as _;
6923 let mut perms = std::fs::metadata(path)?.permissions();
6924 perms.set_mode(0o755);
6925 std::fs::set_permissions(path, perms)?;
6926 }
6927 #[cfg(not(unix))]
6928 {
6929 let _ = path;
6930 }
6931 Ok(())
6932}
6933
6934struct WaveCtx<'a> {
6941 run: &'a str,
6944 node: &'a str,
6946 prompts: &'a Prompts,
6947 cache: Option<&'a Path>,
6949 round: Option<usize>,
6952 carry_seats: bool,
6956}
6957
6958async fn run_one(
6960 job: SeatJob,
6961 sem: Arc<Semaphore>,
6962 ctx: &WaveCtx<'_>,
6963 state: &mut RunState,
6964 attempt: usize,
6965) -> (SeatState, AgentOutcome) {
6966 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
6967 .await
6968 .pop()
6969 .expect("one job in, one result out");
6970 (seat, out)
6971}
6972
6973async fn wave(
6979 jobs: Vec<SeatJob>,
6980 sem: Arc<Semaphore>,
6981 ctx: &WaveCtx<'_>,
6982 state: &mut RunState,
6983 attempt: usize,
6984) -> Vec<(usize, SeatState, AgentOutcome)> {
6985 let WaveCtx {
6986 run,
6987 node,
6988 prompts,
6989 cache,
6990 round,
6991 carry_seats: _,
6992 } = *ctx;
6993 for job in &jobs {
6994 state.seat_started(node, &job.seat.key, job.timeout, attempt);
6995 }
6996 if let Err(e) = state.save() {
6997 tracing::warn!("could not persist in-progress seats: {e:#}");
7002 }
7003 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7019 let wait_started = Instant::now();
7020 let cache_guard = if let Some(cache_dir) = cache {
7021 if jobs_had_a_writer {
7022 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7023 let budget = jobs
7024 .iter()
7025 .map(|j| j.timeout)
7026 .max()
7027 .unwrap_or(Duration::from_secs(60));
7028 acquire_cache_lease(state, cache_dir, &owner, budget, node)
7029 .await
7030 .ok()
7031 } else {
7032 None
7033 }
7034 } else {
7035 None
7036 };
7037 let waited_for_lease = wait_started.elapsed();
7044 let mut set = tokio::task::JoinSet::new();
7045 let overlay = prompts.overlay(node);
7046 for (i, mut job) in jobs.into_iter().enumerate() {
7047 job.timeout = job.timeout.saturating_sub(waited_for_lease);
7048 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7049 if cache.is_some() {
7050 job.prompt.push('\n');
7051 job.prompt
7052 .push_str(&prompt::build_cache_note(node, job.allow_write));
7053 }
7054 let sem = Arc::clone(&sem);
7055 let run = run.to_owned();
7056 let node = node.to_owned();
7057 let attachments = if node == "implement" {
7060 state.attachments.clone()
7061 } else {
7062 Vec::new()
7063 };
7064 let cache = cache
7075 .filter(|_| job.allow_write && cache_guard.is_some())
7076 .map(Path::to_path_buf);
7077 set.spawn(async move {
7078 let _permit = sem.acquire().await;
7079 let mut seat = job.seat;
7080 let out = agent::invoke(
7081 &job.spec,
7082 &mut seat,
7083 &Invocation {
7084 cwd: &job.cwd,
7085 prompt: &job.prompt,
7086 timeout: job.timeout,
7087 allow_write: job.allow_write,
7088 unsandboxed: false,
7089 sessions: job.sessions,
7090 artifacts: &job.artifacts,
7091 stem: &job.stem,
7092 run: &run,
7093 node: &node,
7094 cache_dir: cache.as_deref(),
7095 attachments: &attachments,
7096 writable: &[],
7097 },
7098 )
7099 .await;
7100 let out = match out {
7101 Ok(o) if o.usable() => AgentOutcome::Ok(o),
7102 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7103 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7111 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7112 Ok(o) => AgentOutcome::Failed(format!(
7113 "exited with {:?} and no usable output",
7114 o.exit_code
7115 )),
7116 Err(e) => AgentOutcome::Failed(e.to_string()),
7117 };
7118 (i, seat, out)
7119 });
7120 }
7121 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7122 while let Some(joined) = set.join_next().await {
7123 let (i, seat, out) = match joined {
7124 Ok(v) => v,
7125 Err(e) => {
7129 tracing::error!("agent task panicked: {e}");
7130 continue;
7131 }
7132 };
7133 state.seat_finished(&seat.key);
7134 record_jobs(state, node, round, &seat.key, &out);
7135 if let Err(e) = state.save() {
7136 tracing::warn!("could not persist a seat's completion: {e:#}");
7137 }
7138 if collected.len() <= i {
7139 collected.resize_with(i + 1, || None);
7140 }
7141 collected[i] = Some((i, seat, out));
7142 }
7143 if state
7149 .active
7150 .values()
7151 .any(|a| a.node == node && a.attempt == attempt)
7152 {
7153 state
7154 .active
7155 .retain(|_, a| !(a.node == node && a.attempt == attempt));
7156 if let Err(e) = state.save() {
7157 tracing::warn!("could not persist the end of a wave: {e:#}");
7158 }
7159 }
7160 if let Some(cache_dir) = cache
7167 && jobs_had_a_writer
7168 {
7169 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7170 }
7171 if let Some(guard) = cache_guard {
7172 guard.release();
7173 }
7174 collected.into_iter().flatten().collect()
7175}
7176
7177fn record_jobs(
7188 state: &mut RunState,
7189 node: &str,
7190 round: Option<usize>,
7191 seat: &str,
7192 out: &AgentOutcome,
7193) {
7194 let commands: &[agent::CommandEvidence] = match out {
7195 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7196 AgentOutcome::Failed(_) => &[],
7197 };
7198 let checked_at = Timestamp::now();
7199 for c in commands {
7200 state.jobs.push(JobRecord {
7201 node: node.to_owned(),
7202 round,
7203 seat: seat.to_owned(),
7204 id: c.id.clone(),
7205 description: c.description.clone(),
7206 checked_at,
7207 status: match c.exit_code {
7208 Some(0) => JobStatus::Completed,
7209 Some(_) => JobStatus::Failed,
7210 None => JobStatus::Unknown,
7211 },
7212 exit_code: c.exit_code,
7213 result_summary: c.result_summary.clone(),
7214 source: c.source.clone(),
7215 });
7216 }
7217}
7218
7219fn round_is_clean(
7240 blocking: usize,
7241 e2e_ok: bool,
7242 answered: usize,
7243 expected: usize,
7244 quota_missing: usize,
7245 policy: IncompleteReviewPolicy,
7246) -> bool {
7247 if blocking != 0 || !e2e_ok {
7248 return false;
7249 }
7250 if answered == expected || policy == IncompleteReviewPolicy::Warn {
7251 return true;
7252 }
7253 answered > 0 && expected - answered <= quota_missing
7254}
7255
7256fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7280 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7281 return Some(RunStatus::Gating);
7282 }
7283 let last = reviews.last()?;
7284 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7285 if reviews.len() < max_rounds && !stagnant {
7286 return None;
7287 }
7288 if last.incomplete() && last.blocking == 0 {
7289 return Some(RunStatus::Blocked);
7290 }
7291 if last.e2e_status() == E2eStatus::ResourceBlocked {
7292 return None;
7293 }
7294 Some(if last.e2e.iter().all(CommandOutcome::ok) {
7295 RunStatus::Gating
7296 } else {
7297 RunStatus::Blocked
7298 })
7299}
7300
7301pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
7316 if nudged {
7317 (full / 4).max(Duration::from_secs(120)).min(full)
7318 } else {
7319 full
7320 }
7321}
7322
7323#[allow(clippy::too_many_arguments)]
7352async fn ask_json_wave<T>(
7353 jobs: Vec<SeatJob>,
7354 sem: Arc<Semaphore>,
7355 retries: usize,
7356 roster: &[AgentSpec],
7357 ctx: &WaveCtx<'_>,
7358 losses: &mut Vec<QuotaLoss>,
7359 state: &mut RunState,
7360 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7361) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7362where
7363 T: serde::de::DeserializeOwned + Send + 'static,
7364{
7365 ask_wave_with(
7366 jobs,
7367 sem,
7368 retries,
7369 roster,
7370 ctx,
7371 losses,
7372 state,
7373 &|text: &str| {
7374 let v = verdict::extract_json::<T>(text)?;
7375 validate(&v)?;
7376 Ok(v)
7377 },
7378 )
7379 .await
7380}
7381
7382#[allow(clippy::too_many_arguments)]
7391async fn ask_wave_with<T>(
7392 jobs: Vec<SeatJob>,
7393 sem: Arc<Semaphore>,
7394 retries: usize,
7395 roster: &[AgentSpec],
7396 ctx: &WaveCtx<'_>,
7397 losses: &mut Vec<QuotaLoss>,
7398 state: &mut RunState,
7399 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7400) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7401where
7402 T: Send + 'static,
7403{
7404 let n = jobs.len();
7405 let originals: Vec<SeatJob> = jobs;
7406 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7407 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7408 let mut nudges: Vec<usize> = vec![0; n];
7415 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7419 let mut tried: Vec<BTreeSet<String>> = specs
7420 .iter()
7421 .map(|s| BTreeSet::from([s.id.clone()]))
7422 .collect();
7423 let starts: Vec<usize> = specs
7424 .iter()
7425 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7426 .collect();
7427 let carry = ctx.carry_seats && !roster.is_empty();
7428 let carried: Vec<BTreeSet<String>> = originals
7431 .iter()
7432 .map(|j| {
7433 state
7434 .seat_history
7435 .get(&j.seat.key)
7436 .filter(|_| carry)
7437 .map(|h| h.failed.clone())
7438 .unwrap_or_default()
7439 })
7440 .collect();
7441 let mut prev: Vec<Option<FailClass>> = originals
7442 .iter()
7443 .map(|j| {
7444 state
7445 .seat_history
7446 .get(&j.seat.key)
7447 .filter(|_| carry)
7448 .and_then(|h| h.last_fail.clone())
7449 })
7450 .collect();
7451 let next_agent =
7452 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
7453 let others: BTreeSet<String> = specs
7454 .iter()
7455 .enumerate()
7456 .filter(|(j, _)| *j != i)
7457 .map(|(_, s)| s.id.clone())
7458 .collect();
7459 pick_successor(
7460 roster,
7461 starts[i],
7462 tried,
7463 carry.then(|| &carried[i]),
7464 &others,
7465 )
7466 .cloned()
7467 };
7468 let mut fresh: Vec<Option<String>> = vec![None; n];
7469 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7470 let mut pending: Vec<usize> = (0..n).collect();
7471
7472 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7477 for round in 0..max_rounds {
7478 if pending.is_empty() {
7479 break;
7480 }
7481 let mut batch = Vec::with_capacity(pending.len());
7482 let mut renudged: Vec<&str> = Vec::new();
7483 for &i in &pending {
7484 let src = &originals[i];
7485 let full: &str = match &src.handover {
7490 Some(h) if specs[i].id != src.spec.id => h,
7491 _ => &src.prompt,
7492 };
7493 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7496 (
7497 full.to_owned(),
7498 src.timeout,
7499 format!("{}-{word}-{}", src.stem, specs[i].id),
7500 )
7501 } else if nudges[i] == 0 {
7502 (full.to_owned(), src.timeout, src.stem.clone())
7503 } else {
7504 renudged.push(src.seat.key.as_str());
7505 let why = done[i]
7506 .as_ref()
7507 .and_then(|r| r.as_ref().err().map(ToString::to_string))
7508 .unwrap_or_else(|| "no parsable answer".to_owned());
7509 let nudge = prompt::nudge(&why);
7510 let nudged = has_context(&specs[i], &seats[i], src.sessions);
7511 let prompt = if nudged {
7512 nudge
7513 } else {
7514 format!("{full}\n\n---\n\n{nudge}")
7515 };
7516 (
7517 prompt,
7518 retry_budget(src.timeout, nudged),
7519 format!("{}-retry{}", src.stem, nudges[i]),
7520 )
7521 };
7522 batch.push(SeatJob {
7523 spec: specs[i].clone(),
7524 seat: seats[i].clone(),
7525 cwd: src.cwd.clone(),
7526 prompt,
7527 timeout,
7528 allow_write: src.allow_write,
7529 sessions: src.sessions,
7530 artifacts: src.artifacts.clone(),
7531 stem,
7532 handover: None,
7533 });
7534 }
7535
7536 if !renudged.is_empty() {
7537 state.event(
7538 ctx.node,
7539 format!("retry {round}: re-asking {}", renudged.join(", ")),
7540 );
7541 }
7542 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7543 let mut still = Vec::new();
7544 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7545 seats[i] = seat;
7546 let class = FailClass::of(&out);
7547 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7550 && nudges[i] < retries
7551 && !roster.is_empty();
7552 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7553 let next = should_hand_over(prev[i].as_ref(), &cur)
7554 .then(|| next_agent(i, &tried[i], &specs))
7555 .flatten();
7556 if carry {
7557 let h = state
7558 .seat_history
7559 .entry(originals[i].seat.key.clone())
7560 .or_default();
7561 h.failed.insert(specs[i].id.clone());
7562 h.last_fail = Some(cur.clone());
7563 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7564 h.last_ok = None;
7565 }
7566 if let Err(e) = state.save() {
7569 tracing::warn!("could not persist a seat's failure history: {e:#}");
7570 }
7571 }
7572 if let Some(next) = next {
7573 record_handover(
7574 state,
7575 ctx.node,
7576 &originals[i].seat.key,
7577 &specs[i].id,
7578 &next.id,
7579 &cur,
7580 &fail_reason(&out),
7581 );
7582 tried[i].insert(next.id.clone());
7583 prev[i] = Some(cur.clone());
7584 seats[i] =
7585 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7586 specs[i] = next;
7587 fresh[i] = Some(cur.stem_word().to_owned());
7588 nudges[i] = 0;
7589 done[i] = Some(Err(anyhow::anyhow!(
7590 "handed over after: {}",
7591 fail_reason(&out)
7592 )));
7593 still.push(i);
7594 continue;
7595 }
7596 }
7597 if carry
7598 && !nudge_first
7599 && let Some(cur) = class.clone()
7600 {
7601 let h = state
7603 .seat_history
7604 .entry(originals[i].seat.key.clone())
7605 .or_default();
7606 h.failed.insert(specs[i].id.clone());
7607 h.last_fail = Some(cur);
7608 }
7609 let parsed = match out {
7610 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7611 AgentOutcome::Quota(o) => {
7612 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7613 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7614 }
7615 AgentOutcome::Dropped(o) => {
7619 let why = o
7620 .dropped
7621 .as_ref()
7622 .map(|d| d.why.as_str())
7623 .unwrap_or("the CLI ended the stream without delivering its answer");
7624 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7625 }
7626 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7627 };
7628 let quota = class == Some(FailClass::Quota);
7629 let failed = parsed.is_err();
7630 done[i] = Some(parsed);
7631 if carry && !failed {
7632 let h = state
7633 .seat_history
7634 .entry(originals[i].seat.key.clone())
7635 .or_default();
7636 h.failed.remove(&specs[i].id);
7637 h.last_ok = Some(specs[i].id.clone());
7638 h.last_fail = None;
7639 }
7640 let agent_failure = class.is_some()
7648 && !nudge_first
7649 && !roster.is_empty()
7650 && next_agent(i, &tried[i], &specs).is_some();
7651 if failed && !quota && !agent_failure && nudges[i] < retries {
7652 nudges[i] += 1;
7653 still.push(i);
7654 }
7655 }
7656 pending = still;
7657 }
7658
7659 for (i, q) in last_quota.into_iter().enumerate() {
7662 if let Some(reset) = q {
7663 losses.push(QuotaLoss {
7664 seat: originals[i].seat.key.clone(),
7665 node: ctx.node.to_owned(),
7666 at: Timestamp::now(),
7667 reset,
7668 });
7669 }
7670 }
7671
7672 seats
7673 .into_iter()
7674 .zip(done)
7675 .zip(nudges)
7676 .map(|((seat, res), attempts)| {
7677 (
7678 seat,
7679 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7680 attempts,
7681 )
7682 })
7683 .collect()
7684}
7685
7686async fn acquire_cache_lease(
7699 state: &mut RunState,
7700 cache_dir: &Path,
7701 owner: &crate::cache::Owner,
7702 budget: Duration,
7703 context: &str,
7704) -> Result<crate::cache::Guard> {
7705 let home = crate::run::home();
7706 let started = Instant::now();
7707 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7708 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7709 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7710 Err(e) => {
7711 state.event(
7712 "verify",
7713 format!("{context}: could not check the shared build cache: {e:#}"),
7714 );
7715 if let Err(e2) = state.save() {
7716 tracing::warn!("could not persist a cache-check failure: {e2:#}");
7717 }
7718 return Err(e);
7719 }
7720 };
7721 state.event(
7722 "verify",
7723 format!(
7724 "{context}: waiting for the shared build cache at {} ({})",
7725 cache_dir.display(),
7726 busy.describe()
7727 ),
7728 );
7729 if let Err(e) = state.save() {
7730 tracing::warn!("could not persist a cache wait: {e:#}");
7731 }
7732 let remaining = budget.saturating_sub(started.elapsed());
7733 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7734 Ok(g) => Ok(g),
7735 Err(e) => {
7736 state.event("verify", format!("{context}: {e:#}"));
7737 if let Err(e2) = state.save() {
7738 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7739 }
7740 Err(e)
7741 }
7742 }
7743}
7744
7745#[allow(clippy::too_many_arguments)]
7766async fn with_cache_lease<'s, F, Fut>(
7767 state: &'s mut RunState,
7768 cache_dir: Option<&Path>,
7769 node: &str,
7770 seat: &str,
7771 worktree: &Path,
7772 head: &str,
7773 budget: Duration,
7774 context: &str,
7775 body: F,
7776) -> (Vec<CommandOutcome>, bool)
7777where
7778 F: FnOnce(&'s mut RunState, Duration) -> Fut,
7779 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7780{
7781 let Some(cache_dir) = cache_dir else {
7782 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7783 return (outcomes, retried);
7784 };
7785 let home = crate::run::home();
7786 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7787 let started = Instant::now();
7788 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
7789 Ok(g) => g,
7790 Err(e) => {
7791 return (
7792 vec![CommandOutcome {
7793 command: "(waiting for the shared build cache)".to_owned(),
7794 code: None,
7795 output_tail: e.to_string(),
7796 duration_ms: started.elapsed().as_millis() as u64,
7797 resource_blocked: true,
7798 }],
7799 false,
7800 );
7801 }
7802 };
7803 let identity = crate::cache::Identity::new(worktree, head);
7804 if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
7805 state.event(
7813 "verify",
7814 format!(
7815 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
7816 worktree.display(),
7817 short(head)
7818 ),
7819 );
7820 guard.release();
7821 return (
7822 vec![CommandOutcome {
7823 command: "(confirming the shared build cache is fresh)".to_owned(),
7824 code: None,
7825 output_tail: e.to_string(),
7826 duration_ms: started.elapsed().as_millis() as u64,
7827 resource_blocked: true,
7828 }],
7829 false,
7830 );
7831 }
7832 let remaining = budget.saturating_sub(started.elapsed());
7833 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
7834 if !timed_out_pids.is_empty() {
7839 wait_for_timed_out_children_to_die(&timed_out_pids).await;
7840 }
7841 guard.release();
7842 (outcomes, retried)
7843}
7844
7845async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
7857 wait_for_pids_with(
7858 pids,
7859 crate::proc::pid_alive,
7860 LEASE_RELEASE_POLL,
7861 LEASE_RELEASE_MAX_WAIT,
7862 )
7863 .await;
7864}
7865
7866async fn wait_for_pids_with<F: Fn(u32) -> bool>(
7872 pids: &[u32],
7873 alive: F,
7874 poll: Duration,
7875 max_wait: Duration,
7876) {
7877 let deadline = Instant::now() + max_wait;
7878 loop {
7879 if pids.iter().all(|&pid| !alive(pid)) {
7880 return;
7881 }
7882 if Instant::now() >= deadline {
7883 return;
7884 }
7885 tokio::time::sleep(poll).await;
7886 }
7887}
7888
7889fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
7896 outcomes.iter().any(|o| o.resource_blocked)
7897}
7898
7899enum GateFix {
7901 Retry,
7903 Stop,
7906 Defer,
7909}
7910
7911fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
7919 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
7920 red.peek().is_some()
7921 && red.all(|o| {
7922 !o.resource_blocked
7923 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
7924 && !o.output_tail.trim().is_empty()
7925 })
7926}
7927
7928fn e2e_outcome_label(o: &CommandOutcome) -> String {
7932 if o.ok() {
7933 return "pass".to_owned();
7934 }
7935 let reason = if o.build_failed() {
7936 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
7937 } else {
7938 format!("FAIL ({:?})", o.code)
7939 };
7940 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
7941}
7942
7943async fn run_e2e_with_retry(
7951 state: &mut RunState,
7952 shell: &[String],
7953 commands: &[String],
7954 worktree: &Path,
7955 timeout: Duration,
7956 context: &str,
7957) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
7958 let (mut e2e, mut timed_out_pids) = run_commands(
7959 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
7960 )
7961 .await;
7962 for o in &e2e {
7963 state.event(
7964 "verify",
7965 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
7966 );
7967 }
7968 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
7972 if verify_retried {
7973 state.event(
7974 "verify",
7975 format!(
7976 "{context}: verify could not build/link, not a test result — retrying once \
7977 before concluding"
7978 ),
7979 );
7980 let retried = run_commands(
7981 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
7982 )
7983 .await;
7984 e2e = retried.0;
7985 timed_out_pids.extend(retried.1);
7988 for o in &e2e {
7989 state.event(
7990 "verify",
7991 format!(
7992 "{context}: retry `{}` -> {}",
7993 o.command,
7994 e2e_outcome_label(o)
7995 ),
7996 );
7997 }
7998 }
7999 (e2e, verify_retried, timed_out_pids)
8000}
8001
8002#[allow(clippy::too_many_arguments)]
8018async fn run_commands(
8019 state: &mut RunState,
8020 node: &str,
8021 task: &str,
8022 attempt: usize,
8023 shell: &[String],
8024 commands: &[String],
8025 cwd: &Path,
8026 timeout: Duration,
8027) -> (Vec<CommandOutcome>, Vec<u32>) {
8028 if commands.is_empty() {
8029 return (Vec::new(), Vec::new());
8034 }
8035 let mut out = Vec::new();
8036 let mut timed_out_pids = Vec::new();
8037 let total = commands.len();
8038 for (idx, command) in commands.iter().enumerate() {
8039 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8040 if let Err(e) = state.save() {
8041 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8042 }
8043 let started = Instant::now();
8044 let mut cmd = tokio::process::Command::new(&shell[0]);
8045 cmd.quiet();
8046 cmd.args(&shell[1..])
8047 .arg(command)
8048 .current_dir(cwd)
8049 .stdin(std::process::Stdio::null())
8050 .stdout(std::process::Stdio::piped())
8051 .stderr(std::process::Stdio::piped())
8052 .kill_on_drop(true);
8053 let spawned = cmd.spawn();
8054 let (code, body) = match spawned {
8055 Ok(child) => {
8056 let pid = child.id();
8061 match tokio::time::timeout(timeout, child.wait_with_output()).await {
8062 Ok(Ok(o)) => {
8063 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8064 body.push_str(&String::from_utf8_lossy(&o.stderr));
8065 (o.status.code(), body)
8066 }
8067 Ok(Err(e)) => (None, format!("failed to run: {e}")),
8068 Err(_) => {
8069 if let Some(pid) = pid {
8070 timed_out_pids.push(pid);
8071 }
8072 (None, format!("timed out after {}s", timeout.as_secs()))
8073 }
8074 }
8075 }
8076 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8077 };
8078 out.push(CommandOutcome {
8079 command: command.clone(),
8080 code,
8081 output_tail: tail(&body, OUTPUT_TAIL),
8082 duration_ms: started.elapsed().as_millis() as u64,
8083 resource_blocked: false,
8084 });
8085 }
8086 state.task_finished(task);
8087 if let Err(e) = state.save() {
8088 tracing::warn!("could not persist the end of {task}: {e:#}");
8089 }
8090 (out, timed_out_pids)
8091}
8092
8093fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8105 let repo = repo.display();
8106 match style {
8107 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8108 MergeStyle::Squash => {
8109 let subject = message
8112 .lines()
8113 .next()
8114 .unwrap_or(branch)
8115 .replace(['\\', '"', '$', '`'], "");
8116 format!(
8117 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8118 )
8119 }
8120 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8121 }
8122}
8123
8124const PR_TITLE_MAX: usize = 240;
8135
8136fn english_title(text: &str, max: usize) -> Option<String> {
8147 let line = queue::first_line(text)?;
8148 let chars: Vec<char> = line.chars().collect();
8149 let (kept, mark) = if chars.len() <= max {
8150 (line.to_owned(), "")
8151 } else if let Some(end) = sentence_end(&chars, max) {
8152 (chars[..end].iter().collect::<String>(), "")
8153 } else {
8154 let room = max.saturating_sub(3);
8155 let cut = if chars[room].is_whitespace() {
8158 room
8159 } else {
8160 chars[..room]
8161 .iter()
8162 .rposition(|c| c.is_whitespace())
8163 .unwrap_or(room)
8164 };
8165 let head: String = chars[..cut].iter().collect();
8166 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8167 (head.to_owned(), "...")
8168 };
8169 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8170 return None;
8171 }
8172 Some(format!("{kept}{mark}"))
8173}
8174
8175fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8179 const MIN: usize = 20;
8180 for i in MIN..max.min(chars.len()) {
8181 if !matches!(chars[i], '.' | '!' | '?') {
8182 continue;
8183 }
8184 let Some(&next) = chars.get(i + 1) else {
8185 continue;
8186 };
8187 if !next.is_whitespace() {
8188 continue;
8189 }
8190 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8191 if after.is_some_and(|c| c.is_ascii_lowercase()) {
8192 continue;
8193 }
8194 let word: String = chars[..i]
8195 .iter()
8196 .rev()
8197 .take_while(|c| !c.is_whitespace())
8198 .collect::<Vec<_>>()
8199 .into_iter()
8200 .rev()
8201 .collect();
8202 let word = word.to_ascii_lowercase();
8203 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8204 continue;
8205 }
8206 let end = chars[..i]
8207 .iter()
8208 .rposition(|c| !c.is_whitespace())
8209 .map_or(i, |p| p + 1);
8210 return Some(end);
8211 }
8212 None
8213}
8214
8215struct PrMessage {
8220 title: String,
8221 body: String,
8222}
8223
8224impl PrMessage {
8225 fn commit_message(&self) -> String {
8229 format!("{}\n\n{}", self.title, self.body)
8230 }
8231}
8232
8233fn title_marker(line: &str) -> Option<&str> {
8235 let line = line.trim();
8236 let head = line.get(..6)?;
8237 head.eq_ignore_ascii_case("title:")
8238 .then(|| line[6..].trim())
8239}
8240
8241fn summary_title(summary: &str) -> Option<String> {
8246 let first = summary.lines().find(|l| !l.trim().is_empty())?;
8247 let raw = title_marker(first)?;
8248 if raw.is_empty() {
8249 return None;
8250 }
8251 let title = queue::title_from(raw, PR_TITLE_MAX);
8252 let lower = title.to_ascii_lowercase();
8253 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8254 return None;
8255 }
8256 Some(title)
8257}
8258
8259const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
8261
8262const EXISTING_BRANCH: &str = "(existing branch)";
8264
8265fn is_review_run(state: &RunState) -> bool {
8269 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8270}
8271
8272fn review_title(state: &RunState) -> Option<String> {
8277 english_subject(state.reviewed_commits.as_ref()?.first()?)
8278}
8279
8280fn english_subject(raw: &str) -> Option<String> {
8283 let raw = raw.trim();
8284 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8285 return None;
8286 }
8287 let title = queue::title_from(raw, PR_TITLE_MAX);
8288 let lower = title.to_ascii_lowercase();
8289 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8290 return None;
8291 }
8292 Some(title)
8293}
8294
8295#[derive(Debug, Clone, PartialEq, Eq)]
8298struct BranchFacts {
8299 commits: Vec<(String, String)>,
8301 stat: String,
8303}
8304
8305const STAT_FILE_LINES: usize = 25;
8307const COMMITS_MAX_CHARS: usize = 20_000;
8309
8310async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8313 let commits = git::commit_log(repo, base, branch).await.ok()?;
8314 if commits.is_empty() {
8315 return None;
8316 }
8317 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8318 let lines: Vec<&str> = stat.lines().collect();
8319 let stat = if lines.len() > STAT_FILE_LINES + 1 {
8320 let omitted = lines.len() - 1 - STAT_FILE_LINES;
8321 let more = format!(" ... {omitted} more file(s)");
8322 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8323 kept.push(&more);
8324 kept.push(lines[lines.len() - 1]);
8325 kept.join("\n")
8326 } else {
8327 lines.join("\n")
8328 };
8329 Some(BranchFacts { commits, stat })
8330}
8331
8332fn markdown_safe(text: &str) -> String {
8335 text.replace("</details>", "</details>")
8336 .replace("\x60\x60\x60", "~~~")
8337}
8338
8339fn neutral_title(state: &RunState, winner: char) -> String {
8340 format!(
8341 "chore: land candidate {} of run {}",
8342 winner.to_ascii_uppercase(),
8343 state.id
8344 )
8345}
8346
8347pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8353 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8354 ""
8355 } else {
8356 pr_title
8357 }
8358}
8359
8360pub fn landing_subject_source(state: &RunState) -> String {
8364 if is_review_run(state) {
8365 let winner = state.candidates.first().map_or('A', |c| c.label);
8366 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8367 }
8368 state.instruction.clone()
8369}
8370
8371fn summary_without_title(summary: &str) -> String {
8374 let mut lines = summary.trim().lines().peekable();
8375 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8376 lines.next();
8377 }
8378 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8379}
8380
8381#[cfg(test)]
8395fn pr_message(state: &RunState, winner: char) -> PrMessage {
8396 pr_message_with(state, winner, None)
8397}
8398
8399#[cfg(test)]
8402fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8403 let raw = pr_message_raw(state, winner, facts);
8404 let id = crate::scrub::Identity::current();
8405 PrMessage {
8406 title: crate::scrub::scrub(&raw.title, &id),
8407 body: crate::scrub::scrub(&raw.body, &id),
8408 }
8409}
8410
8411fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8412 let summary = state
8413 .candidates
8414 .iter()
8415 .find(|c| c.label == winner)
8416 .map(|c| c.summary.as_str())
8417 .unwrap_or_default();
8418 let review = is_review_run(state);
8421 let title = if review {
8422 facts
8423 .and_then(|f| english_subject(&f.commits.first()?.0))
8424 .or_else(|| review_title(state))
8425 .or_else(|| {
8426 state
8427 .candidates
8428 .iter()
8429 .find(|c| c.label == winner)
8430 .filter(|c| !c.branch.starts_with("magi/"))
8431 .and_then(|c| english_subject(&c.branch))
8432 })
8433 .unwrap_or_else(|| neutral_title(state, winner))
8434 } else {
8435 summary_title(summary).unwrap_or_else(|| {
8436 english_title(&state.instruction, PR_TITLE_MAX)
8437 .unwrap_or_else(|| neutral_title(state, winner))
8438 })
8439 };
8440
8441 let mut body = String::new();
8442 let what = summary_without_title(summary);
8443 if !what.is_empty() {
8444 body.push_str("## Summary\n\n");
8445 body.push_str(&what);
8446 body.push_str("\n\n");
8447 }
8448
8449 let notes: Vec<(usize, &str)> = state
8452 .reviews
8453 .iter()
8454 .filter_map(|r| {
8455 let n = r.fix.as_ref()?.notes.trim();
8456 (!n.is_empty()).then_some((r.round, n))
8457 })
8458 .collect();
8459 if !notes.is_empty() {
8460 body.push_str("## Review fixes\n\n");
8461 if let [(_, only)] = notes.as_slice() {
8462 body.push_str(only);
8463 body.push_str("\n\n");
8464 } else {
8465 for (round, n) in ¬es {
8466 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8467 }
8468 }
8469 }
8470 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8471
8472 let open = state.open_findings();
8473 if !open.is_empty() {
8474 body.push_str("## Open review findings\n\n");
8475 for f in &open {
8476 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8477 }
8478 body.push('\n');
8479 }
8480
8481 if let Some(fix) = fix
8482 && !fix.rejected.is_empty()
8483 {
8484 body.push_str("## Declined by the fixer\n\n");
8485 for r in &fix.rejected {
8486 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8487 }
8488 body.push('\n');
8489 }
8490
8491 if review {
8492 body.push_str("## Commits under review\n\n");
8494 if let Some(facts) = facts {
8495 let mut left = COMMITS_MAX_CHARS;
8496 for (i, (subject, text)) in facts.commits.iter().enumerate() {
8497 let mut entry = format!("- {}\n", markdown_safe(subject));
8498 for l in markdown_safe(text).lines() {
8499 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
8500 }
8501 if left == 0 {
8502 body.push_str(&format!(
8503 "- ... {} more commit(s)\n",
8504 facts.commits.len() - i
8505 ));
8506 break;
8507 }
8508 if entry.len() > left {
8509 let mut end = left;
8512 while !entry.is_char_boundary(end) {
8513 end -= 1;
8514 }
8515 entry.truncate(end);
8516 entry.push_str("\n ... (truncated)\n");
8517 left = 0;
8518 } else {
8519 left -= entry.len();
8520 }
8521 body.push_str(&entry);
8522 }
8523 if !facts.stat.trim().is_empty() {
8524 body.push_str(&format!(
8525 "\n## Diff stat\n\n```\n{}\n```\n",
8526 markdown_safe(facts.stat.trim())
8527 ));
8528 }
8529 } else {
8530 match &state.reviewed_commits {
8531 Some(subjects) => {
8532 for s in subjects {
8533 body.push_str(&format!("- {}\n", s.trim()));
8534 }
8535 }
8536 None => {
8537 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8540 body.push_str(rest.trim());
8541 body.push('\n');
8542 }
8543 }
8544 }
8545 } else {
8546 let task = state.instruction.trim();
8547 let task = if task.is_empty() {
8548 "(empty task)"
8549 } else {
8550 task
8551 };
8552 body.push_str(&format!(
8553 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8554 task.replace("</details>", "</details>")
8555 ));
8556 }
8557
8558 body.push_str(&format!(
8559 "\n---\nmagi:run/{} magi:candidate-{}\n",
8560 state.id,
8561 winner.to_ascii_lowercase()
8562 ));
8563
8564 PrMessage { title, body }
8565}
8566
8567fn seeded_instruction(state: &RunState) -> String {
8571 match refs::describe(&state.seeds) {
8572 Some(facts) => format!(
8573 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8574 Candidates start from the unmerged branch named above, when there \
8575 is one, and carry any unmerged commit named by sha as a \
8576 cherry-pick. Check that this is what the task meant before \
8577 building on it.",
8578 state.instruction
8579 ),
8580 None => state.instruction.clone(),
8581 }
8582}
8583
8584async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8588 let base = &state.base_branch;
8589 let mut against = base.clone();
8590 if mode == MergeMode::Pr {
8591 let remote = &state.config.merge.remote;
8595 let tracking = format!("{remote}/{base}");
8596 let fetched = git::fetch(repo, remote, base).await;
8597 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8598 against = tracking;
8599 } else {
8600 return false;
8601 }
8602 }
8603 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8604}
8605
8606fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8609 let mut detail = format!(
8610 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8611 nothing to open a pull request for"
8612 );
8613 match refs::describe(&state.seeds) {
8614 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8615 None => detail.push_str(
8616 "\nThe task names no existing branch or commit; if it means to land work \
8617 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8618 ),
8619 }
8620 detail
8621}
8622
8623#[derive(Debug, PartialEq, Eq)]
8626enum PrPlan {
8627 Create,
8628 Adopt { url: String, title: String },
8629 Stop(String),
8630}
8631
8632fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8635 match found {
8636 Ok(land::OpenPr::None) => PrPlan::Create,
8637 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8638 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8639 "several open pull requests exist for this branch, not picking one: {}",
8640 urls.join(" ")
8641 )),
8642 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8643 }
8644}
8645
8646async fn gh_pr_create(
8648 cwd: &Path,
8649 base: &str,
8650 head: &str,
8651 title: &str,
8652 body: &str,
8653) -> Result<String> {
8654 let out = tokio::process::Command::new("gh")
8655 .args([
8656 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8657 ])
8658 .current_dir(cwd)
8659 .quiet()
8660 .stdin(std::process::Stdio::null())
8661 .output()
8662 .await
8663 .context("spawn gh")?;
8664 if out.status.success() {
8665 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8666 } else {
8667 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8668 }
8669}
8670
8671pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8680 let repo = state.repo.clone();
8681 let root = state.worktree_root();
8682 let winner = state.tally.as_ref().map(|t| t.winner);
8683 let mut removed = Vec::new();
8684
8685 for i in 0..state.candidates.len() {
8686 let c = state.candidates[i].clone();
8687 let is_winner = Some(c.label) == winner;
8688 if is_winner && !drop_winner {
8689 continue;
8690 }
8691 if c.worktree.exists() {
8692 git::worktree_remove(&repo, &c.worktree).await.ok();
8693 removed.push(c.worktree.to_string_lossy().into_owned());
8694 }
8695 let handed_over = state.released_branches.contains(&c.branch);
8698 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8699 git::branch_delete(&repo, &c.branch).await.ok();
8700 removed.push(c.branch.clone());
8701 }
8702 state.candidates[i].folded = true;
8703 }
8704
8705 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8706 let path = name.path();
8707 let keep = !drop_winner
8708 && winner.is_some_and(|w| {
8709 path.file_name()
8710 .is_some_and(|n| n == format!("cand-{w}").as_str())
8711 });
8712 if keep {
8713 continue;
8714 }
8715 git::worktree_remove(&repo, &path).await.ok();
8716 removed.push(path.to_string_lossy().into_owned());
8717 }
8718
8719 remove_if_empty(&root);
8728
8729 if state.enabled_worktree_config && drop_winner {
8730 git::release_worktree_config(&repo).await.ok();
8734 state.enabled_worktree_config = false;
8735 }
8736 state.save_under(home)?;
8737 Ok(removed)
8738}
8739
8740fn remove_if_empty(dir: &Path) {
8751 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
8752 std::fs::remove_dir(dir).ok();
8753 }
8754}
8755
8756pub fn worst_open(state: &RunState) -> Option<Severity> {
8758 state
8759 .reviews
8760 .last()?
8761 .reviews
8762 .iter()
8763 .flat_map(|r| r.findings.iter())
8764 .map(|f| f.severity)
8765 .max()
8766}
8767
8768async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
8772 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
8773 git::acquire_worktree_config(repo).await?;
8774 state.enabled_worktree_config = true;
8775 }
8776 Ok(())
8777}
8778
8779#[cfg(test)]
8780mod tests {
8781 #[test]
8782 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
8783 let leaked = vec!["chore(deps): update a crate".to_owned()];
8784 let own = "fix(daemon): apply a chosen action";
8785 assert!(should_retitle("", own, &leaked));
8786 assert!(should_retitle(
8787 &format!("{REVIEW_PROMPT_OPENING} `x`"),
8788 own,
8789 &leaked
8790 ));
8791 assert!(should_retitle(
8792 "chore: land candidate A of run 1",
8793 own,
8794 &leaked
8795 ));
8796 assert!(should_retitle(
8797 "magi: candidate A (uncommitted work)",
8798 own,
8799 &leaked
8800 ));
8801 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
8802 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
8803 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
8804 }
8805
8806 #[test]
8807 fn pr_merge_plan_creates_adopts_or_stops() {
8808 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
8809 assert_eq!(
8810 pr_merge_plan(Ok(land::OpenPr::One {
8811 url: "u".into(),
8812 title: "t".into()
8813 })),
8814 PrPlan::Adopt {
8815 url: "u".into(),
8816 title: "t".into()
8817 }
8818 );
8819 let PrPlan::Stop(many) =
8820 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
8821 else {
8822 panic!("many must stop");
8823 };
8824 assert!(many.contains('a') && many.contains('b'));
8825 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
8826 panic!("a failed lookup must stop");
8827 };
8828 assert!(err.contains("bad token"));
8829 }
8830
8831 use super::*;
8832 use crate::run::GateStatus;
8833 use std::collections::BTreeMap;
8834 use std::time::Duration;
8835
8836 fn conductor() -> AgentSpec {
8837 AgentSpec {
8838 id: "conductor".to_owned(),
8839 kind: crate::config::AgentKind::Command,
8840 model: None,
8841 command: vec!["true".to_owned()],
8842 extra_args: Vec::new(),
8843 env: BTreeMap::new(),
8844 prompt_delivery: None,
8845 }
8846 }
8847
8848 fn spec(id: &str) -> AgentSpec {
8849 AgentSpec {
8850 id: id.to_owned(),
8851 kind: crate::config::AgentKind::Command,
8852 model: None,
8853 command: vec!["true".to_owned()],
8854 extra_args: Vec::new(),
8855 env: BTreeMap::new(),
8856 prompt_delivery: None,
8857 }
8858 }
8859
8860 fn ids(xs: &[&str]) -> BTreeSet<String> {
8861 xs.iter().map(|s| (*s).to_owned()).collect()
8862 }
8863
8864 #[test]
8865 fn pick_successor_skips_an_agent_another_seat_holds() {
8866 let roster = [spec("a"), spec("b"), spec("c")];
8868 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8869 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8870 }
8871
8872 #[test]
8873 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
8874 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
8876 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8877 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8878 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
8879 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
8880 }
8881
8882 #[test]
8883 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
8884 let roster = [spec("a"), spec("b")];
8885 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8886 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8887 let carried = ids(&["b"]);
8888 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
8889 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8890 }
8891
8892 #[test]
8893 fn pick_successor_returns_none_without_any_untried_successor() {
8894 let roster = [spec("a"), spec("b")];
8895 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
8896 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
8897 let carried = ids(&["a"]);
8898 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
8899 }
8900
8901 #[test]
8902 fn pick_successor_rescue_avoids_another_seats_occupant() {
8903 let roster = [spec("a"), spec("b"), spec("c")];
8905 let carried = ids(&["b", "c"]);
8906 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
8907 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8908 }
8909
8910 #[test]
8911 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
8912 let roster = [spec("a"), spec("b"), spec("c")];
8913 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
8914 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8915 }
8916
8917 #[test]
8918 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
8919 let roster = [spec("a"), spec("b"), spec("c")];
8920 let failed = ids(&["a", "b", "c"]);
8921 let mut tried = ids(&["b"]);
8923 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8924 assert_eq!(first.id, "a");
8925 tried.insert(first.id.clone());
8926 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8927 assert_eq!(second.id, "c");
8928 tried.insert(second.id.clone());
8929 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
8930 }
8931
8932 #[test]
8933 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
8934 let roster = [spec("a"), spec("b")];
8935 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
8936 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8937 }
8938
8939 #[test]
8940 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
8941 let roster = [spec("a"), spec("b"), spec("c")];
8942 let h = SeatHistory {
8943 failed: ids(&["a"]),
8944 last_ok: Some("b".to_owned()),
8945 last_fail: None,
8946 };
8947 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
8948 let gone = SeatHistory {
8950 last_ok: Some("zzz".to_owned()),
8951 ..h.clone()
8952 };
8953 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
8954 let failed = SeatHistory {
8955 failed: ids(&["a", "b"]),
8956 last_ok: Some("b".to_owned()),
8957 last_fail: None,
8958 };
8959 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
8960 }
8961
8962 #[test]
8963 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
8964 let first = SeatState::new("review-1", "alpha", 7);
8965 let next = handover_seat("review-1", "gamma", 7);
8966 assert_ne!(first.claude_session, next.claude_session);
8967 }
8968
8969 #[test]
8970 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
8971 let mut state = state_with_summary("x", "y");
8972 let a = handover_seat("review-1", "beta", state.next_seat_seed());
8973 let b = handover_seat("review-1", "beta", state.next_seat_seed());
8974 assert_ne!(a.claude_session, b.claude_session);
8975 }
8976
8977 #[test]
8978 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
8979 let roster = [spec("a"), spec("b")];
8980 let h = SeatHistory {
8981 failed: ids(&["a", "b"]),
8982 ..SeatHistory::default()
8983 };
8984 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
8985 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
8986 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
8987 }
8988
8989 #[test]
8996 fn failure_signature_ignores_numbers_and_paths() {
8997 assert_eq!(
8998 failure_signature("exited with Some(2) and no usable output"),
8999 failure_signature("exited with Some(137) and no usable output")
9000 );
9001 assert_eq!(
9002 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9003 failure_signature("cannot open /var/x.txt: denied")
9004 );
9005 assert_ne!(failure_signature("boom"), failure_signature("bang"));
9006 }
9007
9008 #[test]
9009 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9010 let other = FailClass::Other("x".into());
9011 assert!(should_hand_over(None, &FailClass::Quota));
9012 assert!(should_hand_over(Some(&other), &FailClass::Quota));
9013 assert!(should_hand_over(
9014 Some(&FailClass::Timeout),
9015 &FailClass::Timeout
9016 ));
9017 assert!(should_hand_over(None, &other));
9018 assert!(!should_hand_over(Some(&other), &other));
9019 assert!(should_hand_over(
9020 Some(&other),
9021 &FailClass::Other("y".into())
9022 ));
9023 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9025 assert!(should_hand_over(Some(&FailClass::Quota), &other));
9026 }
9027
9028 #[test]
9029 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9030 let a = SeatState::new("judge-1", "alpha", 7);
9031 let b = handover_seat("judge-1", "beta", 7);
9032 assert_ne!(a.claude_session, b.claude_session);
9033 assert_eq!(b.turns, 0);
9034 }
9035
9036 #[test]
9037 fn a_timeout_is_classified_apart_from_other_failures() {
9038 assert_eq!(
9039 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9040 Some(FailClass::Timeout)
9041 );
9042 assert!(matches!(
9043 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9044 Some(FailClass::Other(_))
9045 ));
9046 }
9047
9048 #[test]
9049 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9050 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9051 let tried = BTreeSet::from(["beta".to_owned()]);
9052 let next = next_untried_in_roster(&roster, 1, &tried);
9055 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9056 }
9057
9058 #[test]
9059 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9060 let roster = vec![spec("alpha"), spec("beta")];
9061 let tried = BTreeSet::from(["beta".to_owned()]);
9062 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9066 }
9067
9068 #[test]
9069 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9070 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9071 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9072 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9076 }
9077
9078 #[test]
9079 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9080 let roster = vec![spec("a"), spec("a"), spec("b")];
9081 let tried = BTreeSet::from(["a".to_owned()]);
9082 let next = next_untried_in_roster(&roster, 0, &tried);
9083 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9084 }
9085
9086 #[test]
9087 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9088 let roster = vec![spec("a"), spec("b")];
9089 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9090 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9091 }
9092
9093 #[test]
9094 fn remove_if_empty_only_ever_takes_a_bare_directory() {
9095 let dir = tempfile::tempdir().unwrap();
9096 let bay = dir.path().join("ffff");
9097
9098 remove_if_empty(&bay);
9100 assert!(!bay.exists());
9101
9102 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9105 remove_if_empty(&bay);
9106 assert!(bay.exists(), "non-empty directory must survive");
9107
9108 std::fs::remove_dir(bay.join("cand-A")).unwrap();
9110 remove_if_empty(&bay);
9111 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9112 }
9113
9114 #[test]
9123 fn a_full_panel_that_found_nothing_is_clean() {
9124 assert!(round_is_clean(
9125 0,
9126 true,
9127 2,
9128 2,
9129 0,
9130 IncompleteReviewPolicy::Block
9131 ));
9132 }
9133
9134 #[test]
9135 fn a_missing_seat_is_never_clean_under_the_default_policy() {
9136 assert!(!round_is_clean(
9137 0,
9138 true,
9139 1,
9140 2,
9141 0,
9142 IncompleteReviewPolicy::Block
9143 ));
9144 }
9145
9146 #[test]
9147 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9148 assert!(!round_is_clean(
9149 1,
9150 true,
9151 1,
9152 2,
9153 0,
9154 IncompleteReviewPolicy::Warn
9155 ));
9156 }
9157
9158 #[test]
9159 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9160 assert!(round_is_clean(
9161 0,
9162 true,
9163 1,
9164 2,
9165 0,
9166 IncompleteReviewPolicy::Warn
9167 ));
9168 }
9169
9170 #[test]
9171 fn a_full_panel_with_an_open_finding_is_not_clean() {
9172 assert!(!round_is_clean(
9173 1,
9174 true,
9175 2,
9176 2,
9177 0,
9178 IncompleteReviewPolicy::Block
9179 ));
9180 }
9181
9182 #[test]
9183 fn a_full_panel_with_a_red_e2e_is_not_clean() {
9184 assert!(!round_is_clean(
9185 0,
9186 false,
9187 2,
9188 2,
9189 0,
9190 IncompleteReviewPolicy::Block
9191 ));
9192 }
9193
9194 #[test]
9201 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9202 assert!(round_is_clean(
9205 0,
9206 true,
9207 1,
9208 2,
9209 1,
9210 IncompleteReviewPolicy::Block
9211 ));
9212 }
9213
9214 #[test]
9215 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9216 assert!(!round_is_clean(
9219 0,
9220 true,
9221 1,
9222 2,
9223 0,
9224 IncompleteReviewPolicy::Block
9225 ));
9226 }
9227
9228 #[test]
9229 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9230 assert!(!round_is_clean(
9231 1,
9232 true,
9233 1,
9234 2,
9235 1,
9236 IncompleteReviewPolicy::Block
9237 ));
9238 assert!(!round_is_clean(
9239 0,
9240 false,
9241 1,
9242 2,
9243 1,
9244 IncompleteReviewPolicy::Block
9245 ));
9246 }
9247
9248 #[test]
9249 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9250 assert!(!round_is_clean(
9254 0,
9255 true,
9256 0,
9257 2,
9258 2,
9259 IncompleteReviewPolicy::Block
9260 ));
9261 }
9262
9263 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
9264 CommandOutcome {
9265 command: "test".to_owned(),
9266 code,
9267 output_tail: String::new(),
9268 duration_ms: 0,
9269 resource_blocked,
9270 }
9271 }
9272
9273 #[test]
9274 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
9275 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
9276 assert!(
9277 !verify_inconclusive(&[outcome(Some(1), false)]),
9278 "an ordinary failure is still evidence about the patch"
9279 );
9280 assert!(verify_inconclusive(&[outcome(None, true)]));
9281 assert!(
9282 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
9283 "one inconclusive outcome taints the whole batch"
9284 );
9285 assert!(!verify_inconclusive(&[]));
9286 }
9287
9288 #[tokio::test]
9289 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9290 let calls = std::sync::atomic::AtomicUsize::new(0);
9294 let started = Instant::now();
9295 wait_for_pids_with(
9296 &[123],
9297 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9298 Duration::from_millis(5),
9299 Duration::from_secs(5),
9300 )
9301 .await;
9302 assert!(
9303 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9304 "must keep checking rather than deciding on the first answer"
9305 );
9306 assert!(
9307 started.elapsed() < Duration::from_secs(1),
9308 "must return the moment it is confirmed dead, not wait out the ceiling"
9309 );
9310 }
9311
9312 #[tokio::test]
9313 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9314 let started = Instant::now();
9315 wait_for_pids_with(
9316 &[123],
9317 |_| true, Duration::from_millis(5),
9319 Duration::from_millis(30),
9320 )
9321 .await;
9322 let elapsed = started.elapsed();
9323 assert!(
9324 elapsed >= Duration::from_millis(30),
9325 "must not give up before its own ceiling: {elapsed:?}"
9326 );
9327 assert!(
9328 elapsed < Duration::from_secs(1),
9329 "must not wait past its own ceiling either: {elapsed:?}"
9330 );
9331 }
9332
9333 #[tokio::test]
9334 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9335 let started = Instant::now();
9336 wait_for_pids_with(
9337 &[],
9338 |_| true,
9339 Duration::from_secs(5),
9340 Duration::from_secs(5),
9341 )
9342 .await;
9343 assert!(
9344 started.elapsed() < Duration::from_millis(200),
9345 "an empty pid list has nothing to confirm"
9346 );
9347 }
9348
9349 fn review_round(
9355 clean: bool,
9356 blocking: usize,
9357 answered: usize,
9358 expected: usize,
9359 progressed: bool,
9360 e2e_ok: bool,
9361 ) -> ReviewRound {
9362 ReviewRound {
9363 round: 1,
9364 head: "h".to_owned(),
9365 verified_head: None,
9366 verified_at: None,
9367 reviews: Vec::new(),
9368 e2e: vec![CommandOutcome {
9369 command: "test".to_owned(),
9370 code: Some(if e2e_ok { 0 } else { 1 }),
9371 output_tail: String::new(),
9372 duration_ms: 0,
9373 resource_blocked: false,
9374 }],
9375 verify_retried: false,
9376 e2e_deferred: false,
9377 e2e_defer_reason: None,
9378 fix: None,
9379 blocking,
9380 answered,
9381 expected,
9382 clean,
9383 progressed,
9384 vote_split: false,
9385 reconsideration: Vec::new(),
9386 verdict: None,
9387 }
9388 }
9389
9390 #[test]
9391 fn review_conclusion_is_none_when_nothing_has_run() {
9392 assert_eq!(review_conclusion(&[], 3), None);
9393 }
9394
9395 #[test]
9396 fn review_conclusion_is_none_while_rounds_remain() {
9397 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9398 assert_eq!(review_conclusion(&rounds, 3), None);
9399 }
9400
9401 #[test]
9402 fn review_conclusion_is_gating_once_a_round_is_clean() {
9403 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9404 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9405 }
9406
9407 #[test]
9408 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9409 let rounds = vec![
9410 review_round(false, 1, 2, 2, true, true),
9411 review_round(false, 1, 2, 2, true, true),
9412 ];
9413 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9414 }
9415
9416 #[test]
9417 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9418 let rounds = vec![
9419 review_round(false, 1, 2, 2, true, true),
9420 review_round(false, 1, 2, 2, true, false),
9421 ];
9422 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9423 }
9424
9425 #[test]
9426 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9427 let mut blocked = review_round(false, 1, 2, 2, true, false);
9434 blocked.e2e[0].resource_blocked = true;
9435 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9436 assert_eq!(review_conclusion(&rounds, 2), None);
9437 }
9438
9439 #[test]
9440 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9441 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9443 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9444 }
9445
9446 #[test]
9447 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9448 let rounds = vec![
9449 review_round(false, 1, 2, 2, false, true),
9450 review_round(false, 1, 2, 2, false, true),
9451 ];
9452 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9453 }
9454
9455 fn secs(n: u64) -> Duration {
9456 Duration::from_secs(n)
9457 }
9458
9459 fn init_repo(dir: &Path) {
9462 let run = |args: &[&str]| {
9463 let out = std::process::Command::new("git")
9464 .args(args)
9465 .current_dir(dir)
9466 .quiet()
9467 .output()
9468 .expect("spawn git");
9469 assert!(
9470 out.status.success(),
9471 "git {args:?} failed: {}",
9472 String::from_utf8_lossy(&out.stderr)
9473 );
9474 };
9475 run(&["init", "-b", "main"]);
9476 run(&["config", "user.name", "magi test"]);
9477 run(&["config", "user.email", "magi@example.com"]);
9478 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9479 run(&["add", "-A"]);
9480 run(&["commit", "-m", "init"]);
9481 }
9482
9483 fn ask_test_home() {
9491 crate::run::pin_test_home();
9492 }
9493
9494 fn runner_at(status: RunStatus) -> Runner {
9497 let mut state = RunState::new(
9498 PathBuf::from("/nonexistent/repo"),
9499 "main".to_owned(),
9500 "deadbeef".to_owned(),
9501 "task".to_owned(),
9502 Config::default(),
9503 );
9504 state.status = status;
9505 Runner {
9506 state,
9507 roles: ResolvedRoles {
9508 implementers: Vec::new(),
9509 judges: Vec::new(),
9510 reviewers: Vec::new(),
9511 fixer: None,
9512 conductor: conductor(),
9513 implementer_roster: Vec::new(),
9514 judge_roster: Vec::new(),
9515 reviewer_roster: Vec::new(),
9516 },
9517 sem: Arc::new(Semaphore::new(1)),
9518 pause: Pause::new(),
9519 interrupt: Pause::new(),
9520 }
9521 }
9522
9523 #[test]
9527 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9528 crate::run::pin_test_home();
9529 let mut runner = runner_at(RunStatus::Implementing);
9530 let interrupt = Pause::new();
9531 runner.watch_interrupt(interrupt.clone());
9532
9533 interrupt.park_because("task a1b2 asked to run first");
9534
9535 assert!(runner.park_here().expect("park_here"));
9536 assert!(runner.state.parked);
9537 let last = runner.state.events.last().expect("a park event");
9538 assert_eq!(last.node, "park");
9539 assert!(
9540 last.message.contains("task a1b2 asked to run first"),
9541 "expected the interrupt reason in {:?}",
9542 last.message
9543 );
9544 }
9545
9546 #[test]
9554 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9555 crate::run::pin_test_home();
9556 let mut runner = runner_at(RunStatus::Implementing);
9557 let shutdown = Pause::new();
9558 runner.on_pause(shutdown.clone());
9559 let interrupt = Pause::new();
9560 runner.watch_interrupt(interrupt.clone());
9561
9562 assert!(!runner.park_here().expect("park_here"));
9564 assert!(!runner.state.parked);
9565
9566 interrupt.park_because("test");
9568 assert!(!shutdown.parked());
9569 assert!(runner.park_here().expect("park_here"));
9570 }
9571
9572 #[tokio::test]
9586 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9587 crate::run::pin_test_home();
9588 let mut runner = runner_at(RunStatus::Implementing);
9589 let interrupt = Pause::new();
9590 runner.watch_interrupt(interrupt.clone());
9591
9592 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9593 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9594
9595 let node = async move {
9599 started_tx.send(()).expect("send started");
9600 finish_rx.await.expect("recv finish");
9601 "node finished"
9602 };
9603
9604 let interrupter = async move {
9605 started_rx.await.expect("recv started");
9606 interrupt.park_because("higher-priority task waiting");
9608 tokio::task::yield_now().await;
9612 finish_tx.send(()).expect("send finish");
9613 };
9614
9615 let (node_result, ()) = tokio::join!(node, interrupter);
9616 assert_eq!(
9617 node_result, "node finished",
9618 "the in-flight call ran to completion"
9619 );
9620
9621 assert!(runner.park_here().expect("park_here"));
9624 assert!(runner.state.parked);
9625 }
9626
9627 #[test]
9633 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9634 crate::run::pin_test_home();
9635 let mut runner = runner_at(RunStatus::Judging);
9636 runner.state.config.agents = vec![conductor()];
9640 runner.state.candidates = vec![Candidate {
9641 index: 0,
9642 label: 'A',
9643 agent: "alpha".to_owned(),
9644 branch: "magi/x/A".to_owned(),
9645 worktree: PathBuf::from("/nonexistent/worktree"),
9646 summary: "did the thing".to_owned(),
9647 stat: "1 file changed".to_owned(),
9648 files: 1,
9649 commits: 1,
9650 empty: false,
9651 failed: None,
9652 verified_noop: None,
9653 duration_ms: 1234,
9654 folded: false,
9655 }];
9656 let run_id = runner.state.id.clone();
9657
9658 let interrupt = Pause::new();
9659 runner.watch_interrupt(interrupt.clone());
9660 interrupt.park_because("task c3d4 asked to run first");
9661 assert!(runner.park_here().expect("park_here"));
9662
9663 let resumed = Runner::resume(&run_id).expect("resume");
9664 assert_eq!(resumed.state.candidates.len(), 1);
9665 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9666 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9667 assert_eq!(resumed.state.status, runner.state.status);
9668 assert!(
9669 resumed.state.parked,
9670 "still parked until `execute` actually walks the graph again"
9671 );
9672 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9673 }
9674
9675 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9677 let mut q = ask::Question::new(
9678 run.to_owned(),
9679 "implement".to_owned(),
9680 "impl-A".to_owned(),
9681 "Which storage backend should the cache use?".to_owned(),
9682 String::new(),
9683 vec!["SQLite".to_owned(), "Redis".to_owned()],
9684 );
9685 store.put(&mut q).unwrap();
9686 q
9687 }
9688
9689 #[test]
9690 fn a_failed_runs_open_question_is_abandoned() {
9691 ask_test_home();
9692 let store = ask::Questions::open();
9693 let mut runner = runner_at(RunStatus::Failed);
9694 let run = runner.state.id.clone();
9695 let q = ask_open_question(&store, &run);
9696
9697 runner.settle_questions();
9698
9699 let back = store.get(&q.id).unwrap();
9700 assert!(
9701 !back.status.open(),
9702 "the seat that asked died with the run; nobody is left to read an answer"
9703 );
9704 assert!(
9705 back.detail.contains(&run) && back.detail.contains("failed"),
9706 "the reason names what the run became, not just that it is gone: {}",
9707 back.detail
9708 );
9709 }
9710
9711 #[test]
9712 fn a_merged_runs_open_question_is_abandoned_too() {
9713 ask_test_home();
9714 let store = ask::Questions::open();
9715 for status in [RunStatus::Merged, RunStatus::Ready] {
9718 let mut runner = runner_at(status);
9719 let run = runner.state.id.clone();
9720 let q = ask_open_question(&store, &run);
9721
9722 runner.settle_questions();
9723
9724 let back = store.get(&q.id).unwrap();
9725 assert!(
9726 !back.status.open(),
9727 "{status:?} run's question must not outlive the run"
9728 );
9729 }
9730 }
9731
9732 #[test]
9733 fn a_still_resumable_runs_open_question_is_left_alone() {
9734 ask_test_home();
9735 let store = ask::Questions::open();
9736 for status in [RunStatus::Blocked, RunStatus::Stalled] {
9742 let mut runner = runner_at(status);
9743 let run = runner.state.id.clone();
9744 let q = ask_open_question(&store, &run);
9745
9746 runner.settle_questions();
9747
9748 let back = store.get(&q.id).unwrap();
9749 assert!(
9750 back.status.open(),
9751 "{status:?} is still alive; the question must still be waiting"
9752 );
9753 }
9754 }
9755
9756 #[test]
9757 fn settle_questions_never_touches_an_already_answered_question() {
9758 ask_test_home();
9759 let store = ask::Questions::open();
9760 let mut runner = runner_at(RunStatus::Failed);
9761 let run = runner.state.id.clone();
9762 let mut q = ask_open_question(&store, &run);
9763 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
9764 .unwrap();
9765 store.put(&mut q).unwrap();
9766
9767 runner.settle_questions();
9772 runner.settle_questions();
9773
9774 let back = store.get(&q.id).unwrap();
9775 assert_eq!(
9776 back.status,
9777 ask::QuestionStatus::Answered,
9778 "a real answer is a decision on record, never overwritten by a sweep"
9779 );
9780 }
9781
9782 #[tokio::test]
9793 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
9794 crate::run::pin_test_home();
9795 let tmp = tempfile::tempdir().expect("tempdir");
9796 let repo = tmp.path().join("repo");
9797 std::fs::create_dir_all(&repo).unwrap();
9798 init_repo(&repo);
9799
9800 let mut config = Config::default();
9801 config.graph.worktree_root = Some(tmp.path().join("wt"));
9802
9803 let mut state = RunState::new(
9804 repo.clone(),
9805 "main".to_owned(),
9806 "deadbeef".to_owned(),
9807 "task".to_owned(),
9808 config,
9809 );
9810 let root = state.worktree_root();
9811 let wt_a = root.join("cand-A");
9812 let wt_b = root.join("cand-B");
9813 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
9814 .await
9815 .expect("worktree A");
9816 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
9817 .await
9818 .expect("worktree B");
9819
9820 state.candidates = vec![
9821 Candidate {
9822 index: 0,
9823 label: 'A',
9824 agent: "alpha".to_owned(),
9825 branch: "magi/x/A".to_owned(),
9826 worktree: wt_a.clone(),
9827 summary: String::new(),
9828 stat: String::new(),
9829 files: 0,
9830 commits: 0,
9831 empty: false,
9832 failed: None,
9833 verified_noop: None,
9834 duration_ms: 0,
9835 folded: false,
9836 },
9837 Candidate {
9838 index: 1,
9839 label: 'B',
9840 agent: "beta".to_owned(),
9841 branch: "magi/x/B".to_owned(),
9842 worktree: wt_b.clone(),
9843 summary: String::new(),
9844 stat: String::new(),
9845 files: 0,
9846 commits: 0,
9847 empty: false,
9848 failed: None,
9849 verified_noop: None,
9850 duration_ms: 0,
9851 folded: false,
9852 },
9853 ];
9854 state.tally = Some(Tally {
9855 first_choice: BTreeMap::from([('A', 1)]),
9856 borda: BTreeMap::new(),
9857 winner: 'A',
9858 rankings: 1,
9859 unanimous_initial: true,
9860 deliberated: false,
9861 changed_votes: 0,
9862 unanimous_final: true,
9863 tie_break: None,
9864 judges: 1,
9865 present: 1,
9866 quorum: 1,
9867 met_quorum: true,
9868 uncontested: None,
9869 });
9870 state.status = RunStatus::Ready;
9871
9872 fold_run(&mut state, false, &crate::run::home())
9873 .await
9874 .expect("fold_run");
9875
9876 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
9877 assert!(
9878 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9879 "the unmerged winner's branch survives"
9880 );
9881 assert!(
9882 !state.candidates[0].folded,
9883 "the winner is not marked folded"
9884 );
9885
9886 assert!(!wt_b.exists(), "the loser's worktree is removed");
9887 assert!(
9888 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
9889 "the loser's branch is removed"
9890 );
9891 assert!(state.candidates[1].folded, "the loser is marked folded");
9892 }
9893
9894 #[tokio::test]
9897 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
9898 let tmp = tempfile::tempdir().expect("tempdir");
9899 let repo = tmp.path().join("repo");
9900 std::fs::create_dir_all(&repo).unwrap();
9901 init_repo(&repo);
9902 let home = tmp.path().join("home");
9903
9904 let mut config = Config::default();
9905 config.graph.worktree_root = Some(tmp.path().join("wt"));
9906 let mut state = RunState::new(
9907 repo.clone(),
9908 "main".to_owned(),
9909 "deadbeef".to_owned(),
9910 "task".to_owned(),
9911 config,
9912 );
9913 git::git(&repo, &["branch", "magi/x/A", "main"])
9915 .await
9916 .expect("branch");
9917 state.candidates = vec![Candidate {
9918 index: 0,
9919 label: 'A',
9920 agent: "alpha".to_owned(),
9921 branch: "magi/x/A".to_owned(),
9922 worktree: state.worktree_root().join("cand-A"),
9923 summary: String::new(),
9924 stat: String::new(),
9925 files: 0,
9926 commits: 0,
9927 empty: false,
9928 failed: None,
9929 verified_noop: None,
9930 duration_ms: 0,
9931 folded: true,
9932 }];
9933 state.released_to = Some("20260901-000000-new1".to_owned());
9934 state.released_branches = vec!["magi/x/A".to_owned()];
9935
9936 fold_run(&mut state, true, &home).await.expect("fold_run");
9937
9938 assert!(
9939 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9940 "the handed-over branch survives a fold"
9941 );
9942 }
9943
9944 #[tokio::test]
9948 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
9949 let tmp = tempfile::tempdir().expect("tempdir");
9950 let repo = tmp.path().join("repo");
9951 std::fs::create_dir_all(&repo).unwrap();
9952 init_repo(&repo);
9953 let run = |args: &[&str]| {
9954 let out = std::process::Command::new("git")
9955 .quiet()
9956 .args(args)
9957 .current_dir(&repo)
9958 .output()
9959 .expect("spawn git");
9960 assert!(out.status.success(), "git {args:?}");
9961 };
9962 run(&["branch", "magi/x/A"]);
9963 run(&["checkout", "-q", "-b", "magi/x/B"]);
9964 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
9965 run(&["add", "-A"]);
9966 run(&["commit", "-q", "-m", "work"]);
9967 run(&["checkout", "-q", "main"]);
9968
9969 let probe = RunState::new(
9973 repo.clone(),
9974 "main".to_owned(),
9975 "deadbeef".to_owned(),
9976 "task".to_owned(),
9977 Config::default(),
9978 );
9979 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
9980 let bare = tmp.path().join("origin.git");
9981 let out = std::process::Command::new("git")
9982 .quiet()
9983 .args(["init", "-q", "--bare"])
9984 .arg(&bare)
9985 .output()
9986 .expect("spawn git");
9987 assert!(out.status.success(), "git init --bare");
9988 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
9989 run(&["push", "-q", "origin", "main"]);
9990
9991 let mut state = RunState::new(
9992 repo.clone(),
9993 "main".to_owned(),
9994 "deadbeef".to_owned(),
9995 "task".to_owned(),
9996 Config::default(),
9997 );
9998 state.seeds = vec![refs::Seed {
9999 token: "magi/27b2/A".to_owned(),
10000 kind: refs::SeedKind::Unresolved,
10001 sha: String::new(),
10002 branch: true,
10003 detail: "no branch or commit named magi/27b2/A".to_owned(),
10004 }];
10005
10006 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10007 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10008 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10009 let detail = empty_candidate_detail(&state, "main");
10010 assert!(detail.starts_with("empty candidate"), "{detail}");
10011 assert!(detail.contains("magi/27b2/A"), "{detail}");
10012 }
10013
10014 #[tokio::test]
10023 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10024 let tmp = tempfile::tempdir().expect("tempdir");
10025 let repo = tmp.path().join("repo");
10026 std::fs::create_dir_all(&repo).unwrap();
10027 init_repo(&repo);
10028
10029 let mut config = Config::default();
10030 config.merge.mode = MergeMode::Local;
10031
10032 let mut state = RunState::new(
10033 repo.clone(),
10034 "main".to_owned(),
10035 "deadbeef".to_owned(),
10036 "task".to_owned(),
10037 config,
10038 );
10039 state.candidates = vec![Candidate {
10040 index: 0,
10041 label: 'A',
10042 agent: "alpha".to_owned(),
10043 branch: "does-not-exist".to_owned(),
10044 worktree: repo.clone(),
10045 summary: String::new(),
10046 stat: String::new(),
10047 files: 0,
10048 commits: 0,
10049 empty: false,
10050 failed: None,
10051 verified_noop: None,
10052 duration_ms: 0,
10053 folded: false,
10054 }];
10055 state.tally = Some(Tally {
10056 first_choice: BTreeMap::from([('A', 1)]),
10057 borda: BTreeMap::new(),
10058 winner: 'A',
10059 rankings: 1,
10060 unanimous_initial: true,
10061 deliberated: false,
10062 changed_votes: 0,
10063 unanimous_final: true,
10064 tie_break: None,
10065 judges: 0,
10066 present: 0,
10067 quorum: 0,
10068 met_quorum: true,
10069 uncontested: Some("only candidate A produced a change".to_owned()),
10070 });
10071 state.reviews = vec![ReviewRound {
10072 round: 1,
10073 head: "deadbeef".to_owned(),
10074 verified_head: None,
10075 verified_at: None,
10076 reviews: Vec::new(),
10077 e2e: Vec::new(),
10078 fix: None,
10079 blocking: 0,
10080 answered: 0,
10081 expected: 0,
10082 clean: true,
10083 verify_retried: false,
10084 e2e_deferred: false,
10085 e2e_defer_reason: None,
10086 progressed: false,
10087 vote_split: false,
10088 reconsideration: Vec::new(),
10089 verdict: None,
10090 }];
10091 state.gate = vec![CommandOutcome {
10092 command: "test".to_owned(),
10093 code: Some(0),
10094 output_tail: String::new(),
10095 duration_ms: 0,
10096 resource_blocked: false,
10097 }];
10098 state.gate_ran = true;
10099 state.status = RunStatus::Ready;
10104 state.merge = Some(MergeOutcome {
10105 mode: MergeMode::Local,
10106 ok: false,
10107 detail: "already concluded".to_owned(),
10108 empty: false,
10109 });
10110
10111 let mut runner = Runner {
10112 state,
10113 roles: ResolvedRoles {
10114 implementers: Vec::new(),
10115 judges: Vec::new(),
10116 reviewers: Vec::new(),
10117 fixer: None,
10118 conductor: conductor(),
10119 implementer_roster: Vec::new(),
10120 judge_roster: Vec::new(),
10121 reviewer_roster: Vec::new(),
10122 },
10123 sem: Arc::new(Semaphore::new(1)),
10124 pause: Pause::new(),
10125 interrupt: Pause::new(),
10126 };
10127
10128 runner.merge().await.expect("merge");
10129
10130 assert_eq!(
10131 runner.state.status,
10132 RunStatus::Ready,
10133 "a concluded run's status must not change on reentry"
10134 );
10135 assert_eq!(
10136 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10137 Some("already concluded"),
10138 "merge must not run again once the node already recorded an outcome"
10139 );
10140 }
10141
10142 #[tokio::test]
10151 async fn merge_refuses_a_gate_that_has_not_actually_run() {
10152 let tmp = tempfile::tempdir().expect("tempdir");
10153 let repo = tmp.path().join("repo");
10154 std::fs::create_dir_all(&repo).unwrap();
10155 init_repo(&repo);
10156
10157 let mut config = Config::default();
10158 config.merge.mode = MergeMode::Local;
10159
10160 let mut state = RunState::new(
10161 repo.clone(),
10162 "main".to_owned(),
10163 "deadbeef".to_owned(),
10164 "task".to_owned(),
10165 config,
10166 );
10167 state.candidates = vec![Candidate {
10168 index: 0,
10169 label: 'A',
10170 agent: "alpha".to_owned(),
10171 branch: "does-not-exist".to_owned(),
10172 worktree: repo.clone(),
10173 summary: String::new(),
10174 stat: String::new(),
10175 files: 0,
10176 commits: 0,
10177 empty: false,
10178 failed: None,
10179 verified_noop: None,
10180 duration_ms: 0,
10181 folded: false,
10182 }];
10183 state.tally = Some(Tally {
10184 first_choice: BTreeMap::from([('A', 1)]),
10185 borda: BTreeMap::new(),
10186 winner: 'A',
10187 rankings: 1,
10188 unanimous_initial: true,
10189 deliberated: false,
10190 changed_votes: 0,
10191 unanimous_final: true,
10192 tie_break: None,
10193 judges: 0,
10194 present: 0,
10195 quorum: 0,
10196 met_quorum: true,
10197 uncontested: Some("only candidate A produced a change".to_owned()),
10198 });
10199 state.reviews = vec![ReviewRound {
10200 round: 1,
10201 head: "deadbeef".to_owned(),
10202 verified_head: None,
10203 verified_at: None,
10204 reviews: Vec::new(),
10205 e2e: Vec::new(),
10206 fix: None,
10207 blocking: 0,
10208 answered: 0,
10209 expected: 0,
10210 clean: true,
10211 verify_retried: false,
10212 e2e_deferred: false,
10213 e2e_defer_reason: None,
10214 progressed: false,
10215 vote_split: false,
10216 reconsideration: Vec::new(),
10217 verdict: None,
10218 }];
10219 state.gate = Vec::new();
10221 state.gate_ran = false;
10222 state.status = RunStatus::Gating;
10223
10224 let mut runner = Runner {
10225 state,
10226 roles: ResolvedRoles {
10227 implementers: Vec::new(),
10228 judges: Vec::new(),
10229 reviewers: Vec::new(),
10230 fixer: None,
10231 conductor: conductor(),
10232 implementer_roster: Vec::new(),
10233 judge_roster: Vec::new(),
10234 reviewer_roster: Vec::new(),
10235 },
10236 sem: Arc::new(Semaphore::new(1)),
10237 pause: Pause::new(),
10238 interrupt: Pause::new(),
10239 };
10240
10241 runner.merge().await.expect("merge");
10242
10243 assert!(
10244 runner.state.merge.is_none(),
10245 "an empty gate must never be read as a passing one: {:?}",
10246 runner.state.merge
10247 );
10248 }
10249
10250 #[tokio::test]
10257 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
10258 let tmp = tempfile::tempdir().expect("tempdir");
10259 let repo = tmp.path().join("repo");
10260 std::fs::create_dir_all(&repo).unwrap();
10261 init_repo(&repo);
10262
10263 let config = Config::default();
10265
10266 let mut state = RunState::new(
10267 repo.clone(),
10268 "main".to_owned(),
10269 "deadbeef".to_owned(),
10270 "task".to_owned(),
10271 config,
10272 );
10273 state.candidates = vec![Candidate {
10274 index: 0,
10275 label: 'A',
10276 agent: "alpha".to_owned(),
10277 branch: "does-not-exist".to_owned(),
10278 worktree: repo.clone(),
10279 summary: String::new(),
10280 stat: String::new(),
10281 files: 0,
10282 commits: 0,
10283 empty: false,
10284 failed: None,
10285 verified_noop: None,
10286 duration_ms: 0,
10287 folded: false,
10288 }];
10289 state.tally = Some(Tally {
10290 first_choice: BTreeMap::from([('A', 1)]),
10291 borda: BTreeMap::new(),
10292 winner: 'A',
10293 rankings: 1,
10294 unanimous_initial: true,
10295 deliberated: false,
10296 changed_votes: 0,
10297 unanimous_final: true,
10298 tie_break: None,
10299 judges: 0,
10300 present: 0,
10301 quorum: 0,
10302 met_quorum: true,
10303 uncontested: Some("only candidate A produced a change".to_owned()),
10304 });
10305 state.reviews = vec![ReviewRound {
10306 round: 1,
10307 head: "deadbeef".to_owned(),
10308 verified_head: None,
10309 verified_at: None,
10310 reviews: Vec::new(),
10311 e2e: Vec::new(),
10312 fix: None,
10313 blocking: 0,
10314 answered: 0,
10315 expected: 0,
10316 clean: true,
10317 verify_retried: false,
10318 e2e_deferred: false,
10319 e2e_defer_reason: None,
10320 progressed: false,
10321 vote_split: false,
10322 reconsideration: Vec::new(),
10323 verdict: None,
10324 }];
10325
10326 let mut runner = Runner {
10327 state,
10328 roles: ResolvedRoles {
10329 implementers: Vec::new(),
10330 judges: Vec::new(),
10331 reviewers: Vec::new(),
10332 fixer: None,
10333 conductor: conductor(),
10334 implementer_roster: Vec::new(),
10335 judge_roster: Vec::new(),
10336 reviewer_roster: Vec::new(),
10337 },
10338 sem: Arc::new(Semaphore::new(1)),
10339 pause: Pause::new(),
10340 interrupt: Pause::new(),
10341 };
10342
10343 runner.gate().await.expect("gate");
10344 assert!(
10345 runner.state.gate_ran,
10346 "zero configured commands is still a real attempt, not an unrun gate"
10347 );
10348 assert!(runner.state.gate.is_empty());
10349 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10350 assert_ne!(
10351 runner.state.status,
10352 RunStatus::Blocked,
10353 "a gate with nothing to check must not read as failed"
10354 );
10355
10356 runner.merge().await.expect("merge");
10357 assert_eq!(
10358 runner.state.status,
10359 RunStatus::Ready,
10360 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10361 );
10362 }
10363
10364 #[tokio::test]
10375 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10376 crate::run::pin_test_home();
10377 let home = crate::run::home();
10378
10379 let tmp = tempfile::tempdir().expect("tempdir");
10380 let repo = tmp.path().join("repo");
10381 std::fs::create_dir_all(&repo).unwrap();
10382 init_repo(&repo);
10383 let cache_dir = tmp.path().join("target");
10386
10387 let mut config = Config::default();
10388 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
10389 config.graph.timeout_verify = Some(2);
10392
10393 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10394 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10395 .expect("no io error acquiring directly")
10396 {
10397 crate::cache::AcquireOutcome::Acquired(g) => g,
10398 crate::cache::AcquireOutcome::Busy(b) => {
10399 panic!("expected the direct acquire to win the lease first: {b:?}")
10400 }
10401 };
10402
10403 let mut state = RunState::new(
10404 repo.clone(),
10405 "main".to_owned(),
10406 "deadbeef".to_owned(),
10407 "task".to_owned(),
10408 config,
10409 );
10410 state.candidates = vec![Candidate {
10411 index: 0,
10412 label: 'A',
10413 agent: "alpha".to_owned(),
10414 branch: "does-not-exist".to_owned(),
10415 worktree: repo.clone(),
10416 summary: String::new(),
10417 stat: String::new(),
10418 files: 0,
10419 commits: 0,
10420 empty: false,
10421 failed: None,
10422 verified_noop: None,
10423 duration_ms: 0,
10424 folded: false,
10425 }];
10426 state.tally = Some(Tally {
10427 first_choice: BTreeMap::from([('A', 1)]),
10428 borda: BTreeMap::new(),
10429 winner: 'A',
10430 rankings: 1,
10431 unanimous_initial: true,
10432 deliberated: false,
10433 changed_votes: 0,
10434 unanimous_final: true,
10435 tie_break: None,
10436 judges: 0,
10437 present: 0,
10438 quorum: 0,
10439 met_quorum: true,
10440 uncontested: Some("only candidate A produced a change".to_owned()),
10441 });
10442 state.reviews = vec![ReviewRound {
10443 round: 1,
10444 head: "deadbeef".to_owned(),
10445 verified_head: None,
10446 verified_at: None,
10447 reviews: Vec::new(),
10448 e2e: Vec::new(),
10449 fix: None,
10450 blocking: 0,
10451 answered: 0,
10452 expected: 0,
10453 clean: true,
10454 verify_retried: false,
10455 e2e_deferred: false,
10456 e2e_defer_reason: None,
10457 progressed: false,
10458 vote_split: false,
10459 reconsideration: Vec::new(),
10460 verdict: None,
10461 }];
10462
10463 let mut runner = Runner {
10464 state,
10465 roles: ResolvedRoles {
10466 implementers: Vec::new(),
10467 judges: Vec::new(),
10468 reviewers: Vec::new(),
10469 fixer: None,
10470 conductor: conductor(),
10471 implementer_roster: Vec::new(),
10472 judge_roster: Vec::new(),
10473 reviewer_roster: Vec::new(),
10474 },
10475 sem: Arc::new(Semaphore::new(1)),
10476 pause: Pause::new(),
10477 interrupt: Pause::new(),
10478 };
10479
10480 let started = std::time::Instant::now();
10481 runner.gate().await.expect("gate");
10482 assert!(
10483 started.elapsed() < Duration::from_secs(1),
10484 "a gate with nothing to run must never wait on a lease it never needed"
10485 );
10486 assert!(
10487 runner.state.gate_ran,
10488 "zero commands is still a real, immediate attempt"
10489 );
10490 assert!(runner.state.gate.is_empty());
10491 assert_ne!(
10492 runner.state.status,
10493 RunStatus::Blocked,
10494 "must not read as resource-blocked on a lease it never asked for"
10495 );
10496 }
10497
10498 #[tokio::test]
10508 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10509 crate::run::pin_test_home();
10510
10511 let tmp = tempfile::tempdir().expect("tempdir");
10512 let repo = tmp.path().join("repo");
10513 std::fs::create_dir_all(&repo).unwrap();
10514 init_repo(&repo);
10515
10516 let mut config = Config::default();
10517 config.verify.gate = vec![
10518 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10519 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10520 .to_owned(),
10521 ];
10522
10523 let mut state = RunState::new(
10524 repo.clone(),
10525 "main".to_owned(),
10526 "deadbeef".to_owned(),
10527 "task".to_owned(),
10528 config,
10529 );
10530 let run_id = state.id.clone();
10531 state.candidates = vec![Candidate {
10532 index: 0,
10533 label: 'A',
10534 agent: "alpha".to_owned(),
10535 branch: "does-not-exist".to_owned(),
10536 worktree: repo.clone(),
10537 summary: String::new(),
10538 stat: String::new(),
10539 files: 0,
10540 commits: 0,
10541 empty: false,
10542 failed: None,
10543 verified_noop: None,
10544 duration_ms: 0,
10545 folded: false,
10546 }];
10547 state.tally = Some(Tally {
10548 first_choice: BTreeMap::from([('A', 1)]),
10549 borda: BTreeMap::new(),
10550 winner: 'A',
10551 rankings: 1,
10552 unanimous_initial: true,
10553 deliberated: false,
10554 changed_votes: 0,
10555 unanimous_final: true,
10556 tie_break: None,
10557 judges: 0,
10558 present: 0,
10559 quorum: 0,
10560 met_quorum: true,
10561 uncontested: Some("only candidate A produced a change".to_owned()),
10562 });
10563 state.reviews = vec![ReviewRound {
10564 round: 1,
10565 head: "deadbeef".to_owned(),
10566 verified_head: None,
10567 verified_at: None,
10568 reviews: Vec::new(),
10569 e2e: Vec::new(),
10570 fix: None,
10571 blocking: 0,
10572 answered: 0,
10573 expected: 0,
10574 clean: true,
10575 verify_retried: false,
10576 e2e_deferred: false,
10577 e2e_defer_reason: None,
10578 progressed: false,
10579 vote_split: false,
10580 reconsideration: Vec::new(),
10581 verdict: None,
10582 }];
10583
10584 let mut runner = Runner {
10585 state,
10586 roles: ResolvedRoles {
10587 implementers: Vec::new(),
10588 judges: Vec::new(),
10589 reviewers: Vec::new(),
10590 fixer: None,
10591 conductor: conductor(),
10592 implementer_roster: Vec::new(),
10593 judge_roster: Vec::new(),
10594 reviewer_roster: Vec::new(),
10595 },
10596 sem: Arc::new(Semaphore::new(1)),
10597 pause: Pause::new(),
10598 interrupt: Pause::new(),
10599 };
10600
10601 let started_marker = repo.join("started.marker");
10602 let release_marker = repo.join("release.marker");
10603 let poller = tokio::spawn(async move {
10604 for _ in 0..100 {
10609 if started_marker.exists()
10610 && let Ok(s) = crate::run::RunState::load(&run_id)
10611 && let Some(a) = s.active.get("gate")
10612 {
10613 std::fs::write(&release_marker, b"go").expect("release marker");
10614 return Some(a.clone());
10615 }
10616 tokio::time::sleep(Duration::from_millis(50)).await;
10617 }
10618 None
10619 });
10620
10621 runner.gate().await.expect("gate");
10622 let captured = poller.await.expect("poller task");
10623 let captured = captured.expect(
10624 "the poller never saw a `gate` task entry in run.json while the command was \
10625 still blocked on its own release marker",
10626 );
10627
10628 assert_eq!(captured.task.as_deref(), Some("gate"));
10629 assert_eq!(captured.node, "gate");
10630 assert_eq!(captured.index, Some(1));
10631 assert_eq!(captured.total, Some(1));
10632 assert!(
10633 captured
10634 .command
10635 .as_deref()
10636 .is_some_and(|c| c.contains("started.marker")),
10637 "{captured:?}"
10638 );
10639
10640 assert!(
10641 runner.state.active.is_empty(),
10642 "the entry must be cleared once the command actually finished: {:?}",
10643 runner.state.active
10644 );
10645 assert!(runner.state.gate_ran);
10646 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10647 }
10648
10649 #[tokio::test]
10652 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10653 use crate::verdict::{Finding, ReviewVote, Severity};
10654 crate::run::pin_test_home();
10655 let tmp = tempfile::tempdir().expect("tempdir");
10656 let repo = tmp.path().join("repo");
10657 std::fs::create_dir_all(&repo).unwrap();
10658 init_repo(&repo);
10659
10660 for (severity, vote, expect) in [
10661 (Severity::Major, ReviewVote::Reject, true),
10662 (Severity::Minor, ReviewVote::Reject, false),
10663 (Severity::Major, ReviewVote::Approve, false),
10664 ] {
10665 let mut round = review_round(false, 1, 1, 1, false, true);
10666 round.reviews = vec![ReviewRecord {
10667 reviewer: 1,
10668 agent: "alpha".to_owned(),
10669 summary: String::new(),
10670 findings: vec![Finding {
10671 id: "R1-1-1".to_owned(),
10672 severity,
10673 file: None,
10674 line: None,
10675 title: "t".to_owned(),
10676 detail: String::new(),
10677 }],
10678 vote: Some(vote),
10679 failed: None,
10680 duration_ms: 0,
10681 attempts: 0,
10682 }];
10683 let mut state = RunState::new(
10684 repo.clone(),
10685 "main".to_owned(),
10686 "deadbeef".to_owned(),
10687 "task".to_owned(),
10688 Config::default(),
10689 );
10690 state.reviews = vec![round];
10691 let mut runner = Runner {
10692 state,
10693 roles: ResolvedRoles {
10694 implementers: Vec::new(),
10695 judges: Vec::new(),
10696 reviewers: Vec::new(),
10697 fixer: None,
10698 conductor: conductor(),
10699 implementer_roster: Vec::new(),
10700 judge_roster: Vec::new(),
10701 reviewer_roster: Vec::new(),
10702 },
10703 sem: Arc::new(Semaphore::new(1)),
10704 pause: Pause::new(),
10705 interrupt: Pause::new(),
10706 };
10707 let shell = runner.state.config.shell();
10708 runner
10709 .stop_reviewing("round budget spent", &shell, &repo)
10710 .await
10711 .expect("stop_reviewing");
10712 assert_eq!(runner.state.status, RunStatus::Gating);
10713 assert_eq!(
10714 runner.state.contested_handoff.is_some(),
10715 expect,
10716 "{severity:?} + {vote:?}"
10717 );
10718 }
10719 }
10720
10721 #[tokio::test]
10734 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10735 crate::run::pin_test_home();
10736 let home = crate::run::home();
10737
10738 let tmp = tempfile::tempdir().expect("tempdir");
10739 let repo = tmp.path().join("repo");
10740 std::fs::create_dir_all(&repo).unwrap();
10741 init_repo(&repo);
10742 let head = crate::git::rev_parse(&repo, "HEAD")
10743 .await
10744 .expect("rev-parse");
10745 let cache_dir = tmp.path().join("target");
10748
10749 let mut config = Config::default();
10750 config.verify.e2e = vec![format!(
10751 "CARGO_TARGET_DIR='{}' test -f README.md",
10752 cache_dir.display()
10753 )];
10754 config.graph.review_rounds = 1;
10755 config.graph.timeout_verify = Some(2);
10758
10759 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10760 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10761 .expect("no io error acquiring directly")
10762 {
10763 crate::cache::AcquireOutcome::Acquired(g) => g,
10764 crate::cache::AcquireOutcome::Busy(b) => {
10765 panic!("expected the direct acquire to win the lease first: {b:?}")
10766 }
10767 };
10768
10769 let mut state = RunState::new(
10770 repo.clone(),
10771 "main".to_owned(),
10772 head.clone(),
10773 "task".to_owned(),
10774 config,
10775 );
10776 state.candidates = vec![Candidate {
10777 index: 0,
10778 label: 'A',
10779 agent: "alpha".to_owned(),
10780 branch: "does-not-exist".to_owned(),
10781 worktree: repo.clone(),
10782 summary: String::new(),
10783 stat: String::new(),
10784 files: 0,
10785 commits: 0,
10786 empty: false,
10787 failed: None,
10788 verified_noop: None,
10789 duration_ms: 0,
10790 folded: false,
10791 }];
10792 state.tally = Some(Tally {
10793 first_choice: BTreeMap::from([('A', 1)]),
10794 borda: BTreeMap::new(),
10795 winner: 'A',
10796 rankings: 1,
10797 unanimous_initial: true,
10798 deliberated: false,
10799 changed_votes: 0,
10800 unanimous_final: true,
10801 tie_break: None,
10802 judges: 0,
10803 present: 0,
10804 quorum: 0,
10805 met_quorum: true,
10806 uncontested: Some("only candidate A produced a change".to_owned()),
10807 });
10808 state.reviews = vec![ReviewRound {
10812 round: 1,
10813 head: head.clone(),
10814 verified_head: None,
10815 verified_at: None,
10816 reviews: Vec::new(),
10817 e2e: Vec::new(),
10818 fix: None,
10819 blocking: 1,
10820 answered: 1,
10821 expected: 1,
10822 clean: false,
10823 verify_retried: false,
10824 e2e_deferred: true,
10825 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
10826 progressed: false,
10827 vote_split: false,
10828 reconsideration: Vec::new(),
10829 verdict: None,
10830 }];
10831
10832 let mut runner = Runner {
10833 state,
10834 roles: ResolvedRoles {
10835 implementers: Vec::new(),
10836 judges: Vec::new(),
10837 reviewers: Vec::new(),
10838 fixer: None,
10839 conductor: conductor(),
10840 implementer_roster: Vec::new(),
10841 judge_roster: Vec::new(),
10842 reviewer_roster: Vec::new(),
10843 },
10844 sem: Arc::new(Semaphore::new(1)),
10845 pause: Pause::new(),
10846 interrupt: Pause::new(),
10847 };
10848
10849 let shell = runner.state.config.shell();
10850 runner
10851 .stop_reviewing("round budget spent", &shell, &repo)
10852 .await
10853 .expect("stop_reviewing");
10854
10855 let last = runner.state.reviews.last().expect("round record");
10856 assert_eq!(
10857 last.e2e_status(),
10858 E2eStatus::ResourceBlocked,
10859 "the shared cache is still held; the attempt must read as blocked, not deferred or \
10860 failed: {last:?}"
10861 );
10862 assert_eq!(
10863 last.verified_head.as_deref(),
10864 Some(head.as_str()),
10865 "which commit this attempt targeted is known even though nothing finished checking \
10866 it"
10867 );
10868 let first_attempt_at = last
10869 .verified_at
10870 .expect("when this attempt ran is known too");
10871 assert_ne!(
10872 runner.state.status,
10873 RunStatus::Blocked,
10874 "contention is evidence about the machine, not the patch — it must not settle the \
10875 run as blocked: {:?}",
10876 runner.state.status
10877 );
10878 assert!(
10879 !runner
10880 .state
10881 .events
10882 .iter()
10883 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
10884 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
10885 runner.state.events
10886 );
10887
10888 runner
10893 .stop_reviewing("round budget spent", &shell, &repo)
10894 .await
10895 .expect("stop_reviewing retry");
10896 assert_eq!(
10897 runner.state.reviews.len(),
10898 1,
10899 "no new round was started: {:?}",
10900 runner.state.reviews
10901 );
10902 let last = runner.state.reviews.last().expect("round record");
10903 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
10904 assert!(
10905 last.verified_at.expect("still known") > first_attempt_at,
10906 "a second reentry must be a fresh attempt, not a stale copy of the first"
10907 );
10908 assert_ne!(runner.state.status, RunStatus::Blocked);
10909
10910 held.release();
10911 }
10912
10913 #[tokio::test]
10925 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
10926 crate::run::pin_test_home();
10927 let home = crate::run::home();
10928
10929 let tmp = tempfile::tempdir().expect("tempdir");
10930 let repo = tmp.path().join("repo");
10931 std::fs::create_dir_all(&repo).unwrap();
10932 init_repo(&repo);
10933 let head = crate::git::rev_parse(&repo, "HEAD")
10934 .await
10935 .expect("rev-parse");
10936 let cache_dir = tmp.path().join("target");
10937
10938 let mut config = Config::default();
10939 config.verify.e2e = vec![format!(
10940 "CARGO_TARGET_DIR='{}' test -f README.md",
10941 cache_dir.display()
10942 )];
10943 config.graph.review_rounds = 1;
10944 config.graph.timeout_verify = Some(2);
10945
10946 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10947 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10948 .expect("no io error acquiring directly")
10949 {
10950 crate::cache::AcquireOutcome::Acquired(g) => g,
10951 crate::cache::AcquireOutcome::Busy(b) => {
10952 panic!("expected the direct acquire to win the lease first: {b:?}")
10953 }
10954 };
10955
10956 let mut state = RunState::new(
10957 repo.clone(),
10958 "main".to_owned(),
10959 head.clone(),
10960 "task".to_owned(),
10961 config,
10962 );
10963 state.candidates = vec![Candidate {
10964 index: 0,
10965 label: 'A',
10966 agent: "alpha".to_owned(),
10967 branch: "does-not-exist".to_owned(),
10968 worktree: repo.clone(),
10969 summary: String::new(),
10970 stat: String::new(),
10971 files: 0,
10972 commits: 0,
10973 empty: false,
10974 failed: None,
10975 verified_noop: None,
10976 duration_ms: 0,
10977 folded: false,
10978 }];
10979 state.tally = Some(Tally {
10980 first_choice: BTreeMap::from([('A', 1)]),
10981 borda: BTreeMap::new(),
10982 winner: 'A',
10983 rankings: 1,
10984 unanimous_initial: true,
10985 deliberated: false,
10986 changed_votes: 0,
10987 unanimous_final: true,
10988 tie_break: None,
10989 judges: 0,
10990 present: 0,
10991 quorum: 0,
10992 met_quorum: true,
10993 uncontested: Some("only candidate A produced a change".to_owned()),
10994 });
10995 state.reviews = vec![ReviewRound {
10999 round: 1,
11000 head: head.clone(),
11001 verified_head: Some(head.clone()),
11002 verified_at: Some(jiff::Timestamp::now()),
11003 reviews: Vec::new(),
11004 e2e: vec![CommandOutcome {
11005 command: format!(
11006 "CARGO_TARGET_DIR='{}' test -f README.md",
11007 cache_dir.display()
11008 ),
11009 code: None,
11010 output_tail: "waiting for the shared build cache".to_owned(),
11011 duration_ms: 0,
11012 resource_blocked: true,
11013 }],
11014 fix: None,
11015 blocking: 1,
11016 answered: 1,
11017 expected: 1,
11018 clean: false,
11019 verify_retried: false,
11020 e2e_deferred: false,
11021 e2e_defer_reason: None,
11022 progressed: false,
11023 vote_split: false,
11024 reconsideration: Vec::new(),
11025 verdict: None,
11026 }];
11027
11028 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11029 let mut runner = Runner {
11030 state,
11031 roles: ResolvedRoles {
11032 implementers: Vec::new(),
11033 judges: Vec::new(),
11034 reviewers: Vec::new(),
11035 fixer: None,
11036 conductor: conductor(),
11037 implementer_roster: Vec::new(),
11038 judge_roster: Vec::new(),
11039 reviewer_roster: Vec::new(),
11040 },
11041 sem: Arc::new(Semaphore::new(1)),
11042 pause: Pause::new(),
11043 interrupt: Pause::new(),
11044 };
11045
11046 runner.review_loop().await.expect("review_loop");
11051
11052 assert_eq!(
11053 runner.state.reviews.len(),
11054 1,
11055 "no new round was started on top of the unresolved one: {:?}",
11056 runner.state.reviews
11057 );
11058 let last = &runner.state.reviews[0];
11059 assert_eq!(
11060 last.e2e_status(),
11061 E2eStatus::ResourceBlocked,
11062 "still contended: {last:?}"
11063 );
11064 assert!(
11065 last.verified_at.expect("still known") > first_attempt_at,
11066 "review_loop must have actually retried the check, not left it exactly as found"
11067 );
11068 assert_ne!(
11069 runner.state.status,
11070 RunStatus::Blocked,
11071 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11072 runner.state.status
11073 );
11074
11075 held.release();
11076 }
11077
11078 #[tokio::test]
11079 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11080 crate::run::pin_test_home();
11081 let tmp = tempfile::tempdir().expect("tempdir");
11082 let repo = tmp.path().join("repo");
11083 std::fs::create_dir_all(&repo).unwrap();
11084 init_repo(&repo);
11085
11086 let mut config = Config::default();
11087 config.merge.mode = MergeMode::Pr;
11088 config.graph.land = true;
11089 config.graph.land_approval = false;
11090
11091 let mut state = RunState::new(
11092 repo.clone(),
11093 "main".to_owned(),
11094 "deadbeef".to_owned(),
11095 "task".to_owned(),
11096 config,
11097 );
11098 state.candidates = vec![Candidate {
11099 index: 0,
11100 label: 'A',
11101 agent: "alpha".to_owned(),
11102 branch: "does-not-exist".to_owned(),
11103 worktree: repo.clone(),
11104 summary: String::new(),
11105 stat: String::new(),
11106 files: 0,
11107 commits: 0,
11108 empty: false,
11109 failed: None,
11110 verified_noop: None,
11111 duration_ms: 0,
11112 folded: false,
11113 }];
11114 state.tally = Some(Tally {
11115 first_choice: BTreeMap::from([('A', 1)]),
11116 borda: BTreeMap::new(),
11117 winner: 'A',
11118 rankings: 1,
11119 unanimous_initial: true,
11120 deliberated: false,
11121 changed_votes: 0,
11122 unanimous_final: true,
11123 tie_break: None,
11124 judges: 0,
11125 present: 0,
11126 quorum: 0,
11127 met_quorum: true,
11128 uncontested: Some("only candidate A produced a change".to_owned()),
11129 });
11130 state.reviews = vec![ReviewRound {
11131 round: 1,
11132 head: "deadbeef".to_owned(),
11133 verified_head: None,
11134 verified_at: None,
11135 reviews: Vec::new(),
11136 e2e: Vec::new(),
11137 fix: None,
11138 blocking: 0,
11139 answered: 0,
11140 expected: 0,
11141 clean: true,
11142 verify_retried: false,
11143 e2e_deferred: false,
11144 e2e_defer_reason: None,
11145 progressed: false,
11146 vote_split: false,
11147 reconsideration: Vec::new(),
11148 verdict: None,
11149 }];
11150 state.gate = vec![CommandOutcome {
11151 command: "test".to_owned(),
11152 code: Some(0),
11153 output_tail: String::new(),
11154 duration_ms: 0,
11155 resource_blocked: false,
11156 }];
11157 state.gate_ran = true;
11158 state.status = RunStatus::Landing;
11162 state.merge = Some(MergeOutcome {
11163 mode: MergeMode::Pr,
11164 ok: true,
11165 detail: "https://example.invalid/x/y/pull/1".to_owned(),
11166 empty: false,
11167 });
11168
11169 ask_test_home();
11173 let store = ask::Questions::open();
11174 let q = ask_open_question(&store, &state.id);
11175
11176 let mut runner = Runner {
11177 state,
11178 roles: ResolvedRoles {
11179 implementers: Vec::new(),
11180 judges: Vec::new(),
11181 reviewers: Vec::new(),
11182 fixer: None,
11183 conductor: conductor(),
11184 implementer_roster: Vec::new(),
11185 judge_roster: Vec::new(),
11186 reviewer_roster: Vec::new(),
11187 },
11188 sem: Arc::new(Semaphore::new(1)),
11189 pause: Pause::new(),
11190 interrupt: Pause::new(),
11191 };
11192
11193 runner.execute().await.expect("execute");
11198
11199 assert_eq!(
11200 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11201 Some("https://example.invalid/x/y/pull/1"),
11202 "reentry must not push again or open a second pull request over the \
11203 one `land` is already watching"
11204 );
11205 assert_ne!(
11206 runner.state.status,
11207 RunStatus::Landing,
11208 "land could not actually reach the fake pull request, so it must \
11209 have given up rather than left the run silently parked forever"
11210 );
11211 assert_eq!(runner.state.status, RunStatus::Blocked);
11215 assert!(
11216 store.get(&q.id).unwrap().status.open(),
11217 "Blocked is still alive; settle_questions must have been a no-op here"
11218 );
11219 }
11220
11221 fn state_with_round(round: ReviewRound) -> RunState {
11222 let mut s = RunState::new(
11223 PathBuf::from("/repo"),
11224 "main".to_owned(),
11225 "abc1234".to_owned(),
11226 "add retries".to_owned(),
11227 Config::default(),
11228 );
11229 s.reviews = vec![round];
11230 s
11231 }
11232
11233 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11234 crate::verdict::Finding {
11235 id: id.to_owned(),
11236 severity,
11237 file: None,
11238 line: None,
11239 title: title.to_owned(),
11240 detail: String::new(),
11241 }
11242 }
11243
11244 #[test]
11245 fn pr_body_names_open_findings_and_declined_ones() {
11246 let round = ReviewRound {
11247 round: 2,
11248 head: "deadbee".to_owned(),
11249 verified_head: None,
11250 verified_at: None,
11251 reviews: vec![ReviewRecord {
11252 attempts: 0,
11253 reviewer: 1,
11254 agent: "alpha".to_owned(),
11255 summary: String::new(),
11256 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
11257 vote: None,
11258 failed: None,
11259 duration_ms: 0,
11260 }],
11261 e2e: vec![CommandOutcome {
11262 command: "cargo test".to_owned(),
11263 code: Some(0),
11264 output_tail: String::new(),
11265 duration_ms: 0,
11266 resource_blocked: false,
11267 }],
11268 verify_retried: false,
11269 e2e_deferred: false,
11270 e2e_defer_reason: None,
11271 fix: Some(FixRecord {
11272 agent: "alpha".to_owned(),
11273 addressed: Vec::new(),
11274 rejected: vec![crate::verdict::Rejection {
11275 id: "R1-1-1".to_owned(),
11276 why: "not reachable from any caller".to_owned(),
11277 }],
11278 notes: String::new(),
11279 committed: true,
11280 failed: None,
11281 duration_ms: 0,
11282 continuation: None,
11283 }),
11284 blocking: 0,
11285 answered: 1,
11286 expected: 1,
11287 clean: false,
11288 progressed: true,
11289 vote_split: false,
11290 reconsideration: Vec::new(),
11291 verdict: None,
11292 };
11293 let state = state_with_round(round);
11294 let body = pr_message(&state, 'A').body;
11295
11296 assert!(body.contains("add retries"), "the task must still be there");
11297 assert!(body.contains("R2-1-1"), "{body}");
11298 assert!(body.contains("unused import"), "{body}");
11299 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11300 assert!(
11301 body.contains("not reachable from any caller"),
11302 "the reason it was declined: {body}"
11303 );
11304 }
11305
11306 #[test]
11307 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11308 let round = ReviewRound {
11309 round: 1,
11310 head: "deadbee".to_owned(),
11311 verified_head: None,
11312 verified_at: None,
11313 reviews: vec![ReviewRecord {
11314 attempts: 0,
11315 reviewer: 1,
11316 agent: "alpha".to_owned(),
11317 summary: String::new(),
11318 findings: Vec::new(),
11319 vote: None,
11320 failed: None,
11321 duration_ms: 0,
11322 }],
11323 e2e: Vec::new(),
11324 verify_retried: false,
11325 e2e_deferred: false,
11326 e2e_defer_reason: None,
11327 fix: None,
11328 blocking: 0,
11329 answered: 1,
11330 expected: 1,
11331 clean: true,
11332 progressed: false,
11333 vote_split: false,
11334 reconsideration: Vec::new(),
11335 verdict: None,
11336 };
11337 let state = state_with_round(round);
11338 let body = pr_message(&state, 'A').body;
11339 assert!(!body.contains("Open review findings"), "{body}");
11340 assert!(!body.contains("Declined"), "{body}");
11341 }
11342
11343 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11344 let mut state = RunState::new(
11345 PathBuf::from("/repo"),
11346 "main".to_owned(),
11347 "abc1234".to_owned(),
11348 instruction.to_owned(),
11349 Config::default(),
11350 );
11351 state.candidates.push(Candidate {
11352 index: 0,
11353 label: 'A',
11354 agent: "alpha".to_owned(),
11355 branch: "magi/x/A".to_owned(),
11356 worktree: PathBuf::from("/wt"),
11357 summary: summary.to_owned(),
11358 stat: String::new(),
11359 files: 1,
11360 commits: 1,
11361 empty: false,
11362 failed: None,
11363 verified_noop: None,
11364 folded: false,
11365 duration_ms: 0,
11366 });
11367 state
11368 }
11369
11370 fn review_state(subjects: &[&str]) -> RunState {
11371 let mut state = state_with_summary(
11372 "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",
11373 "",
11374 );
11375 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11376 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11377 state
11378 }
11379
11380 #[tokio::test]
11383 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
11384 ask_test_home();
11385 let tmp = tempfile::tempdir().unwrap();
11386 let repo = tmp.path().join("repo");
11387 std::fs::create_dir_all(&repo).unwrap();
11388 init_repo(&repo);
11389 let origin = tmp.path().join("origin.git");
11390 let g = |dir: &Path, args: &[&str]| {
11391 let out = std::process::Command::new("git")
11392 .args(args)
11393 .current_dir(dir)
11394 .quiet()
11395 .output()
11396 .expect("spawn git");
11397 assert!(
11398 out.status.success(),
11399 "git {args:?}: {}",
11400 String::from_utf8_lossy(&out.stderr)
11401 );
11402 };
11403 g(
11404 tmp.path(),
11405 &[
11406 "clone",
11407 "--bare",
11408 "-q",
11409 repo.to_str().unwrap(),
11410 origin.to_str().unwrap(),
11411 ],
11412 );
11413 g(
11414 &repo,
11415 &["remote", "add", "origin", origin.to_str().unwrap()],
11416 );
11417 let c1 = git::rev_parse(&repo, "main").await.unwrap();
11418
11419 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11421 g(&repo, &["add", "-A"]);
11422 g(
11423 &repo,
11424 &["commit", "-q", "-m", "chore(deps): update a crate"],
11425 );
11426 g(&repo, &["push", "-q", "origin", "main"]);
11427 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11428 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11429 g(&repo, &["add", "-A"]);
11430 g(
11431 &repo,
11432 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11433 );
11434 g(&repo, &["checkout", "-q", "main"]);
11435
11436 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11437 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11438 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11440 assert_eq!(fallback, c1);
11441
11442 let mut state = review_state(&[
11443 "chore(deps): update a crate",
11444 "fix(daemon): apply a chosen action",
11445 ]);
11446 state.repo = repo.clone();
11447 state.base_branch = "main".to_owned();
11448 state.base_commit = c1;
11449 refresh_reviewed_commits(&mut state, "feat/own").await;
11450 assert_eq!(
11451 state.reviewed_commits,
11452 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11453 );
11454 assert_eq!(
11455 review_title(&state).as_deref(),
11456 Some("fix(daemon): apply a chosen action")
11457 );
11458 assert_eq!(
11459 leaked_subjects(&state, "feat/own").await,
11460 Some(vec!["chore(deps): update a crate".to_owned()])
11461 );
11462 state.config.merge.remote = "nowhere".to_owned();
11465 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11466 }
11467
11468 #[test]
11469 fn pr_message_review_single_commit_uses_its_subject() {
11470 let state = review_state(&["feat(nats): per-role user"]);
11471 let m = pr_message(&state, 'A');
11472 assert_eq!(m.title, "feat(nats): per-role user");
11473 assert!(!m.body.contains("Review the work already"), "{}", m.body);
11474 assert!(m.body.contains("## Commits under review"), "{}", m.body);
11475 }
11476
11477 #[test]
11478 fn pr_message_review_multi_commit_takes_the_oldest() {
11479 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11480 let m = pr_message(&state, 'A');
11481 assert_eq!(m.title, "feat: the change");
11482 for s in ["feat: the change", "fix: typo", "fix: again"] {
11483 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11484 }
11485 }
11486
11487 #[test]
11488 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11489 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11490 let mut state = review_state(&[first, "fix: later fixup"]);
11491 state.candidates[0].branch = "機能/ログイン".to_owned();
11492 let m = pr_message(&state, 'A');
11493 assert!(
11494 m.title.starts_with("chore: land candidate A of run"),
11495 "{}",
11496 m.title
11497 );
11498 }
11499 }
11500
11501 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11502 BranchFacts {
11503 commits: commits
11504 .iter()
11505 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11506 .collect(),
11507 stat: stat.to_owned(),
11508 }
11509 }
11510
11511 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11512 let mut r = review_round(true, 0, 1, 1, true, true);
11513 r.round = round;
11514 r.fix = notes.map(|n| FixRecord {
11515 agent: "fixer".to_owned(),
11516 addressed: Vec::new(),
11517 rejected: Vec::new(),
11518 notes: n.to_owned(),
11519 committed: true,
11520 failed: None,
11521 duration_ms: 0,
11522 continuation: None,
11523 });
11524 r
11525 }
11526
11527 #[test]
11528 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11529 let state = review_state(&["ignored"]);
11530 let f = facts(
11531 &[
11532 (
11533 "fix(login): resolve PATH on macOS",
11534 "Login shells skip rc files.",
11535 ),
11536 ("fix: address review", ""),
11537 ],
11538 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11539 );
11540 let m = pr_message_with(&state, 'A', Some(&f));
11541 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11542 assert!(
11543 m.body
11544 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
11545 "{}",
11546 m.body
11547 );
11548 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11549 assert!(m.body.contains("## Diff stat"), "{}", m.body);
11550 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11551 for banned in [
11552 "Review the work already",
11553 "no task statement",
11554 "Original task",
11555 ] {
11556 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11557 }
11558 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11559 }
11560
11561 #[test]
11562 fn pr_message_review_truncates_a_huge_first_commit_body() {
11563 let state = review_state(&["ignored"]);
11564 let f = facts(
11565 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11566 "s",
11567 );
11568 let m = pr_message_with(&state, 'A', Some(&f));
11569 assert!(m.body.len() < 30_000, "{}", m.body.len());
11570 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11571 assert!(
11572 m.body.contains("- ... 1 more commit(s)"),
11573 "{}",
11574 m.body.len()
11575 );
11576 assert!(m.body.ends_with("magi:candidate-a\n"));
11577 }
11578
11579 #[test]
11580 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11581 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11582 assert_eq!(m.title, "feat: x");
11583 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11584 assert!(!m.body.contains("Diff stat"), "{}", m.body);
11585 assert!(!m.body.contains("no task statement"), "{}", m.body);
11586 }
11587
11588 #[test]
11589 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11590 let mut state = review_state(&["日本語の件名"]);
11591 state.candidates[0].branch = "fix/macos-login-path".to_owned();
11592 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11593 state.candidates[0].branch = "機能/ログイン".to_owned();
11594 assert!(
11595 pr_message(&state, 'A')
11596 .title
11597 .starts_with("chore: land candidate A")
11598 );
11599 }
11600
11601 #[test]
11602 fn pr_message_review_fixes_survive_a_clean_final_round() {
11603 let mut state = review_state(&["feat: x"]);
11604 state.reviews = vec![
11605 round_with_notes(1, Some("handled the PATH case")),
11606 round_with_notes(2, None),
11607 ];
11608 let body = pr_message(&state, 'A').body;
11609 assert!(
11610 body.contains("## Review fixes\n\nhandled the PATH case\n"),
11611 "{body}"
11612 );
11613 assert!(!body.contains("### Round"), "{body}");
11614
11615 state.reviews = vec![
11616 round_with_notes(1, Some("first fix")),
11617 round_with_notes(2, Some("")),
11618 round_with_notes(3, Some("second fix")),
11619 round_with_notes(4, None),
11620 ];
11621 let body = pr_message(&state, 'A').body;
11622 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11623 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11624 assert!(!body.contains("### Round 2"), "{body}");
11625 }
11626
11627 #[test]
11628 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11629 let state = state_with_summary(
11630 "add retries to the client",
11631 "TITLE: feat: retries\n\nDid it.",
11632 );
11633 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11634 assert_eq!(m.title, "feat: retries");
11635 assert!(
11636 m.body.contains("<summary>Original task</summary>"),
11637 "{}",
11638 m.body
11639 );
11640 assert!(!m.body.contains("Commits under review"), "{}", m.body);
11641 assert!(
11642 m.body
11643 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11644 "{}",
11645 m.body
11646 );
11647 }
11648
11649 #[test]
11650 fn pr_message_review_bounds_a_long_english_subject() {
11651 let long = format!("feat: {}", "word ".repeat(100));
11652 let m = pr_message(&review_state(&[&long]), 'A');
11653 assert!(m.title.starts_with("feat: word"), "{}", m.title);
11654 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11655 }
11656
11657 #[test]
11658 fn pr_message_implementation_run_is_unchanged_by_review_support() {
11659 let state = state_with_summary("add retries\n\ndetails", "- did some things");
11660 let m = pr_message(&state, 'A');
11661 assert_eq!(m.title, "add retries");
11662 assert!(m.body.contains("<summary>Original task</summary>"));
11663 assert!(!m.body.contains("Commits under review"));
11664 assert_eq!(landing_subject_source(&state), state.instruction);
11665 }
11666
11667 #[test]
11668 fn review_run_squash_subject_is_the_change_not_the_prompt() {
11669 let state = review_state(&["feat: the change", "fix: typo"]);
11670 let source = landing_subject_source(&state);
11671 assert_eq!(land::merge_subject("", &source), "feat: the change");
11672 assert_eq!(
11673 land::merge_subject("magi: candidate A (uncommitted work)", &source),
11674 "feat: the change"
11675 );
11676 assert_eq!(
11678 land::merge_subject("feat: renamed by hand", &source),
11679 "feat: renamed by hand"
11680 );
11681 let blank = review_state(&["日本語"]);
11682 assert!(
11683 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11684 );
11685 }
11686
11687 #[test]
11688 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11689 let state = review_state(&["feat: the change"]);
11690 let source = landing_subject_source(&state);
11691 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11692 assert_eq!(
11693 land::merge_subject(landing_title(&state, old), &source),
11694 "feat: the change"
11695 );
11696 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11697 let task = state_with_summary("add retries", "");
11698 assert_eq!(landing_title(&task, old), old);
11699 }
11700
11701 #[test]
11702 fn pr_message_describes_the_change_not_the_task() {
11703 let state = state_with_summary(
11704 "今回やってほしいこと: results projector を直す",
11705 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11706 );
11707 let m = pr_message(&state, 'A');
11708 assert_eq!(m.title, "fix(web): batch the runs list reads");
11709 assert!(
11710 m.body.starts_with("## Summary\n\n- reads run.json once"),
11711 "{}",
11712 m.body
11713 );
11714 assert!(!m.body.contains("TITLE:"), "{}", m.body);
11715 let task_at = m.body.find("今回やってほしいこと").unwrap();
11716 let details_at = m.body.find("<details>").unwrap();
11717 assert!(
11718 details_at < task_at,
11719 "the task lives inside <details>: {}",
11720 m.body
11721 );
11722 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11723 assert!(m.body.contains("magi:candidate-a"));
11724 }
11725
11726 #[test]
11727 fn pr_message_falls_back_to_the_task_without_a_title_line() {
11728 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11729 let m = pr_message(&state, 'A');
11730 assert_eq!(m.title, "add retries");
11731 assert!(
11732 m.body.contains("## Summary\n\n- did some things"),
11733 "{}",
11734 m.body
11735 );
11736
11737 let none = RunState::new(
11738 PathBuf::from("/repo"),
11739 "main".to_owned(),
11740 "abc1234".to_owned(),
11741 "add retries".to_owned(),
11742 Config::default(),
11743 );
11744 let m = pr_message(&none, 'A');
11745 assert_eq!(m.title, "add retries");
11746 assert!(!m.body.contains("## Summary"), "{}", m.body);
11747 }
11748
11749 #[test]
11750 fn pr_message_refuses_the_candidate_commit_subject() {
11751 for bad in [
11752 "TITLE: magi: candidate A (uncommitted work)",
11753 "TITLE: chore: stuff (uncommitted work)",
11754 "TITLE: ",
11755 ] {
11756 let state = state_with_summary("add retries", bad);
11757 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
11758 }
11759 }
11760
11761 #[test]
11762 fn pr_message_bounds_a_very_long_task_and_title() {
11763 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
11764 let state = state_with_summary(&long, "- nothing");
11765 let m = pr_message(&state, 'A');
11766 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11767 assert!(!m.title.contains('\n'));
11768
11769 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
11770 let m = pr_message(&state, 'A');
11771 assert!(m.title.starts_with("feat: "));
11772 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11773 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
11774 }
11775
11776 fn long_title_of(instruction: &str) -> String {
11777 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
11778 }
11779
11780 #[test]
11781 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
11782 let first = "Make the landing path keep a readable title for long tasks";
11783 let line = format!(
11784 "{first}. {}",
11785 "And then keep going with more words ".repeat(20)
11786 );
11787 let t = long_title_of(&line);
11788 assert_eq!(t, first);
11789 assert!(!t.starts_with("chore: land"));
11790 }
11791
11792 #[test]
11793 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
11794 let line = "word ".repeat(200);
11795 let t = long_title_of(&line);
11796 assert!(t.ends_with("word..."), "{t}");
11797 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
11798 }
11799
11800 #[test]
11801 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
11802 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
11803 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
11804 }
11805 }
11806
11807 #[test]
11808 fn pr_message_title_limit_is_exact() {
11809 let at = "a".repeat(PR_TITLE_MAX);
11810 assert_eq!(long_title_of(&at), at);
11811 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
11812 assert!(over.ends_with("..."), "{over}");
11813 assert_eq!(over.chars().count(), PR_TITLE_MAX);
11814 }
11815
11816 #[test]
11817 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
11818 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
11819 let t = long_title_of(&line);
11820 assert!(t.ends_with("..."), "{t}");
11821 assert!(t.is_ascii(), "{t}");
11822 }
11823
11824 #[test]
11825 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
11826 let line = format!(
11827 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
11828 "plus more filler words ".repeat(20)
11829 );
11830 let t = long_title_of(&line);
11831 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
11832 }
11833
11834 #[test]
11835 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
11836 let line = format!("\n\n# {}", "title words ".repeat(40));
11837 let state = state_with_summary(&line, "- nothing");
11838 let m = pr_message(&state, 'A');
11839 assert!(m.title.starts_with("title words"), "{}", m.title);
11840 assert_eq!(
11841 land::merge_subject(&m.title, &landing_subject_source(&state)),
11842 m.title
11843 );
11844 assert_eq!(
11846 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
11847 "feat: renamed by hand"
11848 );
11849 }
11850
11851 #[tokio::test]
11852 async fn github_text_rewrite_is_bounded_and_falls_back() {
11853 crate::run::pin_test_home();
11854 for (reply, accepted) in [
11855 (
11856 "TITLE: fix: retries\nAdd retries for failed requests.",
11857 true,
11858 ),
11859 (
11860 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
11861 false,
11862 ),
11863 ("TITLE: fix: retries\nUse token=secret", false),
11864 ] {
11865 let dir = tempfile::tempdir().unwrap();
11866 let mut runner = runner_at(RunStatus::Gating);
11867 runner.state = state_with_summary(
11868 "add retries",
11869 "TITLE: fix: retries\n日本語の説明を書きます。",
11870 );
11871 runner.state.repo = dir.path().to_owned();
11872 runner.state.candidates[0].worktree = dir.path().to_owned();
11873 let mut author = spec("alpha");
11874 author.command = vec![
11875 "sh".into(),
11876 "-c".into(),
11877 "printf '%s' \"$REWRITE_REPLY\"".into(),
11878 ];
11879 author.env.insert("REWRITE_REPLY".into(), reply.into());
11880 runner.state.config.agents = vec![author];
11881 let winner = runner.state.candidates[0].clone();
11882 let message = runner.guarded_pr_message(&winner, None, true).await;
11883 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
11884 assert_eq!(
11885 message.body.contains("Add retries for failed requests."),
11886 accepted
11887 );
11888 assert_eq!(
11889 runner.state.seats["impl-A"].turns, 1,
11890 "only one rewrite invocation"
11891 );
11892 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
11893 if !accepted {
11894 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
11895 assert!(
11896 message
11897 .body
11898 .contains(&format!("magi:run/{}", runner.state.id))
11899 );
11900 }
11901 }
11902 }
11903
11904 #[test]
11905 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
11906 let mut state = state_with_summary(
11910 "add retries",
11911 "TITLE: fix(web): batch reads\n- reads run.json once",
11912 );
11913 state.config.graph.language = "ja".to_owned();
11914 let m = pr_message(&state, 'A');
11915 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
11916 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
11917
11918 let task = "今回やってほしいこと: results projector を直す";
11921 let mut state = state_with_summary(task, "- no title line");
11922 state.config.graph.language = "ja".to_owned();
11923 let m = pr_message(&state, 'A');
11924 assert_eq!(
11925 m.title,
11926 format!("chore: land candidate A of run {}", state.id)
11927 );
11928 assert!(
11929 m.body.contains(&format!(
11930 "<summary>Original task</summary>\n\n{task}\n\n</details>"
11931 )),
11932 "{}",
11933 m.body
11934 );
11935 }
11936
11937 #[test]
11938 fn pr_message_scrubs_home_paths_and_addresses() {
11939 let state = state_with_summary(
11940 "fix it in /Users/someone/src/x",
11941 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
11942 );
11943 let m = pr_message(&state, 'A');
11944 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
11945 assert!(!m.body.contains(leak), "{}", m.body);
11946 }
11947 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
11948 }
11949
11950 #[test]
11951 fn pr_message_survives_a_task_that_closes_details() {
11952 let state = state_with_summary("a </details> b", "TITLE: fix: x");
11953 let m = pr_message(&state, 'A');
11954 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
11955 }
11956
11957 #[test]
11958 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
11959 let cmd = manual_merge_command(
11960 MergeStyle::Squash,
11961 Path::new("/repo"),
11962 "b",
11963 "fix: \"quoted\" $(x) `y`\n\nbody",
11964 );
11965 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
11966 }
11967
11968 #[test]
11969 fn manual_merge_command_matches_the_configured_style() {
11970 let repo = Path::new("/repo");
11971 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
11972
11973 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
11974 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
11975
11976 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
11977 assert_eq!(
11978 squash,
11979 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
11980 \"Merge magi run 0832 (candidate A)\""
11981 );
11982
11983 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
11984 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
11985 }
11986
11987 #[test]
11988 fn a_nudge_gets_a_quarter_of_the_budget() {
11989 assert_eq!(retry_budget(secs(1200), true), secs(300));
11991 assert_eq!(retry_budget(secs(3600), true), secs(900));
11992 }
11993
11994 #[test]
11995 fn a_resent_prompt_keeps_the_whole_budget() {
11996 assert_eq!(retry_budget(secs(1200), false), secs(1200));
11999 assert_eq!(retry_budget(secs(60), false), secs(60));
12000 }
12001
12002 #[test]
12003 fn the_floor_never_exceeds_the_original_budget() {
12004 assert_eq!(retry_budget(secs(60), true), secs(60));
12008 assert_eq!(retry_budget(secs(480), true), secs(120));
12009 assert_eq!(retry_budget(secs(0), true), secs(0));
12010 }
12011
12012 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
12013 agent::CommandEvidence {
12014 id: "item1".to_owned(),
12015 description: "cargo test".to_owned(),
12016 exit_code,
12017 result_summary: String::new(),
12018 source: "codex".to_owned(),
12019 }
12020 }
12021
12022 #[test]
12023 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
12024 assert!(!has_unconfirmed_command(&[]));
12028 }
12029
12030 #[test]
12031 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
12032 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
12036 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
12037 assert!(!has_unconfirmed_command(&[
12038 evidence(Some(0)),
12039 evidence(Some(101))
12040 ]));
12041 }
12042
12043 #[test]
12044 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
12045 assert!(has_unconfirmed_command(&[
12046 evidence(Some(0)),
12047 evidence(None)
12048 ]));
12049 }
12050
12051 #[test]
12052 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
12053 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12054 assert_eq!(
12055 verified_noop_claim(true, &[], text).as_deref(),
12056 Some("already fixed by b32cfc4, on main.")
12057 );
12058 }
12059
12060 #[test]
12061 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
12062 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12065 assert!(verified_noop_claim(false, &[], text).is_none());
12066 }
12067
12068 #[test]
12069 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
12070 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12071 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
12072 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
12074 }
12075
12076 #[test]
12077 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
12078 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
12079 }
12080
12081 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
12084 runner.state.candidates = shape
12085 .iter()
12086 .enumerate()
12087 .map(|(i, &(empty, verified))| Candidate {
12088 index: i,
12089 label: (b'A' + i as u8) as char,
12090 agent: "sonnet".to_owned(),
12091 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
12092 worktree: PathBuf::from(format!("/wt/{i}")),
12093 summary: String::new(),
12094 stat: String::new(),
12095 files: 0,
12096 commits: 0,
12097 empty,
12098 failed: None,
12099 verified_noop: verified.map(str::to_owned),
12100 duration_ms: 0,
12101 folded: false,
12102 })
12103 .collect();
12104 }
12105
12106 #[test]
12107 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
12108 ask_test_home();
12109 let mut runner = runner_at(RunStatus::Implementing);
12110 set_candidates(
12111 &mut runner,
12112 &[
12113 (true, Some("already on main at b32cfc4")),
12114 (true, Some("same fix, see the existing test")),
12115 ],
12116 );
12117
12118 runner
12119 .after_implement()
12120 .expect("a verified no-op is not an error");
12121
12122 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
12123 }
12124
12125 #[test]
12126 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
12127 ask_test_home();
12128 let mut runner = runner_at(RunStatus::Implementing);
12129 set_candidates(
12133 &mut runner,
12134 &[(true, Some("already on main at b32cfc4")), (true, None)],
12135 );
12136
12137 let err = runner
12138 .after_implement()
12139 .expect_err("an unverified empty candidate must still fail the run");
12140
12141 assert!(
12142 err.to_string().contains("no candidate produced a change"),
12143 "{err}"
12144 );
12145 assert_eq!(runner.state.status, RunStatus::Failed);
12146 }
12147
12148 #[test]
12149 fn after_implement_still_fails_an_ordinary_all_empty_run() {
12150 ask_test_home();
12151 let mut runner = runner_at(RunStatus::Implementing);
12152 set_candidates(&mut runner, &[(true, None), (true, None)]);
12153
12154 let err = runner
12155 .after_implement()
12156 .expect_err("no candidate declared anything; this is an ordinary failure");
12157
12158 assert!(
12159 err.to_string().contains("no candidate produced a change"),
12160 "{err}"
12161 );
12162 assert_eq!(runner.state.status, RunStatus::Failed);
12163 }
12164}