1use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35 AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36 ResolvedRoles,
37};
38use crate::git;
39use crate::land;
40use crate::proc::Quiet as _;
41use crate::prompt::{
42 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
43};
44use crate::queue;
45use crate::refs;
46use crate::run::{
47 BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
48 DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord, Handover,
49 JobRecord, JobStatus, Judgement, MergeOutcome, OperatorFixFinding, OperatorFixOutcome,
50 OperatorFixRequest, Origin, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState,
51 RunStatus, SeatHistory, Tally, VoteRecord, tail, write_artifact,
52};
53use crate::verdict::{
54 self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
55 ReviewVote, Severity,
56};
57
58const OUTPUT_TAIL: usize = 8_000;
60
61const EVENT_OUTPUT_TAIL: usize = 2_000;
64
65const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
68
69const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
86
87pub(crate) const STAGNANT_LIMIT: usize = 2;
101
102const BASE_SYNC_ROUNDS: usize = 4;
115
116const MAX_FIX_CONTINUATIONS: usize = 2;
132
133#[derive(Clone)]
139struct SeatJob {
140 spec: AgentSpec,
141 seat: SeatState,
142 cwd: PathBuf,
143 prompt: String,
144 timeout: Duration,
145 allow_write: bool,
146 sessions: bool,
147 artifacts: PathBuf,
148 stem: String,
149 handover: Option<String>,
154}
155
156enum AgentOutcome {
168 Ok(AgentOutput),
170 Quota(AgentOutput),
172 Dropped(AgentOutput),
175 Failed(String),
177}
178
179#[derive(Debug, Clone, Default)]
206pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
207
208impl Pause {
209 #[must_use]
211 pub fn new() -> Self {
212 Self::default()
213 }
214
215 pub fn park(&self) {
217 self.0.store(true, Ordering::SeqCst);
218 }
219
220 pub fn park_because(&self, reason: impl Into<String>) {
226 let mut reason_guard = self
227 .1
228 .lock()
229 .unwrap_or_else(std::sync::PoisonError::into_inner);
230 if reason_guard.is_none() {
231 *reason_guard = Some(reason.into());
232 }
233 drop(reason_guard);
234 self.park();
235 }
236
237 #[must_use]
239 pub fn parked(&self) -> bool {
240 self.0.load(Ordering::SeqCst)
241 }
242
243 #[must_use]
245 pub fn reason(&self) -> Option<String> {
246 self.1
247 .lock()
248 .unwrap_or_else(std::sync::PoisonError::into_inner)
249 .clone()
250 }
251}
252
253pub struct Runner {
255 pub state: RunState,
257 roles: ResolvedRoles,
258 sem: Arc<Semaphore>,
259 pause: Pause,
263 interrupt: Pause,
269}
270
271async fn review_base(
276 repo: &Path,
277 remote: &str,
278 base_branch: &str,
279 base_commit: &str,
280 branch: &str,
281) -> String {
282 review_base_checked(repo, remote, base_branch, base_commit, branch)
283 .await
284 .0
285}
286
287async fn review_base_checked(
292 repo: &Path,
293 remote: &str,
294 base_branch: &str,
295 base_commit: &str,
296 branch: &str,
297) -> (String, bool) {
298 let tracking = format!("{remote}/{base_branch}");
299 let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
300 if git::rev_exists(repo, &tracking).await
301 && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
302 && !mb.is_empty()
303 {
304 return (mb, fresh);
305 }
306 let mb = git::merge_base(repo, base_commit, branch)
307 .await
308 .ok()
309 .filter(|mb| !mb.is_empty())
310 .unwrap_or_else(|| base_commit.to_owned());
311 (mb, false)
312}
313
314pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
318 if !is_review_run(state) {
319 return;
320 }
321 let base = review_base(
322 &state.repo,
323 &state.config.merge.remote,
324 &state.base_branch,
325 &state.base_commit,
326 branch,
327 )
328 .await;
329 if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
330 && !subjects.is_empty()
331 && state.reviewed_commits.as_ref() != Some(&subjects)
332 {
333 state.reviewed_commits = Some(subjects);
334 state.save().ok();
335 }
336}
337
338async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
342 let (base, trusted) = review_base_checked(
343 &state.repo,
344 &state.config.merge.remote,
345 &state.base_branch,
346 &state.base_commit,
347 branch,
348 )
349 .await;
350 if !trusted {
351 return None;
352 }
353 git::subjects(&state.repo, &state.base_commit, &base)
354 .await
355 .ok()
356}
357
358fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
362 let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
363 if is_leak(computed) {
364 return false;
365 }
366 let cur = current.trim();
367 cur.is_empty()
368 || cur.starts_with(REVIEW_PROMPT_OPENING)
369 || cur.starts_with("chore: land candidate")
370 || cur.starts_with("magi: candidate")
371 || is_leak(cur)
372}
373
374async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
403 let tracking = format!("{remote}/{branch}");
404 let fetched = git::fetch(repo, remote, branch).await;
405 let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
406 let local_exists = git::branch_exists(repo, branch).await?;
407 if !fresh {
408 if !local_exists {
409 bail!("no branch `{branch}` in {} or on {remote}", repo.display());
410 }
411 tracing::warn!(
412 "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
413 );
414 return Ok(());
415 }
416 let remote_sha = git::rev_parse(repo, &tracking).await?;
417 if !local_exists {
418 git::git(repo, &["branch", branch, &tracking]).await?;
419 return Ok(());
420 }
421 let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
422 if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
423 return Ok(());
424 }
425 if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
426 match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
432 .await?
433 {
434 crate::reconcile::Reconciliation::Pushed => {
435 tracing::warn!(
436 "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
437 short(&local_sha),
438 short(&remote_sha)
439 );
440 return Ok(());
441 }
442 crate::reconcile::Reconciliation::Placeholder => {}
443 crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
444 }
445 }
446 let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
447 if !out.ok() {
448 bail!(
449 "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
450 short(&local_sha),
451 short(&remote_sha),
452 out.stderr
453 );
454 }
455 tracing::warn!(
456 "local `{branch}` was stale: fast-forwarded {} -> {}",
457 short(&local_sha),
458 short(&remote_sha)
459 );
460 Ok(())
461}
462
463async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
464 let tracking = format!("{remote}/{base_branch}");
465 let fetched = git::fetch(repo, remote, base_branch).await;
466 if let Ok(out) = &fetched
467 && out.ok()
468 && git::rev_exists(repo, &tracking).await
469 {
470 return git::rev_parse(repo, &tracking).await;
471 }
472 let why = match &fetched {
473 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
474 Ok(_) => format!("{remote} has no {base_branch}"),
475 Err(e) => e.to_string(),
476 };
477 tracing::warn!(
478 "could not read {tracking} ({why}); branching off the local \
479 {base_branch} instead, which may be behind"
480 );
481 git::rev_parse(repo, base_branch).await.with_context(|| {
482 format!(
483 "cannot resolve `{base_branch}`; set [merge] base in magi.toml to a \
484 branch that exists"
485 )
486 })
487}
488
489struct FixClaim {
506 path: PathBuf,
507}
508
509impl FixClaim {
510 fn acquire(dir: &Path) -> Result<Self> {
511 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
512 let path = dir.join("fix.lock");
513 match Self::create(&path) {
514 Ok(claim) => Ok(claim),
515 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
516 if Self::reclaim_if_dead(&path) {
517 Self::create(&path).with_context(|| format!("lock {}", path.display()))
518 } else {
519 bail!(
520 "another `magi fix` is already running for this run ({} exists)",
521 path.display()
522 )
523 }
524 }
525 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
526 }
527 }
528
529 fn create(path: &Path) -> std::io::Result<Self> {
530 let mut f = std::fs::OpenOptions::new()
531 .write(true)
532 .create_new(true)
533 .open(path)?;
534 use std::io::Write as _;
535 writeln!(f, "{}", std::process::id())?;
537 Ok(Self {
538 path: path.to_owned(),
539 })
540 }
541
542 fn reclaim_if_dead(path: &Path) -> bool {
546 let dead = std::fs::read_to_string(path)
547 .ok()
548 .and_then(|body| body.trim().parse::<u32>().ok())
549 .is_some_and(|pid| !crate::proc::pid_alive(pid));
550 dead && std::fs::remove_file(path).is_ok()
551 }
552}
553
554impl Drop for FixClaim {
555 fn drop(&mut self) {
556 let _ = std::fs::remove_file(&self.path);
557 }
558}
559
560impl Runner {
561 pub async fn start(
563 repo: &Path,
564 instruction: String,
565 config: Config,
566 origin: Origin,
567 ) -> Result<Self> {
568 Self::start_naming(repo, instruction, "", config, origin).await
569 }
570
571 pub async fn start_naming(
575 repo: &Path,
576 instruction: String,
577 also_scan: &str,
578 config: Config,
579 origin: Origin,
580 ) -> Result<Self> {
581 let repo = git::toplevel(repo).await?;
582 let missing = agent::missing_programs(&config.agents);
583 if !missing.is_empty() {
584 bail!(
585 "these agent programs are not on PATH: {}. Fix the roster in \
586 magi.toml or install them.",
587 missing.join(", ")
588 );
589 }
590 let base_branch = match config.merge.base.clone() {
591 Some(b) => b,
592 None => git::current_branch(&repo)
593 .await?
594 .context("HEAD is detached; set [merge] base in magi.toml")?,
595 };
596 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
597 if !git::is_clean(&repo).await? {
601 tracing::warn!(
602 "{} has uncommitted changes; they are not part of this run, \
603 which branches off {base_branch} ({})",
604 repo.display(),
605 &base_commit[..base_commit.len().min(8)]
606 );
607 }
608 let roles = config.resolve_roles()?;
609 let max_parallel = config.graph.max_parallel.max(1);
610 let seeds = refs::resolve(
613 &repo,
614 &base_commit,
615 &config.merge.remote,
616 &format!("{also_scan}\n{instruction}"),
617 )
618 .await;
619 refs::plan(&repo, &seeds).await?;
620 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
621 state.origin = Some(origin);
625 for seed in &seeds {
626 state.event(
627 "seed",
628 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
629 );
630 }
631 state.seeds = seeds;
632 state.event("start", format!("run {} created", state.id));
633 state.save()?;
634 Ok(Self {
635 state,
636 roles,
637 sem: Arc::new(Semaphore::new(max_parallel)),
638 pause: Pause::new(),
639 interrupt: Pause::new(),
640 })
641 }
642
643 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
657 Self::review_taking_over(repo, branch, config, None, origin).await
658 }
659
660 pub async fn review_taking_over(
667 repo: &Path,
668 branch: &str,
669 config: Config,
670 takeover: Option<crate::handover::Takeover>,
671 origin: Origin,
672 ) -> Result<Self> {
673 let repo = git::toplevel(repo).await?;
674 let missing = agent::missing_programs(&config.agents);
675 if !missing.is_empty() {
676 bail!(
677 "these agent programs are not on PATH: {}. Fix the roster in \
678 magi.toml or install them.",
679 missing.join(", ")
680 );
681 }
682 let base_branch = match config.merge.base.clone() {
683 Some(b) => b,
684 None => git::current_branch(&repo)
685 .await?
686 .context("HEAD is detached; set [merge] base in magi.toml")?,
687 };
688 if base_branch == branch {
689 bail!("`{branch}` is the base branch; there is nothing to review against");
690 }
691 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
692
693 let roles = config.resolve_roles()?;
694 let max_parallel = config.graph.max_parallel.max(1);
695 let mut state = RunState::new(
696 repo.clone(),
697 base_branch,
698 base_commit.clone(),
699 String::new(),
700 config,
701 );
702 state.origin = Some(origin);
703
704 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
711 earlier: Vec::new(),
712 home: crate::run::home(),
713 choice: None,
714 });
715 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
716 if let Some(released) = &released {
717 state.event(
718 "release",
719 format!(
720 "took `{branch}` over from run {}: its worktree was released: {}",
721 crate::run::short_of(&released.old_id),
722 released.audit
723 ),
724 );
725 }
726 if let Some(choice) = takeover.choice.as_ref()
730 && let Err(e) =
731 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
732 .await
733 {
734 if let Some(released) = &released {
735 released.restore(&repo, branch).await;
736 }
737 return Err(e.context("applying the owner's answer about the diverged branch"));
738 }
739 let opened =
740 Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
741 if opened.is_err()
742 && let Some(released) = &released
743 {
744 released.restore(&repo, branch).await;
745 }
746 opened
747 }
748
749 async fn open_review(
752 repo: &Path,
753 branch: &str,
754 mut state: RunState,
755 roles: ResolvedRoles,
756 max_parallel: usize,
757 base_commit: String,
758 ) -> Result<Self> {
759 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
760 let start = review_base(
763 repo,
764 &state.config.merge.remote,
765 &state.base_branch,
766 &base_commit,
767 branch,
768 )
769 .await;
770 let log = git::log_oneline(repo, &start, branch)
771 .await
772 .unwrap_or_default();
773 let instruction = format!(
774 "Review the work already on branch `{branch}`. There is no task \
775 statement: what the change claims to do is whatever its commits \
776 say.\n\n{}",
777 if log.trim().is_empty() {
778 "(no commit messages)"
779 } else {
780 log.trim()
781 }
782 );
783 state.instruction = instruction;
784 state.reviewed_commits = Some(
785 git::subjects(repo, &start, branch)
786 .await
787 .unwrap_or_default(),
788 );
789
790 let worktree = state.worktree_root().join("under-review");
793 if let Some(parent) = worktree.parent() {
794 tokio::fs::create_dir_all(parent).await.ok();
795 }
796 let path = worktree.to_string_lossy().to_string();
797 git::git(repo, &["worktree", "add", &path, branch])
798 .await
799 .with_context(|| {
800 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
801 })?;
802
803 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
804 .await
805 .unwrap_or(0);
806 if commits == 0 {
807 git::worktree_remove(repo, &worktree).await.ok();
808 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
809 }
810 let files = git::changed_files(&worktree, &base_commit, "HEAD")
811 .await
812 .map(|f| f.len())
813 .unwrap_or(0);
814 if files == 0
815 && let (Ok(head_tree), Ok(base_tree)) = (
816 git::tree_of(&worktree, "HEAD").await,
817 git::tree_of(&worktree, &base_commit).await,
818 )
819 && head_tree == base_tree
820 {
821 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
822 git::worktree_remove(repo, &worktree).await.ok();
823 bail!(
824 "`{branch}` at {} has a tree identical to base {}; this usually means \
825 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
826 against `{}/{branch}`) rather than an empty change",
827 short(&head),
828 short(&base_commit),
829 state.config.merge.remote
830 );
831 }
832 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
833 .await
834 .unwrap_or_default();
835
836 state.candidates.push(Candidate {
837 index: 0,
838 label: 'A',
839 agent: EXISTING_BRANCH.to_owned(),
842 branch: branch.to_owned(),
843 worktree,
844 summary: String::new(),
845 stat,
846 files,
847 commits,
848 empty: false,
849 failed: None,
850 verified_noop: None,
851 duration_ms: 0,
852 folded: false,
853 });
854 state.tally = Some(Tally {
855 first_choice: BTreeMap::from([('A', 0)]),
856 borda: BTreeMap::new(),
857 winner: 'A',
858 rankings: 0,
859 unanimous_initial: false,
860 deliberated: false,
861 changed_votes: 0,
862 unanimous_final: false,
863 tie_break: None,
864 judges: 0,
868 present: 0,
869 quorum: 0,
870 met_quorum: true,
871 uncontested: Some("review-only run: nothing competed".to_owned()),
872 });
873 state.status = RunStatus::Reviewing;
874 state.event(
875 "start",
876 format!(
877 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
878 state.id
879 ),
880 );
881 state.save()?;
882 Ok(Self {
883 state,
884 roles,
885 sem: Arc::new(Semaphore::new(max_parallel)),
886 pause: Pause::new(),
887 interrupt: Pause::new(),
888 })
889 }
890
891 pub fn resume(id: &str) -> Result<Self> {
893 let state = RunState::load(id)?;
894 if let Some(to) = &state.released_to {
895 bail!(
896 "run {} cannot be resumed: its worktree was released to run {}",
897 state.short(),
898 crate::run::short_of(to)
899 );
900 }
901 let roles = state.config.resolve_roles()?;
902 let max_parallel = state.config.graph.max_parallel.max(1);
903 Ok(Self {
904 state,
905 roles,
906 sem: Arc::new(Semaphore::new(max_parallel)),
907 pause: Pause::new(),
908 interrupt: Pause::new(),
909 })
910 }
911
912 pub async fn execute(&mut self) -> Result<()> {
919 let result = self.execute_graph().await;
920 self.mark_driver_exited();
921 let ended = if result.is_err() {
922 Some(crate::notices::run_stopped(&self.state.id, &self.state))
923 } else {
924 crate::notices::run_ended(&self.state)
925 };
926 if let Some(notice) = ended {
927 crate::notices::raise(notice);
928 }
929 result
930 }
931
932 fn mark_driver_exited(&mut self) {
941 self.state.driver_exited = true;
942 let pid = std::process::id();
943 let Ok(mut disk) = RunState::load(&self.state.id) else {
944 return;
945 };
946 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
947 return;
948 }
949 disk.driver_exited = true;
950 if let Err(e) = disk.save() {
951 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
952 }
953 }
954
955 async fn execute_graph(&mut self) -> Result<()> {
956 self.state.parked = false;
961 self.state.clear_active();
968 if let Ok(disk) = RunState::load(&self.state.id)
986 && let Some(to) = &disk.released_to
987 {
988 bail!(
989 "run {} cannot continue: its worktree was released to run {}",
990 self.state.short(),
991 crate::run::short_of(to)
992 );
993 }
994 let pid = std::process::id();
995 self.state.driver_pid = Some(pid);
996 self.state.driver_started_at = crate::proc::process_started_at(pid);
997 self.state.driver_exited = false;
998 self.state.save()?;
999 if self.state.status == RunStatus::Stalled {
1012 if self.recover_stall().await? {
1013 self.finish_after_tally().await?;
1014 } else {
1015 self.state.save()?;
1017 }
1018 return Ok(());
1019 }
1020 if self.state.status == RunStatus::Landing {
1030 self.run_land().await?;
1031 self.settle_questions();
1036 return Ok(());
1037 }
1038 self.prep().await?;
1039 if self.park_here()? {
1040 return Ok(());
1041 }
1042 self.advise().await?;
1043 if self.park_here()? {
1044 return Ok(());
1045 }
1046 self.implement().await?;
1047 if self.park_here()? {
1048 return Ok(());
1049 }
1050 if self.state.status == RunStatus::VerifiedNoop {
1054 return Ok(());
1055 }
1056 self.judge().await?;
1057 if self.park_here()? {
1058 return Ok(());
1059 }
1060 self.deliberate().await?;
1061 if self.park_here()? {
1062 return Ok(());
1063 }
1064 self.vote().await?;
1065 if self.park_here()? {
1066 return Ok(());
1067 }
1068 self.tally()?;
1069 if self.state.status == RunStatus::Stalled {
1074 self.state.save()?;
1078 return Ok(());
1079 }
1080 self.finish_after_tally().await?;
1081 Ok(())
1082 }
1083
1084 fn park_here(&mut self) -> Result<bool> {
1091 if !self.pause.parked() && !self.interrupt.parked() {
1096 return Ok(false);
1097 }
1098 let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1099 Some(reason) => format!(
1100 "parked after `{}` ({reason}) — resume to carry on from here",
1101 self.state.status.as_str()
1102 ),
1103 None => format!(
1104 "parked after `{}` — resume to carry on from here",
1105 self.state.status.as_str()
1106 ),
1107 };
1108 self.state.event("park", why);
1109 self.state.parked = true;
1110 self.state.save()?;
1111 Ok(true)
1112 }
1113
1114 pub fn on_pause(&mut self, pause: Pause) {
1116 self.pause = pause;
1117 }
1118
1119 pub fn watch_interrupt(&mut self, pause: Pause) {
1125 self.interrupt = pause;
1126 }
1127
1128 fn settle_questions(&mut self) {
1148 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1149 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1150 }
1151 }
1152
1153 async fn finish_after_tally(&mut self) -> Result<()> {
1156 self.fold_losers().await?;
1157 self.sync_to_base().await?;
1162 if self.state.status == RunStatus::AlreadyInBase {
1163 return Ok(());
1164 }
1165 self.review_loop().await?;
1166 self.sync_to_base().await?;
1167 if self.state.status == RunStatus::AlreadyInBase {
1168 return Ok(());
1169 }
1170 self.gate().await?;
1171 self.merge().await?;
1172 self.state.save()?;
1173 Ok(())
1174 }
1175
1176 async fn prep(&mut self) -> Result<()> {
1179 if !self.state.candidates.is_empty() {
1180 return Ok(());
1181 }
1182 self.state.status = RunStatus::Prep;
1183 let repo = self.state.repo.clone();
1184 let base = self.state.base_commit.clone();
1185 let plan = refs::plan(&repo, &self.state.seeds).await?;
1186 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1187 let root = self.state.worktree_root();
1188 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1189
1190 let hooks_dir = self.state.dir().join("hooks");
1193 if self.state.config.blind.commit_msg_hook {
1194 std::fs::create_dir_all(&hooks_dir)
1195 .with_context(|| format!("create {}", hooks_dir.display()))?;
1196 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1197 let path = hooks_dir.join("commit-msg");
1198 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1199 make_executable(&path)?;
1200 git::acquire_worktree_config(&repo).await?;
1208 self.state.enabled_worktree_config = true;
1209 }
1210
1211 for (index, (spec, label)) in self
1212 .roles
1213 .implementers
1214 .clone()
1215 .into_iter()
1216 .zip(labels)
1217 .enumerate()
1218 {
1219 let branch = self.state.branch_for(label);
1220 let worktree = root.join(format!("cand-{label}"));
1221 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1222 if self.state.config.blind.commit_msg_hook {
1223 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1224 }
1225 git::local_exclude(&worktree, "/.magi/").await?;
1226 for pick in &plan.picks {
1227 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1228 self.state.status = RunStatus::Blocked;
1229 self.state
1230 .event("prep", format!("cannot apply referenced commit: {e}"));
1231 self.state.save()?;
1232 return Err(e);
1233 }
1234 }
1235 self.state.candidates.push(Candidate {
1236 index,
1237 label,
1238 agent: spec.id.clone(),
1239 branch,
1240 worktree,
1241 summary: String::new(),
1242 stat: String::new(),
1243 files: 0,
1244 commits: 0,
1245 empty: false,
1246 failed: None,
1247 verified_noop: None,
1248 duration_ms: 0,
1249 folded: false,
1250 });
1251 }
1252
1253 for j in 1..=self.roles.judges.len() {
1254 let wt = root.join(format!("judge-{j}"));
1255 if !wt.exists() {
1256 git::worktree_add_detached(&repo, &wt, &base).await?;
1257 }
1258 }
1259
1260 if self.state.config.graph.advise {
1268 for k in 1..=self.state.config.graph.advisors {
1269 let wt = root.join(format!("advisor-{k}"));
1270 if !wt.exists() {
1271 git::worktree_add_detached(&repo, &wt, &base).await?;
1272 }
1273 }
1274 }
1275
1276 let authors: Vec<&str> = self
1281 .roles
1282 .implementers
1283 .iter()
1284 .map(|a| a.id.as_str())
1285 .collect();
1286 let overlap: Vec<String> = self
1287 .roles
1288 .judges
1289 .iter()
1290 .enumerate()
1291 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1292 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1293 .collect();
1294 if !overlap.is_empty() {
1295 let note = format!(
1296 "{} also authored a candidate; blind, but the panel is less \
1297 independent than {} distinct agents would be",
1298 overlap.join(", "),
1299 self.roles.judges.len()
1300 );
1301 self.state.event("prep", note);
1302 }
1303
1304 self.state.event(
1305 "prep",
1306 format!(
1307 "{} candidates, {} judges, base {} ({})",
1308 self.state.candidates.len(),
1309 self.roles.judges.len(),
1310 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1311 self.state.base_branch
1312 ),
1313 );
1314 self.state.status = RunStatus::Implementing;
1315 self.state.save()?;
1316 Ok(())
1317 }
1318
1319 async fn advise(&mut self) -> Result<()> {
1352 let implement_untouched = self
1353 .state
1354 .candidates
1355 .iter()
1356 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1357 if !self.state.config.graph.advise || self.state.advise_attempted {
1358 return Ok(());
1359 }
1360 if !implement_untouched {
1361 self.state.event(
1362 "advise",
1363 "skipping the design-deliberation stage: at least one \
1364 candidate already shows implementation progress, so this \
1365 run is past the point the stage exists to run before"
1366 .to_owned(),
1367 );
1368 self.state.advise_attempted = true;
1369 self.state.save()?;
1370 return Ok(());
1371 }
1372 let run_id = self.state.id.clone();
1373 let prompts = self.state.config.prompts.clone();
1374 let instruction = self.state.instruction.clone();
1375 let language = self.state.config.graph.language.clone();
1376 let root = self.state.worktree_root();
1377 let n = self.state.config.graph.advisors;
1378 let where_recorded = self.state.dir().join("run.json");
1379
1380 let seats = match self.state.config.advisors() {
1381 Ok(seats) if !seats.is_empty() => seats,
1382 Ok(_) => {
1383 self.state.event(
1384 "advise",
1385 format!(
1386 "[graph] advisors is 0; skipping the design-deliberation \
1387 stage and continuing without a synthesis brief (see {})",
1388 where_recorded.display()
1389 ),
1390 );
1391 self.state.advise_attempted = true;
1392 self.state.save()?;
1393 return Ok(());
1394 }
1395 Err(e) => {
1396 self.state.event(
1397 "advise",
1398 format!(
1399 "could not resolve advisor seats ({e:#}); continuing \
1400 without a design-deliberation brief (see {})",
1401 where_recorded.display()
1402 ),
1403 );
1404 self.state.advise_attempted = true;
1405 self.state.save()?;
1406 return Ok(());
1407 }
1408 };
1409
1410 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1411 let artifacts = agent::artifacts_dir(&self.state.dir());
1412 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1413
1414 let mut jobs = Vec::new();
1415 for (i, spec) in seats.iter().cloned().enumerate() {
1416 let seat_key = format!("advisor-{}", i + 1);
1417 let seat = self.seat(&seat_key, &spec.id);
1418 jobs.push(SeatJob {
1419 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1420 spec,
1421 seat,
1422 cwd: worktrees[i % worktrees.len()].clone(),
1423 timeout,
1424 allow_write: false,
1425 sessions: false,
1426 artifacts: artifacts.clone(),
1427 stem: seat_key,
1428 handover: None,
1429 });
1430 }
1431
1432 self.state.event(
1433 "advise",
1434 format!(
1435 "{} advisor seat(s) sketching a design in parallel",
1436 jobs.len()
1437 ),
1438 );
1439 let mut quota_losses = Vec::new();
1440 let cache = self.state.config.cache_dir();
1441 let ctx = WaveCtx {
1442 carry_seats: false,
1443 run: &run_id,
1444 node: "advise",
1445 prompts: &prompts,
1446 cache: cache.as_deref(),
1447 round: None,
1448 };
1449 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1450 let results = ask_json_wave::<Proposal>(
1451 jobs,
1452 Arc::clone(&self.sem),
1453 self.state.config.graph.retries,
1454 &advisor_roster,
1455 &ctx,
1456 &mut quota_losses,
1457 &mut self.state,
1458 &|p: &Proposal| p.validate(),
1459 )
1460 .await;
1461 self.state.quota.extend(quota_losses);
1462
1463 let mut records = Vec::with_capacity(results.len());
1464 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1465 let agent_id = seat.agent.clone();
1466 self.state.seats.insert(seat.key.clone(), seat);
1467 match res {
1468 Ok((proposal, out)) => {
1469 self.state
1470 .event("advise", format!("advisor-{} proposed a design", i + 1));
1471 records.push(advise::AdvisorRecord::proposed(
1472 i + 1,
1473 agent_id,
1474 proposal,
1475 out.duration_ms,
1476 ));
1477 }
1478 Err(e) => {
1479 self.state.event(
1480 "advise",
1481 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1482 );
1483 records.push(advise::AdvisorRecord::failed(
1484 i + 1,
1485 agent_id,
1486 e.to_string(),
1487 ));
1488 }
1489 }
1490 }
1491
1492 let mut advice = advise::Advice {
1493 records,
1494 synthesis: None,
1495 };
1496 if advice.proposals().is_empty() {
1497 self.state.event(
1498 "advise",
1499 "no advisor produced a usable proposal; continuing without a \
1500 synthesis brief"
1501 .to_owned(),
1502 );
1503 } else {
1504 match self
1505 .synthesize_brief(
1506 &advice,
1507 &instruction,
1508 &language,
1509 &worktrees[0],
1510 &artifacts,
1511 &run_id,
1512 &prompts,
1513 cache.as_deref(),
1514 )
1515 .await
1516 {
1517 Ok(Some(text)) => {
1518 self.state.event(
1519 "advise",
1520 "synthesized a design brief for the implementer".to_owned(),
1521 );
1522 advice.synthesis = Some(text);
1523 }
1524 Ok(None) => {
1525 self.state.event(
1526 "advise",
1527 "the synthesis seat produced nothing usable; continuing \
1528 without a design brief"
1529 .to_owned(),
1530 );
1531 }
1532 Err(e) => {
1533 self.state.event(
1534 "advise",
1535 format!("could not synthesize a design brief: {e:#}"),
1536 );
1537 }
1538 }
1539 }
1540 advise::apply_reflection(&mut advice);
1541
1542 self.state.advice = Some(advice);
1543 self.state.advise_attempted = true;
1544 self.state.save()?;
1545 Ok(())
1546 }
1547
1548 #[allow(clippy::too_many_arguments)]
1560 async fn synthesize_brief(
1561 &mut self,
1562 advice: &advise::Advice,
1563 instruction: &str,
1564 language: &str,
1565 cwd: &Path,
1566 artifacts: &Path,
1567 run_id: &str,
1568 prompts: &Prompts,
1569 cache: Option<&Path>,
1570 ) -> Result<Option<String>> {
1571 let chain = agent::pick_chain(
1572 &self.state.config.agents,
1573 self.state.config.roles.synthesizer.as_ref(),
1574 &agent::installed,
1575 "synthesizer",
1576 )?;
1577 let proposals = advice.proposals();
1578 let mut prompt = prompt::with_overlay(
1579 prompt::synthesize_brief(instruction, &proposals, language),
1580 prompts.overlay("advise"),
1581 );
1582 if cache.is_some() {
1583 prompt.push('\n');
1588 prompt.push_str(&prompt::build_cache_note("advise", false));
1589 }
1590 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1591 let mut last = None;
1596 for (n, spec) in chain.iter().enumerate() {
1597 if n > 0 {
1598 self.state
1599 .event("advise", format!("synthesis falling back to {}", spec.id));
1600 }
1601 let mut seat = self.seat("advise-synthesis", &spec.id);
1602 let outcome = agent::invoke(
1603 spec,
1604 &mut seat,
1605 &Invocation {
1606 cwd,
1607 prompt: &prompt,
1608 timeout,
1609 allow_write: false,
1610 sessions: false,
1611 artifacts,
1612 stem: &if n == 0 {
1613 "advise-synthesis".to_owned()
1614 } else {
1615 format!("advise-synthesis-{}", spec.id)
1616 },
1617 run: run_id,
1618 node: "advise",
1619 cache_dir: None,
1620 attachments: &[],
1621 writable: &[],
1622 },
1623 )
1624 .await;
1625 if outcome.is_ok() {
1626 self.state.seats.insert(seat.key.clone(), seat);
1627 }
1628 let advance = agent::chain_advances(&outcome);
1629 last = Some(outcome);
1630 if !advance {
1631 break;
1632 }
1633 }
1634 let out = last.expect("a chain holds at least one agent")?;
1637 if !out.usable() {
1638 return Ok(None);
1639 }
1640 let text =
1641 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1642 Ok((!text.trim().is_empty()).then_some(text))
1643 }
1644
1645 async fn implement(&mut self) -> Result<()> {
1648 let run_id = self.state.id.clone();
1653 let prompts = self.state.config.prompts.clone();
1654 let todo: Vec<usize> = self
1655 .state
1656 .candidates
1657 .iter()
1658 .enumerate()
1659 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1660 .map(|(i, _)| i)
1661 .collect();
1662 if todo.is_empty() {
1663 return self.after_implement();
1664 }
1665 self.state.status = RunStatus::Implementing;
1666
1667 let language = self.state.config.graph.language.clone();
1668 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1669 let sessions = self.state.config.graph.sessions;
1670 let artifacts = agent::artifacts_dir(&self.state.dir());
1671 let brief = self
1675 .state
1676 .advice
1677 .as_ref()
1678 .and_then(|a| a.synthesis.as_deref())
1679 .map(str::to_owned);
1680 let attachments = self.state.attachments.clone();
1681
1682 let mut jobs = Vec::new();
1683 for &i in &todo {
1684 let (index, label, worktree) = {
1685 let c = &self.state.candidates[i];
1686 (c.index, c.label, c.worktree.clone())
1687 };
1688 let spec = self.roles.implementers[index].clone();
1689 let seat_key = format!("impl-{label}");
1690 let seat = self.seat(&seat_key, &spec.id);
1691 let instruction = seeded_instruction(&self.state);
1692 jobs.push(SeatJob {
1693 spec,
1694 seat,
1695 prompt: prompt::implement(
1696 &instruction,
1697 &worktree.to_string_lossy(),
1698 &language,
1699 brief.as_deref(),
1700 &attachments,
1701 ),
1702 cwd: worktree,
1703 timeout,
1704 allow_write: true,
1705 sessions,
1706 artifacts: artifacts.clone(),
1707 stem: format!("impl-{label}"),
1708 handover: None,
1709 });
1710 }
1711
1712 self.state.event(
1713 "implement",
1714 format!("{} candidates in parallel", jobs.len()),
1715 );
1716 let mut sent = jobs.clone();
1722 let cache = self.state.config.cache_dir();
1723 let ctx = WaveCtx {
1724 carry_seats: false,
1725 run: &run_id,
1726 node: "implement",
1727 prompts: &prompts,
1728 cache: cache.as_deref(),
1729 round: None,
1730 };
1731 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1732 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1733 .await;
1734 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1735 .await;
1736 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1737 .await;
1738
1739 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1740 let seat_key = seat.key.clone();
1741 let agent = seat.agent.clone();
1750 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1751 self.state.seats.insert(seat.key.clone(), seat);
1752 let label = self.state.candidates[i].label;
1753 let worktree = self.state.candidates[i].worktree.clone();
1754 let base = self.state.base_commit.clone();
1755
1756 let (summary, duration, failed, verified_claim) = match out {
1757 AgentOutcome::Ok(o) => {
1758 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1759 let failed = (!o.usable()).then(|| {
1760 if o.timed_out {
1761 "agent timed out".to_owned()
1762 } else {
1763 format!("agent exited with {:?}", o.exit_code)
1764 }
1765 });
1766 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1767 (text, o.duration_ms, failed, verified_claim)
1768 }
1769 AgentOutcome::Dropped(o) => {
1775 let why = o
1776 .dropped
1777 .as_ref()
1778 .map(|d| d.why.as_str())
1779 .unwrap_or("the CLI ended the stream without delivering its answer");
1780 (
1781 String::new(),
1782 o.duration_ms,
1783 Some(format!("the CLI dropped the stream ({why})")),
1784 None,
1785 )
1786 }
1787 AgentOutcome::Quota(o) => {
1788 self.state.quota.push(QuotaLoss {
1789 seat: seat_key,
1790 node: "implement".to_owned(),
1791 at: Timestamp::now(),
1792 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1793 });
1794 (
1795 String::new(),
1796 o.duration_ms,
1797 Some("rate limited (quota); produced no change".to_owned()),
1798 None,
1799 )
1800 }
1801 AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1802 };
1803
1804 let rescued = match git::rescue_commit(
1807 &worktree,
1808 &format!("magi: candidate {label} (uncommitted work)"),
1809 )
1810 .await
1811 {
1812 Ok(r) => {
1813 self.state.note_withheld("implement", &r.withheld);
1814 r.committed
1815 }
1816 Err(_) => false,
1817 };
1818 let commits = git::commits_ahead(&worktree, &base, "HEAD")
1819 .await
1820 .unwrap_or(0);
1821 let patch = git::diff(&worktree, &base, "HEAD")
1822 .await
1823 .unwrap_or_default();
1824 let stat = git::diff_stat(&worktree, &base, "HEAD")
1825 .await
1826 .unwrap_or_default();
1827 let files = git::changed_files(&worktree, &base, "HEAD")
1828 .await
1829 .map(|f| f.len())
1830 .unwrap_or(0);
1831 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1832
1833 let c = &mut self.state.candidates[i];
1834 if !exhausted_the_fallback_chain {
1835 c.agent = agent;
1836 }
1837 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1838 c.stat = stat;
1839 c.files = files;
1840 c.commits = commits;
1841 c.duration_ms = duration;
1842 c.empty = commits == 0 || patch.trim().is_empty();
1843 c.failed = match failed {
1846 Some(_) if c.empty => failed,
1847 _ => None,
1848 };
1849 c.verified_noop = if c.empty { verified_claim } else { None };
1854 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1855 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1856 (None, true, Some(_), _) => {
1857 format!("candidate {label}: no change produced (agent-verified no-op)")
1858 }
1859 (None, true, None, _) => format!("candidate {label}: no change produced"),
1860 (None, false, _, true) => {
1861 format!(
1862 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1863 )
1864 }
1865 (None, false, _, false) => {
1866 format!("candidate {label}: {files} files, {commits} commits")
1867 }
1868 };
1869 self.state.event("implement", note);
1870 self.state.save()?;
1871 }
1872
1873 self.after_implement()
1874 }
1875
1876 async fn resume_undelivered(
1904 &mut self,
1905 results: &mut [(usize, SeatState, AgentOutcome)],
1906 sent: &[SeatJob],
1907 prompts: &Prompts,
1908 run_id: &str,
1909 ) {
1910 for (wi, seat, out) in results.iter_mut() {
1911 let Some(dropped) = (match &*out {
1912 AgentOutcome::Dropped(o) => o.dropped.clone(),
1913 _ => None,
1914 }) else {
1915 continue;
1916 };
1917 let Some(job) = sent.get(*wi) else { continue };
1918 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1920 self.state.event(
1921 "implement",
1922 format!(
1923 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1924 work is in the tree",
1925 seat.key, dropped.output_tokens, dropped.why
1926 ),
1927 );
1928 continue;
1929 }
1930 if !has_context(&job.spec, seat, job.sessions) {
1938 self.state.event(
1939 "implement",
1940 format!(
1941 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
1942 is no session left to resume",
1943 seat.key, dropped.output_tokens, dropped.why
1944 ),
1945 );
1946 continue;
1947 }
1948 self.state.event(
1949 "implement",
1950 format!(
1951 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
1952 conversation",
1953 seat.key, dropped.output_tokens, dropped.why
1954 ),
1955 );
1956 let mut retry = job.clone();
1957 retry.seat = seat.clone();
1958 retry.prompt = prompt::resume_after_drop(&dropped.why);
1959 retry.timeout = retry_budget(job.timeout, true);
1960 retry.stem = format!("{}-resume", job.stem);
1961 let cache = self.state.config.cache_dir();
1962 let ctx = WaveCtx {
1963 carry_seats: false,
1964 run: run_id,
1965 node: "implement",
1966 prompts,
1967 cache: cache.as_deref(),
1968 round: None,
1969 };
1970 let (resumed_seat, resumed) =
1971 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
1972 *seat = resumed_seat;
1973 *out = resumed;
1974 }
1975 }
1976
1977 async fn resume_seat_handovers(
2042 &mut self,
2043 results: &mut [(usize, SeatState, AgentOutcome)],
2044 sent: &mut [SeatJob],
2045 prompts: &Prompts,
2046 run_id: &str,
2047 ) {
2048 let instruction = seeded_instruction(&self.state);
2049 let language = self.state.config.graph.language.clone();
2050 let brief = self
2051 .state
2052 .advice
2053 .as_ref()
2054 .and_then(|a| a.synthesis.as_deref())
2055 .map(str::to_owned);
2056 let attachments = self.state.attachments.clone();
2057 for (wi, seat, out) in results.iter_mut() {
2058 let Some(job) = sent.get_mut(*wi) else {
2059 continue;
2060 };
2061 let start = self
2066 .roles
2067 .implementer_roster
2068 .iter()
2069 .position(|s| s.id == job.spec.id)
2070 .unwrap_or(0);
2071 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2072 let mut fallback_attempt = 0usize;
2073 let mut prev: Option<FailClass> = None;
2074 while let Some(cur) = FailClass::of(&*out) {
2075 if !should_hand_over(prev.as_ref(), &cur) {
2076 break;
2077 }
2078 let Some(next) =
2079 next_untried_in_roster(&self.roles.implementer_roster, start, &tried).cloned()
2080 else {
2081 break;
2082 };
2083 tried.insert(next.id.clone());
2084 fallback_attempt += 1;
2085
2086 if let Ok(r) = git::rescue_commit(
2087 &job.cwd,
2088 &format!(
2089 "magi: candidate {} (uncommitted work before {} fallback)",
2090 seat.key,
2091 if cur == FailClass::Quota {
2092 "quota"
2093 } else {
2094 "handover"
2095 }
2096 ),
2097 )
2098 .await
2099 {
2100 self.state.note_withheld("implement", &r.withheld);
2101 }
2102
2103 record_handover(
2104 &mut self.state,
2105 "implement",
2106 &seat.key,
2107 &seat.agent,
2108 &next.id,
2109 &cur,
2110 &fail_reason(&*out),
2111 );
2112 prev = Some(cur.clone());
2113
2114 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2115 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2116 job.spec = next.clone();
2124 let mut retry = job.clone();
2125 retry.seat = new_seat;
2126 retry.prompt = prompt::implement(
2127 &instruction,
2128 &job.cwd.to_string_lossy(),
2129 &language,
2130 brief.as_deref(),
2131 &attachments,
2132 );
2133 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2134 let cache = self.state.config.cache_dir();
2135 let ctx = WaveCtx {
2136 carry_seats: false,
2137 run: run_id,
2138 node: "implement",
2139 prompts,
2140 cache: cache.as_deref(),
2141 round: None,
2142 };
2143 let (fallback_seat, fallback_out) = run_one(
2144 retry,
2145 Arc::clone(&self.sem),
2146 &ctx,
2147 &mut self.state,
2148 fallback_attempt,
2149 )
2150 .await;
2151 *seat = fallback_seat;
2152 *out = fallback_out;
2153 }
2154 }
2155 }
2156
2157 async fn resume_unconfirmed_commands(
2181 &mut self,
2182 results: &mut [(usize, SeatState, AgentOutcome)],
2183 sent: &[SeatJob],
2184 prompts: &Prompts,
2185 run_id: &str,
2186 ) {
2187 for (wi, seat, out) in results.iter_mut() {
2188 let AgentOutcome::Ok(o) = &*out else {
2189 continue;
2190 };
2191 if !has_unconfirmed_command(&o.commands) {
2192 continue;
2193 }
2194 let Some(job) = sent.get(*wi) else { continue };
2195 if !has_context(&job.spec, seat, job.sessions) {
2196 self.state.event(
2197 "implement",
2198 format!(
2199 "{}: the reply named a command whose own CLI never confirmed the exit \
2200 status of, but there is no session left to resume",
2201 seat.key
2202 ),
2203 );
2204 continue;
2205 }
2206 self.state.event(
2207 "implement",
2208 format!(
2209 "{}: the reply named a command whose own CLI never confirmed the exit \
2210 status of; resuming the conversation",
2211 seat.key
2212 ),
2213 );
2214 let mut retry = job.clone();
2215 retry.seat = seat.clone();
2216 retry.prompt = prompt::resume_incomplete(
2217 "a command in your last reply had no confirmed exit status",
2218 );
2219 retry.timeout = retry_budget(job.timeout, true);
2220 retry.stem = format!("{}-confirm", job.stem);
2221 let cache = self.state.config.cache_dir();
2222 let ctx = WaveCtx {
2223 carry_seats: false,
2224 run: run_id,
2225 node: "implement",
2226 prompts,
2227 cache: cache.as_deref(),
2228 round: None,
2229 };
2230 let (resumed_seat, resumed) =
2231 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2232 *seat = resumed_seat;
2233 *out = resumed;
2234 }
2235 }
2236
2237 async fn continue_fix_report(
2258 &mut self,
2259 mut seat: SeatState,
2260 parse_err: String,
2261 job: &SeatJob,
2262 prompts: &Prompts,
2263 run_id: &str,
2264 round: usize,
2265 ) -> (
2266 SeatState,
2267 Option<FixReport>,
2268 Option<String>,
2269 ContinuationRecord,
2270 ) {
2271 let mut last_err = parse_err;
2272 let mut cumulative_wait_ms = 0u64;
2273 let mut attempts = 0usize;
2274 loop {
2275 if !has_context(&job.spec, &seat, job.sessions) {
2276 self.state.event(
2277 "fix",
2278 format!(
2279 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2280 session left to resume into"
2281 ),
2282 );
2283 let outcome = if attempts == 0 {
2284 ContinuationOutcome::NoSession
2285 } else {
2286 ContinuationOutcome::Exhausted
2287 };
2288 return (
2289 seat,
2290 None,
2291 Some(format!("unparsable fix report: {last_err}")),
2292 ContinuationRecord {
2293 attempts,
2294 cumulative_wait_ms,
2295 outcome,
2296 },
2297 );
2298 }
2299 if attempts >= MAX_FIX_CONTINUATIONS {
2300 self.state.event(
2301 "fix",
2302 format!(
2303 "round {round}: fixer's reply still had no adoption report after \
2304 {attempts} continuation(s) ({last_err}); giving up"
2305 ),
2306 );
2307 return (
2308 seat,
2309 None,
2310 Some(format!(
2311 "unparsable fix report after {attempts} continuation(s): {last_err}"
2312 )),
2313 ContinuationRecord {
2314 attempts,
2315 cumulative_wait_ms,
2316 outcome: ContinuationOutcome::Exhausted,
2317 },
2318 );
2319 }
2320 attempts += 1;
2321 self.state.event(
2322 "fix",
2323 format!(
2324 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2325 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2326 ),
2327 );
2328 let mut retry = job.clone();
2329 retry.seat = seat.clone();
2330 retry.prompt = prompt::resume_incomplete(&last_err);
2331 retry.timeout = retry_budget(job.timeout, true);
2332 retry.stem = format!("{}-continue{attempts}", job.stem);
2333 let cache = self.state.config.cache_dir();
2334 let ctx = WaveCtx {
2335 carry_seats: false,
2336 run: run_id,
2337 node: "fix",
2338 prompts,
2339 cache: cache.as_deref(),
2340 round: Some(round),
2341 };
2342 let (resumed_seat, resumed_out) = run_one(
2343 retry,
2344 Arc::clone(&self.sem),
2345 &ctx,
2346 &mut self.state,
2347 attempts,
2348 )
2349 .await;
2350 seat = resumed_seat;
2351 match resumed_out {
2352 AgentOutcome::Ok(o) => {
2353 cumulative_wait_ms += o.duration_ms;
2354 match verdict::extract_json::<FixReport>(&o.text) {
2355 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2356 self.state.event(
2357 "fix",
2358 format!(
2359 "round {round}: fixer's adoption report recovered after \
2360 {attempts} continuation(s)"
2361 ),
2362 );
2363 return (
2364 seat,
2365 Some(report),
2366 None,
2367 ContinuationRecord {
2368 attempts,
2369 cumulative_wait_ms,
2370 outcome: ContinuationOutcome::Resumed,
2371 },
2372 );
2373 }
2374 Ok(_) => {
2382 last_err = "the reply parsed, but it reported a command whose own CLI \
2383 never confirmed an exit status"
2384 .to_owned();
2385 }
2386 Err(e) => last_err = e.to_string(),
2387 }
2388 }
2389 AgentOutcome::Quota(o) => {
2390 cumulative_wait_ms += o.duration_ms;
2391 self.state.quota.push(QuotaLoss {
2392 seat: seat.key.clone(),
2393 node: "fix".to_owned(),
2394 at: Timestamp::now(),
2395 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2396 });
2397 self.state.event(
2398 "fix",
2399 format!(
2400 "round {round}: continuation rate limited (quota); not retrying now"
2401 ),
2402 );
2403 return (
2404 seat,
2405 None,
2406 Some("rate limited (quota) while recovering the fix report".to_owned()),
2407 ContinuationRecord {
2408 attempts,
2409 cumulative_wait_ms,
2410 outcome: ContinuationOutcome::QuotaLost,
2411 },
2412 );
2413 }
2414 AgentOutcome::Dropped(o) => {
2415 cumulative_wait_ms += o.duration_ms;
2416 let why = o
2417 .dropped
2418 .as_ref()
2419 .map(|d| d.why.as_str())
2420 .unwrap_or("the CLI ended the stream without delivering its answer");
2421 last_err = format!("the CLI dropped the stream ({why})");
2422 }
2423 AgentOutcome::Failed(e) => last_err = e,
2424 }
2425 }
2426 }
2427
2428 fn after_implement(&mut self) -> Result<()> {
2429 if self.state.leaks.is_empty() {
2431 let cfg = self.state.config.blind.clone();
2432 let mut leaks = Vec::new();
2433 for c in &self.state.candidates {
2434 let Some(patch) =
2435 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2436 else {
2437 continue;
2438 };
2439 leaks.extend(blind::scan(
2440 &format!("candidate {} patch", c.label),
2441 &patch,
2442 &cfg.vendor_tokens,
2443 ));
2444 }
2445 if !leaks.is_empty() {
2446 let summary = leaks
2447 .iter()
2448 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2449 .collect::<Vec<_>>()
2450 .join(", ");
2451 match cfg.on_leak {
2452 LeakPolicy::Fail => {
2453 self.state.status = RunStatus::Failed;
2454 self.state
2455 .event("blind", format!("vendor text in a patch: {summary}"));
2456 self.state.leaks = leaks;
2457 self.state.save()?;
2458 self.settle_questions();
2459 bail!(
2460 "blind.on_leak = \"fail\" and vendor text reached a \
2461 judged patch: {summary}"
2462 );
2463 }
2464 LeakPolicy::Redact => self.state.event(
2465 "blind",
2466 format!("redacting vendor text for judging: {summary}"),
2467 ),
2468 LeakPolicy::Warn => self.state.event(
2469 "blind",
2470 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2471 ),
2472 }
2473 self.state.leaks = leaks;
2474 }
2475 }
2476
2477 if self.state.viable().is_empty() {
2478 if self.state.all_candidates_verified_noop() {
2479 self.state.status = RunStatus::VerifiedNoop;
2490 self.state.save()?;
2491 self.settle_questions();
2492 return Ok(());
2493 }
2494 self.state.status = RunStatus::Failed;
2495 self.state.save()?;
2496 self.settle_questions();
2497 bail!("no candidate produced a change; nothing to judge");
2498 }
2499 self.state.status = RunStatus::Judging;
2500 self.state.save()?;
2501 Ok(())
2502 }
2503
2504 async fn judge(&mut self) -> Result<()> {
2507 let run_id = self.state.id.clone();
2512 let prompts = self.state.config.prompts.clone();
2513 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2514 return Ok(());
2515 }
2516 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2517 if viable.len() == 1 {
2518 self.state.judge_skipped = true;
2525 self.state.event(
2526 "judge",
2527 format!(
2528 "only candidate {} produced a change; judging skipped",
2529 viable[0].label
2530 ),
2531 );
2532 self.state.save()?;
2533 return Ok(());
2534 }
2535 self.state.status = RunStatus::Judging;
2536
2537 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2538 let language = self.state.config.graph.language.clone();
2539 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2540 let sessions = self.state.config.graph.sessions;
2541 let artifacts = agent::artifacts_dir(&self.state.dir());
2542 let root = self.state.worktree_root();
2543 let base_short = short(&self.state.base_commit);
2544
2545 let mut jobs = Vec::new();
2546 let mut orders = Vec::new();
2547 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2548 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2549 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2550 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2551 let seat_key = format!("judge-{}", j + 1);
2552 let seat = self.seat(&seat_key, &spec.id);
2553 jobs.push(SeatJob {
2554 prompt: prompt::judge(
2555 &self.state.instruction,
2556 &views,
2557 self.roles.judges.len(),
2558 &base_short,
2559 &language,
2560 ),
2561 spec,
2562 seat,
2563 cwd: root.join(format!("judge-{}", j + 1)),
2564 timeout,
2565 allow_write: false,
2566 sessions,
2567 artifacts: artifacts.clone(),
2568 stem: format!("judge-{}", j + 1),
2569 handover: None,
2570 });
2571 }
2572
2573 self.state.event(
2574 "judge",
2575 format!(
2576 "{} judges ranking {} candidates blind",
2577 jobs.len(),
2578 viable.len()
2579 ),
2580 );
2581 let labels_for_check = labels.clone();
2582 let mut quota_losses = Vec::new();
2583 let cache = self.state.config.cache_dir();
2584 let ctx = WaveCtx {
2585 carry_seats: false,
2586 run: &run_id,
2587 node: "judge",
2588 prompts: &prompts,
2589 cache: cache.as_deref(),
2590 round: None,
2591 };
2592 let results = ask_json_wave::<Ranking>(
2593 jobs,
2594 Arc::clone(&self.sem),
2595 self.state.config.graph.retries,
2596 &self.roles.judge_roster,
2597 &ctx,
2598 &mut quota_losses,
2599 &mut self.state,
2600 &move |r: &Ranking| r.validate(&labels_for_check),
2601 )
2602 .await;
2603 self.state.quota.extend(quota_losses);
2604
2605 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2606 let agent_id = seat.agent.clone();
2607 self.state.seats.insert(seat.key.clone(), seat);
2608 let mut record = Judgement {
2609 judge: j + 1,
2610 seat: format!("judge-{}", j + 1),
2611 agent: agent_id,
2612 ranking: Vec::new(),
2613 reasons: BTreeMap::new(),
2614 confidence: None,
2615 order: orders[j].clone(),
2616 failed: None,
2617 duration_ms: 0,
2618 };
2619 match res {
2620 Ok((ranking, out)) => {
2621 record.ranking = ranking.normalized();
2622 record.reasons = ranking.reasons;
2623 record.confidence = ranking.confidence;
2624 record.duration_ms = out.duration_ms;
2625 self.state.event(
2626 "judge",
2627 format!(
2628 "judge {} ranked {}",
2629 j + 1,
2630 record.ranking.iter().collect::<String>()
2631 ),
2632 );
2633 }
2634 Err(e) => {
2635 record.failed = Some(e.to_string());
2636 self.state
2637 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2638 }
2639 }
2640 self.state.judgements.push(record);
2641 self.state.save()?;
2642 }
2643 Ok(())
2644 }
2645
2646 async fn deliberate(&mut self) -> Result<()> {
2649 let run_id = self.state.id.clone();
2654 let prompts = self.state.config.prompts.clone();
2655 if !self.state.deliberation.is_empty() {
2656 return Ok(());
2657 }
2658 let tops: Vec<char> = self
2659 .state
2660 .judgements
2661 .iter()
2662 .filter_map(|j| j.ranking.first().copied())
2663 .collect();
2664 let rounds = self.state.config.graph.deliberate_rounds;
2665 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2666 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2667 self.state.event(
2668 "deliberate",
2669 format!("judges agreed on {} outright; no deliberation", tops[0]),
2670 );
2671 }
2672 self.state.status = RunStatus::Voting;
2673 self.state.save()?;
2674 return Ok(());
2675 }
2676
2677 self.state.status = RunStatus::Deliberating;
2678 self.state.event(
2679 "deliberate",
2680 format!(
2681 "split: first choices were {} — opening {rounds} round(s)",
2682 tops.iter().collect::<String>()
2683 ),
2684 );
2685
2686 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2687 let language = self.state.config.graph.language.clone();
2688 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2689 let sessions = self.state.config.graph.sessions;
2690 let artifacts = agent::artifacts_dir(&self.state.dir());
2691 let root = self.state.worktree_root();
2692 let base_short = short(&self.state.base_commit);
2693
2694 for round in 1..=rounds {
2698 let mut turns: Vec<DeliberationTurn> = Vec::new();
2699 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2700 if self.state.judgements[j].failed.is_some() {
2701 continue;
2702 }
2703 let seat_key = format!("judge-{}", j + 1);
2704 let spec = self.occupant(&seat_key, spec);
2705 let mut seat = self.seat(&seat_key, &spec.id);
2706 let transcript = self.transcript(&turns, j);
2707 let build = |context: Option<&str>| {
2708 prompt::deliberate(
2709 &self.state.instruction,
2710 context,
2711 &transcript,
2712 round,
2713 rounds,
2714 &language,
2715 )
2716 };
2717 let block = self.candidate_block(&viable, &base_short);
2718 let full = build(Some(&block));
2719 let text = if has_context(&spec, &seat, sessions) {
2720 build(None)
2721 } else {
2722 full.clone()
2723 };
2724 let job = SeatJob {
2725 spec,
2726 seat: seat.clone(),
2727 prompt: text,
2728 cwd: root.join(format!("judge-{}", j + 1)),
2729 timeout,
2730 allow_write: false,
2731 sessions,
2732 artifacts: artifacts.clone(),
2733 stem: format!("delib-{round}-judge-{}", j + 1),
2734 handover: Some(full),
2735 };
2736 let cache = self.state.config.cache_dir();
2737 let ctx = WaveCtx {
2738 carry_seats: false,
2739 run: &run_id,
2740 node: "deliberate",
2741 prompts: &prompts,
2742 cache: cache.as_deref(),
2743 round: None,
2744 };
2745 let mut losses = Vec::new();
2749 let mut results = ask_wave_with::<String>(
2750 vec![job],
2751 Arc::clone(&self.sem),
2752 0,
2753 &self.roles.judge_roster,
2754 &ctx,
2755 &mut losses,
2756 &mut self.state,
2757 &|text: &str| {
2758 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2759 },
2760 )
2761 .await;
2762 self.state.quota.extend(losses);
2763 let (updated, res, _) = results.pop().expect("one job in, one result out");
2764 seat = updated;
2765 let agent_id = seat.agent.clone();
2766 self.state.seats.insert(seat.key.clone(), seat);
2767 let body = match res {
2768 Ok((body, _)) => body,
2769 Err(e) => {
2772 self.state
2773 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2774 continue;
2775 }
2776 };
2777 let tentative = verdict::extract_json::<Position>(&body)
2778 .ok()
2779 .and_then(|p| p.tentative)
2780 .and_then(|s| s.trim().chars().next())
2781 .map(|c| c.to_ascii_uppercase());
2782 self.state.event(
2783 "deliberate",
2784 format!(
2785 "round {round}: judge {} now favours {}",
2786 j + 1,
2787 tentative.map_or("—".to_owned(), |c| c.to_string())
2788 ),
2789 );
2790 turns.push(DeliberationTurn {
2791 judge: j + 1,
2792 agent: agent_id,
2793 body: blind::sanitize_prose(&body, &self.state.config.blind),
2794 tentative,
2795 });
2796 }
2797 self.state
2798 .deliberation
2799 .push(DeliberationRound { round, turns });
2800 self.state.save()?;
2801 }
2802
2803 self.state.status = RunStatus::Voting;
2804 self.state.save()?;
2805 Ok(())
2806 }
2807
2808 async fn vote(&mut self) -> Result<()> {
2811 let run_id = self.state.id.clone();
2816 let prompts = self.state.config.prompts.clone();
2817 if !self.state.votes.is_empty() {
2818 return Ok(());
2819 }
2820 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2821 if viable.len() == 1 {
2822 return Ok(());
2823 }
2824 self.state.status = RunStatus::Voting;
2825
2826 let language = self.state.config.graph.language.clone();
2827 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2828 let sessions = self.state.config.graph.sessions;
2829 let artifacts = agent::artifacts_dir(&self.state.dir());
2830 let root = self.state.worktree_root();
2831 let base_short = short(&self.state.base_commit);
2832 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2833
2834 let mut jobs = Vec::new();
2835 let mut seats_at = Vec::new();
2836 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2837 if self
2838 .state
2839 .judgements
2840 .get(j)
2841 .is_some_and(|r| r.failed.is_some())
2842 {
2843 continue;
2844 }
2845 let seat_key = format!("judge-{}", j + 1);
2846 let spec = self.occupant(&seat_key, spec);
2847 let seat = self.seat(&seat_key, &spec.id);
2848 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2849 let text = if has_context(&spec, &seat, sessions) {
2850 prompt::final_vote(&viable, &language)
2851 } else {
2852 full.clone()
2853 };
2854 jobs.push(SeatJob {
2855 spec,
2856 seat,
2857 prompt: text,
2858 cwd: root.join(format!("judge-{}", j + 1)),
2859 timeout,
2860 allow_write: false,
2861 sessions,
2862 artifacts: artifacts.clone(),
2863 stem: format!("vote-judge-{}", j + 1),
2864 handover: Some(full),
2865 });
2866 seats_at.push(j);
2867 }
2868
2869 self.state.event(
2870 "vote",
2871 format!(
2872 "collecting {} final votes one by one, privately",
2873 jobs.len()
2874 ),
2875 );
2876 let allowed = viable.clone();
2877 let mut quota_losses = Vec::new();
2878 let cache = self.state.config.cache_dir();
2879 let ctx = WaveCtx {
2880 carry_seats: false,
2881 run: &run_id,
2882 node: "vote",
2883 prompts: &prompts,
2884 cache: cache.as_deref(),
2885 round: None,
2886 };
2887 let results = ask_json_wave::<FinalVote>(
2888 jobs,
2889 Arc::clone(&self.sem),
2890 self.state.config.graph.retries,
2891 &self.roles.judge_roster,
2892 &ctx,
2893 &mut quota_losses,
2894 &mut self.state,
2895 &move |v: &FinalVote| match v.label() {
2896 Some(c) if allowed.contains(&c) => Ok(()),
2897 other => bail!("vote {other:?} is not one of {allowed:?}"),
2898 },
2899 )
2900 .await;
2901 self.state.quota.extend(quota_losses);
2902
2903 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2904 let agent_id = seat.agent.clone();
2905 self.state.seats.insert(seat.key.clone(), seat);
2906 let initial = self
2907 .state
2908 .judgements
2909 .get(j)
2910 .and_then(|r| r.ranking.first().copied());
2911 let mut record = VoteRecord {
2912 judge: j + 1,
2913 agent: agent_id,
2914 vote: None,
2915 reason: String::new(),
2916 changed: false,
2917 };
2918 match res {
2919 Ok((v, _)) => {
2920 record.vote = v.label();
2921 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2922 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2923 self.state.event(
2924 "vote",
2925 format!(
2926 "judge {} voted {}{}",
2927 j + 1,
2928 record.vote.unwrap_or('?'),
2929 if record.changed { " (changed)" } else { "" }
2930 ),
2931 );
2932 }
2933 Err(e) => {
2934 self.state
2935 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
2936 }
2937 }
2938 self.state.votes.push(record);
2939 self.state.save()?;
2940 }
2941 Ok(())
2942 }
2943
2944 fn tally(&mut self) -> Result<()> {
2947 if self.state.tally.is_some() {
2948 return Ok(());
2949 }
2950 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2951 let tops: Vec<char> = self
2952 .state
2953 .judgements
2954 .iter()
2955 .filter_map(|j| j.ranking.first().copied())
2956 .collect();
2957 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
2958
2959 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2962 let mut cast: Vec<char> = Vec::new();
2963 for (i, j) in self.state.judgements.iter().enumerate() {
2964 let vote = self
2965 .state
2966 .votes
2967 .iter()
2968 .find(|v| v.judge == i + 1)
2969 .and_then(|v| v.vote)
2970 .or_else(|| j.ranking.first().copied());
2971 if let Some(v) = vote {
2972 *first_choice.entry(v).or_insert(0) += 1;
2973 cast.push(v);
2974 }
2975 }
2976
2977 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2978 for j in &self.state.judgements {
2979 let n = j.ranking.len();
2980 for (pos, label) in j.ranking.iter().enumerate() {
2981 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
2982 }
2983 }
2984
2985 let best = first_choice.values().copied().max().unwrap_or(0);
2986 let mut leaders: Vec<char> = first_choice
2987 .iter()
2988 .filter(|(_, v)| **v == best)
2989 .map(|(k, _)| *k)
2990 .collect();
2991 let mut tie_break = None;
2992 if leaders.len() > 1 {
2993 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
2994 let borda_leaders: Vec<char> = leaders
2995 .iter()
2996 .copied()
2997 .filter(|l| borda[l] == top_borda)
2998 .collect();
2999 tie_break = Some(if borda_leaders.len() == 1 {
3000 format!(
3001 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3002 leaders.len()
3003 )
3004 } else {
3005 format!(
3006 "{} way tie on both first-choice votes and Borda points, broken by label order",
3007 leaders.len()
3008 )
3009 });
3010 leaders = borda_leaders;
3011 leaders.sort_unstable();
3012 }
3013 let winner = *leaders
3014 .first()
3015 .or(viable.first())
3016 .context("no candidate to declare a winner from")?;
3017
3018 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3019 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3020 let deliberated = !self.state.deliberation.is_empty();
3021
3022 let quota_seats: std::collections::BTreeSet<&str> =
3026 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3027 let mut present = 0usize;
3028 for (i, j) in self.state.judgements.iter().enumerate() {
3029 if quota_seats.contains(j.seat.as_str()) {
3030 continue;
3031 }
3032 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3033 let voted = self
3034 .state
3035 .votes
3036 .iter()
3037 .any(|v| v.judge == i + 1 && v.vote.is_some());
3038 if ranked || voted {
3039 present += 1;
3040 }
3041 }
3042 let needs_quorum = viable.len() > 1;
3048 let judges_total = if needs_quorum {
3049 self.roles.judges.len()
3050 } else {
3051 0
3052 };
3053 let quorum = if needs_quorum {
3054 judges_total / 2 + 1
3055 } else {
3056 0
3057 };
3058 let met_quorum = !needs_quorum || present >= quorum;
3059 let uncontested = (!needs_quorum).then(|| {
3060 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3061 });
3062
3063 self.state.event(
3064 "tally",
3065 match &uncontested {
3066 Some(reason) => format!("winner {winner} — {reason}"),
3067 None => format!(
3068 "winner {winner} — votes {} | initial {} | {} changed | \
3069 {present}/{judges_total} judges{}",
3070 first_choice
3071 .iter()
3072 .map(|(k, v)| format!("{k}:{v}"))
3073 .collect::<Vec<_>>()
3074 .join(" "),
3075 if unanimous_initial {
3076 "unanimous"
3077 } else {
3078 "split"
3079 },
3080 changed_votes,
3081 if met_quorum {
3082 String::new()
3083 } else {
3084 format!(" — below quorum ({quorum} required)")
3085 },
3086 ),
3087 },
3088 );
3089 if !met_quorum {
3090 self.state.event(
3091 "stall",
3092 format!(
3093 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3094 the run stops here, resumable"
3095 ),
3096 );
3097 }
3098 self.state.tally = Some(Tally {
3099 first_choice,
3100 borda,
3101 winner,
3102 rankings: tops.len(),
3103 unanimous_initial,
3104 deliberated,
3105 changed_votes,
3106 unanimous_final,
3107 tie_break,
3108 judges: judges_total,
3109 present,
3110 quorum,
3111 met_quorum,
3112 uncontested,
3113 });
3114 self.state.status = if met_quorum {
3115 RunStatus::Reviewing
3116 } else {
3117 RunStatus::Stalled
3118 };
3119 self.state.save()?;
3120 Ok(())
3121 }
3122
3123 #[allow(clippy::too_many_lines)]
3144 async fn recover_stall(&mut self) -> Result<bool> {
3145 let run_id = self.state.id.clone();
3150 let prompts = self.state.config.prompts.clone();
3151 let quota_seats: BTreeSet<&str> =
3156 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3157 let absent: Vec<String> = self
3158 .state
3159 .judgements
3160 .iter()
3161 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3162 .map(|j| j.seat.clone())
3163 .collect();
3164 if absent.is_empty() {
3165 return Ok(false);
3166 }
3167 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3168 if viable.len() <= 1 {
3169 return Ok(false);
3170 }
3171 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3172 let language = self.state.config.graph.language.clone();
3173 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3174 let sessions = self.state.config.graph.sessions;
3175 let artifacts = agent::artifacts_dir(&self.state.dir());
3176 let root = self.state.worktree_root();
3177 let base_short = short(&self.state.base_commit);
3178 let candidates: Vec<Candidate> = viable.clone();
3179
3180 let mut positions: Vec<usize> = absent
3182 .iter()
3183 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3184 .collect();
3185 if positions.is_empty() {
3186 return Ok(false);
3187 }
3188 positions.sort_unstable();
3189 positions.dedup();
3190
3191 let mut judge_jobs = Vec::new();
3193 for &j in &positions {
3194 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3195 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3196 let seat_key = format!("judge-{}", j + 1);
3197 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3198 let seat = self.seat(&seat_key, &spec.id);
3199 judge_jobs.push(SeatJob {
3200 spec,
3201 seat,
3202 prompt: prompt::judge(
3203 &self.state.instruction,
3204 &views,
3205 self.roles.judges.len(),
3206 &base_short,
3207 &language,
3208 ),
3209 cwd: root.join(seat_key),
3210 timeout,
3211 allow_write: false,
3212 sessions,
3213 artifacts: artifacts.clone(),
3214 stem: format!("judge-{}-recover", j + 1),
3215 handover: None,
3216 });
3217 }
3218
3219 let labels_for_check = labels.clone();
3220 let mut judge_losses = Vec::new();
3221 let retries = self.state.config.graph.retries;
3222 let cache = self.state.config.cache_dir();
3223 let ctx = WaveCtx {
3224 carry_seats: false,
3225 run: &run_id,
3226 node: "judge",
3227 prompts: &prompts,
3228 cache: cache.as_deref(),
3229 round: None,
3230 };
3231 let results = ask_json_wave::<Ranking>(
3232 judge_jobs,
3233 Arc::clone(&self.sem),
3234 retries,
3235 &self.roles.judge_roster,
3236 &ctx,
3237 &mut judge_losses,
3238 &mut self.state,
3239 &move |r: &Ranking| r.validate(&labels_for_check),
3240 )
3241 .await;
3242
3243 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3245 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3246 let agent_id = seat.agent.clone();
3247 self.state.seats.insert(seat.key.clone(), seat);
3248 let record = &mut self.state.judgements[j];
3249 match res {
3250 Ok((ranking, out)) => {
3251 record.agent = agent_id;
3252 record.ranking = ranking.normalized();
3253 record.reasons = ranking.reasons;
3254 record.confidence = ranking.confidence;
3255 record.failed = None;
3256 record.duration_ms = out.duration_ms;
3257 recovered.insert(j);
3258 self.state.event(
3259 "recover",
3260 format!("judge {} ranked again after the limit", j + 1),
3261 );
3262 }
3263 Err(e) => {
3264 self.state
3265 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3266 }
3267 }
3268 }
3269
3270 let mut vote_jobs = Vec::new();
3272 let mut vote_pos: Vec<usize> = Vec::new();
3273 for &j in &recovered {
3274 let seat_key = format!("judge-{}", j + 1);
3275 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3276 let seat = self.seat(&seat_key, &spec.id);
3277 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3278 let text = if has_context(&spec, &seat, sessions) {
3279 prompt::final_vote(&labels, &language)
3280 } else {
3281 full.clone()
3282 };
3283 vote_jobs.push(SeatJob {
3284 spec,
3285 seat,
3286 prompt: text,
3287 cwd: root.join(seat_key),
3288 timeout,
3289 allow_write: false,
3290 sessions,
3291 artifacts: artifacts.clone(),
3292 stem: format!("vote-judge-{}-recover", j + 1),
3293 handover: Some(full),
3294 });
3295 vote_pos.push(j);
3296 }
3297 let allowed = labels.clone();
3298 let mut vote_losses = Vec::new();
3299 let vote_retries = self.state.config.graph.retries;
3300 let vote_cache = self.state.config.cache_dir();
3301 let ctx = WaveCtx {
3302 carry_seats: false,
3303 run: &run_id,
3304 node: "vote",
3305 prompts: &prompts,
3306 cache: vote_cache.as_deref(),
3307 round: None,
3308 };
3309 let votes = ask_json_wave::<FinalVote>(
3310 vote_jobs,
3311 Arc::clone(&self.sem),
3312 vote_retries,
3313 &self.roles.judge_roster,
3314 &ctx,
3315 &mut vote_losses,
3316 &mut self.state,
3317 &move |v: &FinalVote| match v.label() {
3318 Some(c) if allowed.contains(&c) => Ok(()),
3319 other => bail!("vote {other:?} is not one of {allowed:?}"),
3320 },
3321 )
3322 .await;
3323 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3324 let agent_id = seat.agent.clone();
3325 self.state.seats.insert(seat.key.clone(), seat);
3326 match res {
3327 Ok((v, _)) => {
3328 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3329 rec.vote = v.label();
3330 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3331 } else {
3332 self.state.votes.push(VoteRecord {
3333 judge: j + 1,
3334 agent: agent_id,
3335 vote: v.label(),
3336 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3337 changed: false,
3338 });
3339 }
3340 self.state.event(
3341 "recover",
3342 format!("judge {} voted again after the limit", j + 1),
3343 );
3344 }
3345 Err(e) => {
3346 self.state
3347 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3348 }
3349 }
3350 }
3351
3352 let recovered_keys: BTreeSet<String> = recovered
3356 .iter()
3357 .map(|&j| format!("judge-{}", j + 1))
3358 .collect();
3359 self.state
3360 .quota
3361 .retain(|q| !recovered_keys.contains(&q.seat));
3362 for loss in judge_losses.into_iter().chain(vote_losses) {
3366 if recovered_keys.contains(&loss.seat) {
3367 continue;
3368 }
3369 self.state.quota.retain(|q| q.seat != loss.seat);
3370 self.state.quota.push(loss);
3371 }
3372
3373 self.state.tally = None;
3375 self.tally()?;
3376 Ok(self
3377 .state
3378 .tally
3379 .as_ref()
3380 .map(|t| t.met_quorum)
3381 .unwrap_or(false))
3382 }
3383
3384 async fn fold_losers(&mut self) -> Result<()> {
3387 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3388 return Ok(());
3389 };
3390 let repo = self.state.repo.clone();
3391 let mut folded = Vec::new();
3392 for i in 0..self.state.candidates.len() {
3393 let c = &self.state.candidates[i];
3394 if c.label == winner || c.folded {
3395 continue;
3396 }
3397 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3398 git::worktree_remove(&repo, &wt).await.ok();
3399 git::branch_delete(&repo, &branch).await.ok();
3400 self.state.candidates[i].folded = true;
3401 folded.push(label.to_string());
3402 }
3403 let root = self.state.worktree_root();
3405 for j in 1..=self.roles.judges.len() {
3406 let wt = root.join(format!("judge-{j}"));
3407 if wt.exists() {
3408 git::worktree_remove(&repo, &wt).await.ok();
3409 }
3410 }
3411 if self.state.config.graph.advise {
3414 for k in 1..=self.state.config.graph.advisors {
3415 let wt = root.join(format!("advisor-{k}"));
3416 if wt.exists() {
3417 git::worktree_remove(&repo, &wt).await.ok();
3418 }
3419 }
3420 }
3421 if !folded.is_empty() {
3422 self.state
3423 .event("fold", format!("folded candidates {}", folded.join(", ")));
3424 self.state.save()?;
3425 }
3426 Ok(())
3427 }
3428
3429 async fn sync_to_base(&mut self) -> Result<()> {
3469 if self.state.status == RunStatus::AlreadyInBase {
3470 return Ok(());
3471 }
3472 let conflicted = self
3473 .state
3474 .base_sync
3475 .as_ref()
3476 .is_some_and(|s| s.conflict.is_some());
3477 let Some(winner) = self.state.winner().cloned() else {
3478 return Ok(());
3479 };
3480
3481 let repo = self.state.repo.clone();
3482 let remote = self.state.config.merge.remote.clone();
3483 let base_branch = self.state.base_branch.clone();
3484 let tracking = format!("{remote}/{base_branch}");
3485
3486 git::fetch(&repo, &remote, &base_branch).await.ok();
3487 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3491 return Ok(());
3492 };
3493
3494 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3495 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3496 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3497
3498 if (behind > 0 || conflicted || head == tip)
3507 && self
3508 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3509 .await?
3510 {
3511 return Ok(());
3512 }
3513 if conflicted {
3514 return Ok(());
3515 }
3516
3517 if behind == 0 {
3518 if let Some(from) = self
3527 .state
3528 .rebase_fixes
3529 .iter()
3530 .rev()
3531 .find_map(|r| r.from.clone())
3532 && from != head
3533 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3534 .await
3535 .is_ok_and(|o| o.ok())
3536 {
3537 git::sync_to_head(&winner.worktree).await?;
3538 }
3539 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3545 if let Some(why) = &conflict {
3546 self.state.status = RunStatus::Blocked;
3547 self.state.event("land", why.clone());
3548 }
3549 self.state.base_sync = Some(BaseSync {
3550 tip,
3551 behind: 0,
3552 attempts,
3553 conflict,
3554 already_in: None,
3555 });
3556 self.state.save()?;
3557 return Ok(());
3558 }
3559
3560 if attempts >= BASE_SYNC_ROUNDS {
3561 let why = format!(
3562 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3563 rebase(s); rebasing again would only race it",
3564 winner.branch
3565 );
3566 self.state.status = RunStatus::Blocked;
3567 self.state.base_sync = Some(BaseSync {
3568 tip,
3569 behind,
3570 attempts,
3571 conflict: Some(why.clone()),
3572 already_in: None,
3573 });
3574 self.state.event("land", why);
3575 self.state.save()?;
3576 return Ok(());
3577 }
3578
3579 self.state.event(
3580 "land",
3581 format!(
3582 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3583 winner.branch
3584 ),
3585 );
3586 self.state.save()?;
3587
3588 let branch_tracking = format!("{remote}/{}", winner.branch);
3594 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3595 let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3596 git::rev_parse(&repo, &branch_tracking).await.ok()
3597 } else {
3598 None
3599 };
3600 if let Some(theirs) = &remote_tip
3604 && !git::is_ancestor(&repo, theirs, &head).await
3605 && !crate::reconcile::origin_missing(&repo, &head, theirs)
3606 .await
3607 .is_ok_and(|missing| missing.is_empty())
3608 {
3609 let why = format!(
3610 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3611 them",
3612 short(theirs),
3613 winner.branch
3614 );
3615 self.state.status = RunStatus::Blocked;
3616 self.state.base_sync = Some(BaseSync {
3617 tip,
3618 behind,
3619 attempts,
3620 conflict: Some(why.clone()),
3621 already_in: None,
3622 });
3623 self.state.event("land", why);
3624 self.state.save()?;
3625 return Ok(());
3626 }
3627
3628 let scratch = self.state.dir().join("base-sync");
3629 let rebased = match crate::rebase::rebase_with_fixer(
3630 &mut self.state,
3631 &scratch,
3632 &winner.branch,
3633 &tracking,
3634 )
3635 .await
3636 {
3637 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3638 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3639 Err(e) => Err(e),
3640 };
3641 let attempts = attempts + 1;
3642 match rebased {
3643 Ok(None) => {
3644 git::sync_to_head(&winner.worktree).await?;
3648 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3649 let mut conflict = None;
3650 if let Some(pinned) = &remote_tip {
3651 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3652 match pushed {
3653 Ok(o) if o.ok() => self.state.event(
3654 "land",
3655 format!("pushed rebased {} to {remote}", winner.branch),
3656 ),
3657 Ok(o) => {
3658 conflict = Some(format!(
3659 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
3660 winner.branch,
3661 short(pinned),
3662 o.stderr.chars().take(600).collect::<String>()
3663 ));
3664 }
3665 Err(e) => {
3666 conflict = Some(format!(
3667 "rebased {} locally but could not push it: {e:#}",
3668 winner.branch
3669 ));
3670 }
3671 }
3672 }
3673 if let Some(why) = &conflict {
3674 self.state.status = RunStatus::Blocked;
3675 self.state.event("land", why.clone());
3676 }
3677 self.state.base_sync = Some(BaseSync {
3678 tip: tip.clone(),
3679 behind: 0,
3680 attempts,
3681 conflict,
3682 already_in: None,
3683 });
3684 self.state
3685 .event("land", format!("rebased {} onto {tracking}", winner.branch));
3686 }
3687 Ok(Some(conflict)) => {
3688 let why = format!(
3689 "{} conflicts with {tracking} and did not rebase: {}",
3690 winner.branch,
3691 conflict.chars().take(600).collect::<String>()
3692 );
3693 self.state.status = RunStatus::Blocked;
3694 self.state.base_sync = Some(BaseSync {
3695 tip,
3696 behind,
3697 attempts,
3698 conflict: Some(why.clone()),
3699 already_in: None,
3700 });
3701 self.state.event("land", why);
3702 }
3703 Err(e) => {
3704 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3705 self.state.status = RunStatus::Blocked;
3706 self.state.base_sync = Some(BaseSync {
3707 tip,
3708 behind,
3709 attempts,
3710 conflict: Some(why.clone()),
3711 already_in: None,
3712 });
3713 self.state.event("land", why);
3714 }
3715 }
3716 self.state.save()?;
3717 Ok(())
3718 }
3719
3720 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3726 let repo = self.state.repo.clone();
3727 let remote = self.state.config.merge.remote.clone();
3728 let fetched = git::fetch(&repo, &remote, branch).await;
3729 if !matches!(&fetched, Ok(o) if o.ok()) {
3730 return None;
3731 }
3732 let branch_tracking = format!("{remote}/{branch}");
3733 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3734 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3735 return None;
3736 }
3737 if !crate::reconcile::origin_missing(&repo, head, &theirs)
3738 .await
3739 .is_ok_and(|missing| missing.is_empty())
3740 {
3741 return Some(format!(
3742 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3743 them",
3744 short(&theirs)
3745 ));
3746 }
3747 match git::push_pinned(&repo, &remote, branch, &theirs).await {
3748 Ok(o) if o.ok() => {
3749 self.state
3750 .event("land", format!("pushed rebased {branch} to {remote}"));
3751 None
3752 }
3753 Ok(o) => Some(format!(
3754 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3755 someone may have pushed): {}",
3756 short(&theirs),
3757 o.stderr.chars().take(600).collect::<String>()
3758 )),
3759 Err(e) => Some(format!(
3760 "{branch} is rebased locally but could not be pushed: {e:#}"
3761 )),
3762 }
3763 }
3764
3765 async fn settle_already_in(
3782 &mut self,
3783 branch: &str,
3784 tip: &str,
3785 head: &str,
3786 attempts: usize,
3787 behind: usize,
3788 ) -> Result<bool> {
3789 let repo = self.state.repo.clone();
3790 let remote = self.state.config.merge.remote.clone();
3791 let start = self.state.base_commit.clone();
3792 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3793 Ok(Some(e)) => e,
3794 Ok(None) => return Ok(false),
3795 Err(e) => {
3796 tracing::warn!("already-in-base check for {branch}: {e:#}");
3797 return Ok(false);
3798 }
3799 };
3800 let mut verified = vec![head.to_owned()];
3801 let fetched = git::fetch(&repo, &remote, branch).await;
3802 if matches!(&fetched, Ok(o) if o.ok())
3803 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3804 && theirs != head
3805 {
3806 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3807 Ok(Some(_)) => verified.push(theirs),
3808 _ => return Ok(false),
3809 }
3810 }
3811 let base_branch = self.state.base_branch.clone();
3812 let message = format!(
3813 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3814 land",
3815 evidence.names(),
3816 evidence.proof.as_str()
3817 );
3818 let closed =
3819 crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3820 match closed {
3821 Ok(Ok(url)) => self
3822 .state
3823 .event("land", format!("closed {url}: superseded on {base_branch}")),
3824 Ok(Err(why)) => self
3825 .state
3826 .event("land", format!("did not close a pull request: {why}")),
3827 Err(e) => {
3828 let why = format!(
3829 "{branch} is already in {remote}/{base_branch}, but its pull request could \
3830 not be closed ({e:#}); resume to retry"
3831 );
3832 self.state.status = RunStatus::Blocked;
3833 self.state.base_sync = Some(BaseSync {
3834 tip: tip.to_owned(),
3835 behind,
3836 attempts,
3837 conflict: Some(why.clone()),
3838 already_in: None,
3839 });
3840 self.state.event("land", why);
3841 self.state.save()?;
3842 return Ok(true);
3843 }
3844 }
3845 self.state.status = RunStatus::AlreadyInBase;
3846 self.state.base_sync = Some(BaseSync {
3847 tip: tip.to_owned(),
3848 behind,
3849 attempts,
3850 conflict: None,
3851 already_in: Some(evidence),
3852 });
3853 self.state.event("land", message);
3854 self.state.save()?;
3855 self.settle_questions();
3856 Ok(true)
3857 }
3858
3859 fn landing_base(&self) -> String {
3869 self.state
3870 .base_sync
3871 .as_ref()
3872 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3873 }
3874
3875 pub async fn fix_selected(
3908 &mut self,
3909 ids: &[String],
3910 reason: &str,
3911 allow_stale: bool,
3912 ) -> Result<()> {
3913 let reason = reason.trim();
3914 if reason.is_empty() {
3915 bail!("a fix request needs a reason — that is the operator's own record of why");
3916 }
3917 if ids.is_empty() {
3918 bail!("no finding id given");
3919 }
3920 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3921 bail!(
3922 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3923 has already concluded — can be given a targeted fix. A run still \
3924 in progress should simply be resumed; a `merged` run's branch has \
3925 already landed, so its answer is a fresh `magi review <branch>`, \
3926 not reopening this run's own record",
3927 self.state.id,
3928 self.state.status.as_str()
3929 );
3930 }
3931 let Some(winner) = self.state.winner().cloned() else {
3932 bail!("run {} has no winning candidate to fix", self.state.id);
3933 };
3934 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
3935 bail!(
3936 "branch `{}` no longer exists; this run cannot be extended",
3937 winner.branch
3938 );
3939 }
3940 let home = crate::run::home();
3941 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
3942 bail!(
3943 "run {} is currently being worked on by another magi process",
3944 self.state.id
3945 );
3946 }
3947 let _claim = FixClaim::acquire(&self.state.dir())?;
3953
3954 let mut seen = BTreeSet::new();
3958 let mut findings = Vec::new();
3959 let mut missing = Vec::new();
3960 for id in ids {
3961 if !seen.insert(id.clone()) {
3962 continue;
3963 }
3964 match self.state.finding(id) {
3965 Some((round, rec, f)) => findings.push(OperatorFixFinding {
3966 id: f.id.clone(),
3967 severity: f.severity,
3968 reviewer_vote: rec.vote,
3969 round: round.round,
3970 round_head: round.head.clone(),
3971 reviewer: rec.reviewer,
3972 agent: rec.agent.clone(),
3973 file: f.file.clone(),
3974 line: f.line,
3975 title: f.title.clone(),
3976 detail: f.detail.clone(),
3977 outcome: OperatorFixOutcome::Pending,
3978 }),
3979 None => missing.push(id.clone()),
3980 }
3981 }
3982 if !missing.is_empty() {
3983 bail!(
3984 "unknown finding id(s): {}; nothing was changed",
3985 missing.join(", ")
3986 );
3987 }
3988
3989 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
3990 let stale_details: Vec<(String, String)> = findings
3991 .iter()
3992 .filter(|f| f.round_head != head_at_request)
3993 .map(|f| (f.id.clone(), f.round_head.clone()))
3994 .collect();
3995 let stale = !stale_details.is_empty();
3996 if stale && !allow_stale {
3997 bail!(
3998 "the branch has moved since some finding(s) were raised — {} — now \
3999 at {}; pass --allow-stale to fix anyway, or re-run review first",
4000 stale_details
4001 .iter()
4002 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4003 .collect::<Vec<_>>()
4004 .join(", "),
4005 short(&head_at_request)
4006 );
4007 }
4008
4009 let request = OperatorFixRequest {
4010 requested_at: Timestamp::now(),
4011 reason: reason.to_owned(),
4012 findings,
4013 head_at_request: head_at_request.clone(),
4014 allow_stale,
4015 stale,
4016 fix: None,
4017 result_head: None,
4018 follow_up_review_run: None,
4019 };
4020 self.state.event(
4021 "fix",
4022 format!(
4023 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4024 request.findings.len(),
4025 request
4026 .findings
4027 .iter()
4028 .map(|f| f.id.as_str())
4029 .collect::<Vec<_>>()
4030 .join(", "),
4031 ),
4032 );
4033 self.state.operator_fixes.push(request);
4040 self.state.save()?;
4041 let request_index = self.state.operator_fixes.len() - 1;
4042
4043 if winner.worktree.exists() {
4052 let dirty = git::git(
4055 &winner.worktree,
4056 &["status", "--porcelain", "--untracked-files=all"],
4057 )
4058 .await?;
4059 let only_withheld = dirty.lines().all(|l| {
4060 l.strip_prefix("?? ")
4061 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4062 });
4063 if !only_withheld {
4064 bail!(
4065 "`{}` has uncommitted changes; refusing to touch it — commit or \
4066 discard them first",
4067 winner.worktree.display()
4068 );
4069 }
4070 git::worktree_remove(&self.state.repo, &winner.worktree)
4071 .await
4072 .ok();
4073 }
4074 let fix_worktree = self.state.worktree_root().join("operator-fix");
4075 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4076 git::git(
4077 &self.state.repo,
4078 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4079 )
4080 .await
4081 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4082 if !git::is_clean(&fix_worktree).await? {
4083 git::worktree_remove(&self.state.repo, &fix_worktree)
4084 .await
4085 .ok();
4086 bail!(
4087 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4088 winner.branch
4089 );
4090 }
4091
4092 let run_id = self.state.id.clone();
4093 let prompts = self.state.config.prompts.clone();
4094 let language = self.state.config.graph.language.clone();
4095 let sessions = self.state.config.graph.sessions;
4096 let artifacts = agent::artifacts_dir(&self.state.dir());
4097 let (fix_spec, fix_seat_key) = match &self.roles.fixer {
4098 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4099 _ => (
4100 self.state
4101 .config
4102 .agent(&winner.agent)
4103 .cloned()
4104 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4105 format!("impl-{}", winner.label),
4106 ),
4107 };
4108 let seat = self.seat(&fix_seat_key, &fix_spec.id);
4109 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4110 .findings
4111 .iter()
4112 .map(|f| Finding {
4113 id: f.id.clone(),
4114 severity: f.severity,
4115 file: f.file.clone(),
4116 line: f.line,
4117 title: f.title.clone(),
4118 detail: f.detail.clone(),
4119 })
4120 .collect();
4121 let job = SeatJob {
4122 prompt: prompt::operator_fix(
4123 &self.state.instruction,
4124 &finding_list,
4125 reason,
4126 &stale_details,
4127 &head_at_request,
4128 &language,
4129 ),
4130 spec: fix_spec.clone(),
4131 seat,
4132 cwd: fix_worktree.clone(),
4133 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
4134 allow_write: true,
4135 sessions,
4136 artifacts: artifacts.clone(),
4137 stem: "operator-fix".to_owned(),
4138 handover: None,
4139 };
4140 let cache = self.state.config.cache_dir();
4141 let ctx = WaveCtx {
4142 carry_seats: false,
4143 run: &run_id,
4144 node: "fix",
4145 prompts: &prompts,
4146 cache: cache.as_deref(),
4147 round: None,
4148 };
4149 let (seat, out) =
4150 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4151 let agent_id = seat.agent.clone();
4152
4153 let mut fix = FixRecord {
4154 agent: agent_id,
4155 addressed: Vec::new(),
4156 rejected: Vec::new(),
4157 notes: String::new(),
4158 committed: false,
4159 failed: None,
4160 duration_ms: 0,
4161 continuation: None,
4162 };
4163 let mut final_seat = seat.clone();
4164 match out {
4165 AgentOutcome::Ok(o) => {
4166 fix.duration_ms = o.duration_ms;
4167 let parsed = verdict::extract_json::<FixReport>(&o.text);
4168 let incomplete_reason = match &parsed {
4169 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4170 "the reply parsed, but it reported a command whose own CLI \
4171 never confirmed an exit status"
4172 .to_owned(),
4173 ),
4174 Ok(_) => None,
4175 Err(e) => Some(e.to_string()),
4176 };
4177 match incomplete_reason {
4178 None => {
4179 let report = parsed.expect("checked Ok above");
4180 fix.addressed = report.addressed;
4181 fix.rejected = report.rejected;
4182 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4183 }
4184 Some(reason) => {
4185 let (resumed_seat, resolved, failure, cont) = self
4186 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4187 .await;
4188 fix.duration_ms += cont.cumulative_wait_ms;
4189 fix.continuation = Some(cont);
4190 final_seat = resumed_seat;
4191 match resolved {
4192 Some(report) => {
4193 fix.addressed = report.addressed;
4194 fix.rejected = report.rejected;
4195 fix.notes =
4196 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4197 }
4198 None => fix.failed = failure,
4199 }
4200 }
4201 }
4202 }
4203 AgentOutcome::Dropped(o) => {
4204 fix.duration_ms = o.duration_ms;
4205 let why = o
4206 .dropped
4207 .as_ref()
4208 .map(|d| d.why.as_str())
4209 .unwrap_or("the CLI ended the stream without delivering its answer");
4210 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4211 }
4212 AgentOutcome::Quota(o) => {
4213 self.state.quota.push(QuotaLoss {
4214 seat: final_seat.key.clone(),
4215 node: "fix".to_owned(),
4216 at: Timestamp::now(),
4217 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4218 });
4219 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4220 }
4221 AgentOutcome::Failed(e) => fix.failed = Some(e),
4222 }
4223 if fix.continuation.is_none() {
4224 fix.continuation = Some(ContinuationRecord::not_needed());
4225 }
4226 self.state.seats.insert(final_seat.key.clone(), final_seat);
4227
4228 let rescue_message = format!(
4229 "magi: operator-selected fix ({}) (uncommitted work)",
4230 self.state.operator_fixes[request_index]
4231 .findings
4232 .iter()
4233 .map(|f| f.id.as_str())
4234 .collect::<Vec<_>>()
4235 .join(", ")
4236 );
4237 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4238 self.state.note_withheld("fix", &r.withheld);
4239 }
4240 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4241 fix.committed = after != head_at_request;
4242 git::worktree_remove(&self.state.repo, &fix_worktree)
4243 .await
4244 .ok();
4245
4246 self.state.event(
4247 "fix",
4248 match &fix.failed {
4249 Some(reason) => format!(
4250 "operator fix: adoption report was lost ({reason}); {}",
4251 if fix.committed {
4252 "committed"
4253 } else {
4254 "NO new commit"
4255 }
4256 ),
4257 None => format!(
4258 "operator fix: {} addressed, {} rejected, {}",
4259 fix.addressed.len(),
4260 fix.rejected.len(),
4261 if fix.committed {
4262 "committed"
4263 } else {
4264 "NO new commit"
4265 }
4266 ),
4267 },
4268 );
4269
4270 for f in &mut self.state.operator_fixes[request_index].findings {
4277 f.outcome = if fix.failed.is_some() {
4278 OperatorFixOutcome::Unreported
4279 } else if fix.addressed.contains(&f.id) {
4280 OperatorFixOutcome::Addressed
4281 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4282 OperatorFixOutcome::Rejected { why: r.why.clone() }
4283 } else {
4284 OperatorFixOutcome::Unreported
4285 };
4286 }
4287
4288 let committed = fix.committed;
4289 if committed {
4290 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4291 }
4292 self.state.operator_fixes[request_index].fix = Some(fix);
4293 self.state.save()?;
4296
4297 if committed {
4298 self.state.event(
4299 "fix",
4300 format!(
4301 "operator fix committed {}; opening a follow-up review-only run",
4302 short(&after)
4303 ),
4304 );
4305 let origin =
4308 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4309 match Self::review(
4310 &self.state.repo,
4311 &winner.branch,
4312 self.state.config.clone(),
4313 origin,
4314 )
4315 .await
4316 {
4317 Ok(mut follow_up) => {
4318 follow_up.state.event(
4319 "start",
4320 format!(
4321 "requested by an operator fix on run {} for finding(s) {}",
4322 self.state.id,
4323 self.state.operator_fixes[request_index]
4324 .findings
4325 .iter()
4326 .map(|f| f.id.as_str())
4327 .collect::<Vec<_>>()
4328 .join(", "),
4329 ),
4330 );
4331 follow_up.state.save()?;
4332 let follow_up_id = follow_up.state.id.clone();
4333 let adopted = match crate::direct::adopt(
4336 &follow_up.state,
4337 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4338 ) {
4339 Ok(a) => a,
4340 Err(e) => {
4341 self.state.event(
4344 "fix",
4345 format!(
4346 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4347 ),
4348 );
4349 self.state.save()?;
4350 return Ok(());
4351 }
4352 };
4353 let executed = follow_up.execute().await;
4354 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4355 if let Some(a) = adopted {
4356 a.finish(&follow_up.state, executed);
4357 }
4358 if let Some(e) = failure {
4359 self.state.event(
4360 "fix",
4361 format!(
4362 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4363 ),
4364 );
4365 }
4366 self.state.operator_fixes[request_index].follow_up_review_run =
4367 Some(follow_up_id);
4368 }
4369 Err(e) => {
4370 self.state.event(
4371 "fix",
4372 format!("committed the fix but could not open a follow-up review: {e:#}"),
4373 );
4374 }
4375 }
4376 self.state.save()?;
4377 }
4378
4379 Ok(())
4380 }
4381
4382 fn fixer_spec(&self, winner: &Candidate) -> (AgentSpec, String) {
4389 match &self.roles.fixer {
4390 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4391 _ => (
4392 self.state
4393 .config
4394 .agent(&winner.agent)
4395 .cloned()
4396 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4397 format!("impl-{}", winner.label),
4398 ),
4399 }
4400 }
4401
4402 async fn review_loop(&mut self) -> Result<()> {
4403 if self
4408 .state
4409 .base_sync
4410 .as_ref()
4411 .is_some_and(|s| s.conflict.is_some())
4412 {
4413 return Ok(());
4414 }
4415 let run_id = self.state.id.clone();
4420 let prompts = self.state.config.prompts.clone();
4421 let Some(winner) = self.state.winner().cloned() else {
4422 return Ok(());
4423 };
4424 let max_rounds = self.state.config.graph.review_rounds;
4425 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4435 if status == RunStatus::Gating {
4438 self.record_contested_handoff();
4439 }
4440 self.state.status = status;
4441 self.state.save()?;
4442 return Ok(());
4443 }
4444 self.state.status = RunStatus::Reviewing;
4445 if self
4455 .state
4456 .reviews
4457 .last()
4458 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4459 {
4460 let shell = self.state.config.shell();
4461 return self
4462 .stop_reviewing(
4463 "the last round's own verification never resolved",
4464 &shell,
4465 &winner.worktree,
4466 )
4467 .await;
4468 }
4469
4470 let repo = self.state.repo.clone();
4471 let root = self.state.worktree_root();
4472 let language = self.state.config.graph.language.clone();
4473 let sessions = self.state.config.graph.sessions;
4474 let artifacts = agent::artifacts_dir(&self.state.dir());
4475 let base = self.landing_base();
4476 let base_short = short(&base);
4477 let reviewers = self.roles.reviewers.clone();
4478 let shell = self.state.config.shell();
4479
4480 for round in (self.state.reviews.len() + 1)..=max_rounds {
4481 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4482 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4483 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4484 let prev_verification = self
4493 .state
4494 .reviews
4495 .last()
4496 .and_then(|r| r.verification_summary(&head));
4497
4498 let mut jobs = Vec::new();
4502 for (r, spec) in reviewers.iter().cloned().enumerate() {
4503 let wt = root.join(format!("review-{}", r + 1));
4504 if wt.exists() {
4505 git::reset_detached(&wt, &head).await?;
4506 } else {
4507 git::worktree_add_detached(&repo, &wt, &head).await?;
4508 }
4509 let seat_key = format!("review-{}", r + 1);
4510 let spec = pick_start_spec(
4513 &self.roles.reviewer_roster,
4514 spec,
4515 self.state.seat_history.get(&seat_key),
4516 );
4517 let seat = self.seat(&seat_key, &spec.id);
4518 jobs.push(SeatJob {
4519 prompt: prompt::review(&prompt::ReviewCtx {
4520 instruction: &self.state.instruction,
4521 branch: &winner.branch,
4522 base_short: &base_short,
4523 stat: &stat,
4524 patch: &patch,
4525 verification: prev_verification.as_ref(),
4526 reviewers: reviewers.len(),
4527 round,
4528 rounds: max_rounds,
4529 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4532 lens: Lens::for_seat(r),
4533 language: &language,
4534 }),
4535 spec,
4536 seat,
4537 cwd: wt,
4538 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4539 allow_write: false,
4540 sessions,
4541 artifacts: artifacts.clone(),
4542 stem: format!("review-{round}-{}", r + 1),
4543 handover: None,
4544 });
4545 }
4546
4547 self.state.event(
4548 "review",
4549 format!(
4550 "round {round}: {} reviewers on {}",
4551 jobs.len(),
4552 short(&head)
4553 ),
4554 );
4555 let mut quota_losses = Vec::new();
4556 let review_retries = self.state.config.graph.retries;
4557 let review_cache = self.state.config.cache_dir();
4558 let ctx = WaveCtx {
4559 carry_seats: true,
4560 run: &run_id,
4561 node: "review",
4562 prompts: &prompts,
4563 cache: review_cache.as_deref(),
4564 round: Some(round),
4565 };
4566 let results = ask_json_wave::<Review>(
4567 jobs,
4568 Arc::clone(&self.sem),
4569 review_retries,
4570 &self.roles.reviewer_roster,
4571 &ctx,
4572 &mut quota_losses,
4573 &mut self.state,
4574 &|_: &Review| Ok(()),
4575 )
4576 .await;
4577 let round_quota_missing = quota_losses.len();
4581 self.state.quota.extend(quota_losses);
4582
4583 let mut records = Vec::new();
4584 let mut all_findings = Vec::new();
4585 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4586 let agent_id = seat.agent.clone();
4587 self.state.seats.insert(seat.key.clone(), seat);
4588 let mut record = ReviewRecord {
4589 reviewer: r + 1,
4590 agent: agent_id,
4591 summary: String::new(),
4592 findings: Vec::new(),
4593 vote: None,
4594 failed: None,
4595 duration_ms: 0,
4596 attempts,
4602 };
4603 match res {
4604 Ok((review, out)) => {
4605 record.summary =
4613 blind::sanitize_prose(&review.summary, &self.state.config.blind);
4614 record.vote = Some(review.vote);
4615 record.duration_ms = out.duration_ms;
4616 for (n, mut f) in review.findings.into_iter().enumerate() {
4617 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4620 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4621 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4622 f.file = f
4628 .file
4629 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4630 all_findings.push(f.clone());
4631 record.findings.push(f);
4632 }
4633 self.state.event(
4634 "review",
4635 format!(
4636 "round {round}: reviewer {} voted {} with {} finding(s)",
4637 r + 1,
4638 review.vote.label(),
4639 record.findings.len()
4640 ),
4641 );
4642 }
4643 Err(e) => {
4644 record.failed = Some(e.to_string());
4645 self.state.event(
4646 "review",
4647 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4648 );
4649 }
4650 }
4651 records.push(record);
4652 }
4653
4654 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4661 let vote_split =
4662 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4663 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4664 if vote_split {
4665 self.state.event(
4666 "review",
4667 format!(
4668 "round {round}: votes split ({}) — one round of reconsideration",
4669 initial_votes
4670 .iter()
4671 .map(|v| v.label())
4672 .collect::<Vec<_>>()
4673 .join(", ")
4674 ),
4675 );
4676 let panel: Vec<ReviewSeatReport<'_>> = records
4679 .iter()
4680 .filter_map(|r| {
4681 r.vote.map(|vote| ReviewSeatReport {
4682 reviewer: r.reviewer,
4683 vote,
4684 summary: &r.summary,
4685 findings: &r.findings,
4686 })
4687 })
4688 .collect();
4689
4690 let mut jobs = Vec::new();
4691 let mut seats_at = Vec::new();
4692 for (r, spec) in reviewers.iter().cloned().enumerate() {
4693 if records[r].vote.is_none() {
4697 continue;
4698 }
4699 let wt = root.join(format!("review-{}", r + 1));
4700 let seat_key = format!("review-{}", r + 1);
4701 let spec = self.occupant(&seat_key, spec);
4702 let seat = self.seat(&seat_key, &spec.id);
4703 let build = |with_patch: bool| {
4711 prompt::review_reconsider(&ReviewReconsiderCtx {
4712 instruction: &self.state.instruction,
4713 reviewer: r + 1,
4714 lens: Lens::for_seat(r),
4715 panel: &panel,
4716 patch: with_patch.then_some(ReviewPatch {
4717 branch: &winner.branch,
4718 base_short: &base_short,
4719 stat: &stat,
4720 patch: &patch,
4721 }),
4722 round,
4723 rounds: max_rounds,
4724 language: &language,
4725 })
4726 };
4727 let full = build(true);
4728 let prompt = if has_context(&spec, &seat, sessions) {
4729 build(false)
4730 } else {
4731 full.clone()
4732 };
4733 jobs.push(SeatJob {
4734 prompt,
4735 spec,
4736 seat,
4737 cwd: wt,
4738 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4739 allow_write: false,
4740 sessions,
4741 artifacts: artifacts.clone(),
4742 stem: format!("review-{round}-reconsider-{}", r + 1),
4743 handover: Some(full),
4744 });
4745 seats_at.push(r);
4746 }
4747
4748 let mut recon_quota_losses = Vec::new();
4749 let recon_cache = self.state.config.cache_dir();
4750 let recon_ctx = WaveCtx {
4751 carry_seats: true,
4752 run: &run_id,
4753 node: "review",
4754 prompts: &prompts,
4755 cache: recon_cache.as_deref(),
4756 round: Some(round),
4757 };
4758 let recon_results = ask_json_wave::<ReviewRevote>(
4759 jobs,
4760 Arc::clone(&self.sem),
4761 review_retries,
4762 &self.roles.reviewer_roster,
4763 &recon_ctx,
4764 &mut recon_quota_losses,
4765 &mut self.state,
4766 &|_: &ReviewRevote| Ok(()),
4767 )
4768 .await;
4769 self.state.quota.extend(recon_quota_losses);
4770
4771 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4772 let agent_id = seat.agent.clone();
4773 self.state.seats.insert(seat.key.clone(), seat);
4774 let mut rec = ReviewRevoteRecord {
4775 reviewer: r + 1,
4776 agent: agent_id,
4777 vote: None,
4778 reason: String::new(),
4779 failed: None,
4780 };
4781 match res {
4782 Ok((rv, _)) => {
4783 rec.vote = Some(rv.vote);
4784 rec.reason =
4785 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4786 self.state.event(
4787 "review",
4788 format!(
4789 "round {round}: reviewer {} revoted {}",
4790 r + 1,
4791 rv.vote.label()
4792 ),
4793 );
4794 }
4795 Err(e) => {
4796 rec.failed = Some(e.to_string());
4797 self.state.event(
4798 "review",
4799 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4800 );
4801 }
4802 }
4803 reconsideration.push(rec);
4804 }
4805 } else if initial_votes.len() > 1 {
4806 self.state.event(
4807 "review",
4808 format!(
4809 "round {round}: votes agreed ({}) — no reconsideration",
4810 initial_votes[0].label()
4811 ),
4812 );
4813 }
4814
4815 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4816 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4817 let defer_e2e =
4828 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4829 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4830 let reason =
4831 format!("{blocking} blocking finding(s) already required a fix this round");
4832 self.state.event(
4833 "verify",
4834 format!(
4835 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4836 {}); it will run once a round has none left",
4837 short(&head)
4838 ),
4839 );
4840 (Vec::new(), false, true, Some(reason))
4841 } else {
4842 let e2e_commands = self.state.config.verify.e2e.clone();
4843 let cache_dir = self.state.config.cache_dir();
4844 let context = format!("round {round}");
4845 let (e2e, verify_retried) = with_cache_lease(
4846 &mut self.state,
4847 cache_dir.as_deref(),
4848 "e2e",
4849 "e2e",
4850 &winner.worktree,
4851 &head,
4852 verify_timeout,
4853 &context,
4854 |state, budget| {
4855 let shell = shell.clone();
4856 let e2e_commands = e2e_commands.clone();
4857 let worktree = winner.worktree.clone();
4858 let context = context.clone();
4859 async move {
4860 run_e2e_with_retry(
4861 state,
4862 &shell,
4863 &e2e_commands,
4864 &worktree,
4865 budget,
4866 &context,
4867 )
4868 .await
4869 }
4870 },
4871 )
4872 .await;
4873 (e2e, verify_retried, false, None)
4874 };
4875
4876 let expected = records.len();
4877 let answered = records.iter().filter(|r| r.failed.is_none()).count();
4878 let incomplete = answered < expected;
4879 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
4880 let policy = self.state.config.graph.incomplete_review;
4881 let clean = round_is_clean(
4882 blocking,
4883 e2e_ok,
4884 answered,
4885 expected,
4886 round_quota_missing,
4887 policy,
4888 );
4889
4890 let mut round_record = ReviewRound {
4891 round,
4892 head: head.clone(),
4893 verified_head: None,
4894 verified_at: None,
4895 reviews: records,
4896 e2e,
4897 verify_retried,
4898 e2e_deferred,
4899 e2e_defer_reason,
4900 fix: None,
4901 blocking,
4902 answered,
4903 expected,
4904 clean,
4905 progressed: false,
4906 vote_split,
4907 reconsideration,
4908 verdict: None,
4909 };
4910 round_record.verdict = ReviewVote::worst(
4914 round_record
4915 .final_votes()
4916 .into_iter()
4917 .map(|(_, _, vote)| vote),
4918 );
4919 if !matches!(
4930 round_record.e2e_status(),
4931 E2eStatus::Deferred | E2eStatus::NotConfigured
4932 ) {
4933 round_record.verified_head = Some(head.clone());
4934 round_record.verified_at = Some(Timestamp::now());
4935 }
4936 let this_round_verification = round_record.verification_summary(&head);
4937
4938 if incomplete {
4939 let missing: Vec<String> = round_record
4940 .reviews
4941 .iter()
4942 .filter(|r| r.failed.is_some())
4943 .map(|r| format!("review-{}", r.reviewer))
4944 .collect();
4945 self.state.event(
4946 "review",
4947 format!(
4948 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
4949 missing.join(", ")
4950 ),
4951 );
4952 }
4953
4954 if clean {
4955 self.state.event(
4956 "review",
4957 if incomplete && policy == IncompleteReviewPolicy::Warn {
4958 format!(
4959 "round {round}: clean (warn policy, incomplete panel) — no \
4960 blocking findings from the seats that answered, verification green"
4961 )
4962 } else if incomplete {
4963 format!(
4964 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
4965 quorum) — no blocking findings from the seats that answered, \
4966 verification green",
4967 expected - answered
4968 )
4969 } else {
4970 format!("round {round}: clean — no blocking findings, verification green")
4971 },
4972 );
4973 self.state.reviews.push(round_record);
4974 self.state.status = RunStatus::Gating;
4975 self.state.save()?;
4976 return Ok(());
4977 }
4978
4979 if incomplete && blocking == 0 && e2e_ok {
4987 self.state.reviews.push(round_record);
4988 self.state.save()?;
4989 if round == max_rounds {
4990 self.state.status = RunStatus::Blocked;
4991 self.state.event(
4992 "review",
4993 format!(
4994 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
4995 refusing to call it clean",
4996 expected - answered
4997 ),
4998 );
4999 return Ok(());
5000 }
5001 continue;
5002 }
5003
5004 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5016 self.state.reviews.push(round_record);
5017 return self
5018 .stop_reviewing(
5019 "the round's own verification could not run",
5020 &shell,
5021 &winner.worktree,
5022 )
5023 .await;
5024 }
5025
5026 if round == max_rounds {
5027 self.state.reviews.push(round_record);
5028 return self
5029 .stop_reviewing(
5030 &format!(
5031 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5032 ),
5033 &shell,
5034 &winner.worktree,
5035 )
5036 .await;
5037 }
5038
5039 let (fix_spec, fix_seat_key) = self.fixer_spec(&winner);
5042 let seat = self.seat(&fix_seat_key, &fix_spec.id);
5043 let blocking_findings: Vec<_> = all_findings
5044 .iter()
5045 .filter(|f| f.severity.blocks())
5046 .cloned()
5047 .collect();
5048 let job = SeatJob {
5049 prompt: prompt::fix(
5050 &self.state.instruction,
5051 &blocking_findings,
5052 this_round_verification.as_ref(),
5053 round,
5054 max_rounds,
5055 &language,
5056 ),
5057 spec: fix_spec.clone(),
5058 seat,
5059 cwd: winner.worktree.clone(),
5060 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5061 allow_write: true,
5062 sessions,
5063 artifacts: artifacts.clone(),
5064 stem: format!("fix-{round}"),
5065 handover: None,
5066 };
5067 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5068 let cache = self.state.config.cache_dir();
5069 let ctx = WaveCtx {
5070 carry_seats: false,
5071 run: &run_id,
5072 node: "fix",
5073 prompts: &prompts,
5074 cache: cache.as_deref(),
5075 round: Some(round),
5076 };
5077 let (seat, out) =
5078 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5079 let agent_id = seat.agent.clone();
5080
5081 let mut fix = FixRecord {
5082 agent: agent_id,
5083 addressed: Vec::new(),
5084 rejected: Vec::new(),
5085 notes: String::new(),
5086 committed: false,
5087 failed: None,
5088 duration_ms: 0,
5089 continuation: None,
5090 };
5091 let mut continuation = ContinuationRecord::not_needed();
5092 let mut final_seat = seat.clone();
5093 match out {
5094 AgentOutcome::Ok(o) => {
5095 fix.duration_ms = o.duration_ms;
5096 let parsed = verdict::extract_json::<FixReport>(&o.text);
5097 let incomplete_reason = match &parsed {
5104 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5105 "the reply parsed, but it reported a command whose own CLI never \
5106 confirmed an exit status"
5107 .to_owned(),
5108 ),
5109 Ok(_) => None,
5110 Err(e) => Some(e.to_string()),
5111 };
5112 match incomplete_reason {
5113 None => {
5114 let report = parsed.expect("checked Ok above");
5115 fix.addressed = report.addressed;
5116 fix.rejected = report.rejected;
5117 fix.notes =
5118 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5119 }
5120 Some(reason) => {
5121 let (resumed_seat, resolved, failure, cont) = self
5122 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5123 .await;
5124 fix.duration_ms += cont.cumulative_wait_ms;
5125 continuation = cont;
5126 final_seat = resumed_seat;
5127 match resolved {
5128 Some(report) => {
5129 fix.addressed = report.addressed;
5130 fix.rejected = report.rejected;
5131 fix.notes = blind::sanitize_prose(
5132 &report.notes,
5133 &self.state.config.blind,
5134 );
5135 }
5136 None => fix.failed = failure,
5137 }
5138 }
5139 }
5140 }
5141 AgentOutcome::Dropped(o) => {
5143 fix.duration_ms = o.duration_ms;
5144 let why = o
5145 .dropped
5146 .as_ref()
5147 .map(|d| d.why.as_str())
5148 .unwrap_or("the CLI ended the stream without delivering its answer");
5149 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5150 }
5151 AgentOutcome::Quota(o) => {
5152 self.state.quota.push(QuotaLoss {
5153 seat: final_seat.key.clone(),
5154 node: "fix".to_owned(),
5155 at: Timestamp::now(),
5156 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5157 });
5158 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5159 }
5160 AgentOutcome::Failed(e) => fix.failed = Some(e),
5161 }
5162 fix.continuation = Some(continuation);
5163 self.state.seats.insert(final_seat.key.clone(), final_seat);
5164 if let Ok(r) = git::rescue_commit(
5165 &winner.worktree,
5166 &format!("magi: review round {round} fixes (uncommitted work)"),
5167 )
5168 .await
5169 {
5170 self.state.note_withheld("fix", &r.withheld);
5171 }
5172 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5173 fix.committed = after != before;
5174 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5182 let progressed = diff_after != patch;
5183 let commit_note = if fix.committed {
5184 "committed"
5185 } else {
5186 "NO new commit"
5187 };
5188 let tree_note = if progressed {
5189 "changed vs base"
5190 } else {
5191 "unchanged vs base"
5192 };
5193 self.state.event(
5194 "fix",
5195 match &fix.failed {
5196 Some(reason) => {
5202 format!(
5203 "round {round}: fixer's adoption report was lost ({reason}); \
5204 {commit_note}, tree {tree_note}"
5205 )
5206 }
5207 None => format!(
5208 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5209 {tree_note}{}",
5210 fix.addressed.len(),
5211 fix.rejected.len(),
5212 if continuation.outcome == ContinuationOutcome::Resumed {
5213 format!(
5214 " (adoption report recovered after {} continuation(s))",
5215 continuation.attempts
5216 )
5217 } else {
5218 String::new()
5219 },
5220 ),
5221 },
5222 );
5223 round_record.fix = Some(fix);
5224 round_record.progressed = progressed;
5225 self.state.reviews.push(round_record);
5226 self.state.save()?;
5227
5228 if matches!(
5241 continuation.outcome,
5242 ContinuationOutcome::Exhausted
5243 | ContinuationOutcome::QuotaLost
5244 | ContinuationOutcome::NoSession
5245 ) {
5246 return self
5247 .stop_reviewing(
5248 "the fixer's adoption report never came back, even after resuming its \
5249 own seat; refusing to start another round against the same worktree \
5250 while that is unresolved",
5251 &shell,
5252 &winner.worktree,
5253 )
5254 .await;
5255 }
5256
5257 let streak = self
5258 .state
5259 .reviews
5260 .iter()
5261 .rev()
5262 .take_while(|r| !r.progressed)
5263 .count();
5264 if streak >= STAGNANT_LIMIT {
5265 return self
5266 .stop_reviewing(
5267 &format!(
5268 "the tree has not moved against base for {streak} round(s) in a row"
5269 ),
5270 &shell,
5271 &winner.worktree,
5272 )
5273 .await;
5274 }
5275 }
5276 Ok(())
5277 }
5278
5279 fn record_contested_handoff(&mut self) {
5313 if self.state.contested_handoff.is_some() {
5314 return;
5315 }
5316 let Some(contested) = self
5317 .state
5318 .reviews
5319 .last()
5320 .and_then(ReviewRound::contested_handoff)
5321 else {
5322 return;
5323 };
5324 self.state.event(
5325 "review",
5326 format!(
5327 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5328 wait for the owner's approval",
5329 contested.findings.len(),
5330 contested.rejecters.len()
5331 ),
5332 );
5333 self.state.contested_handoff = Some(contested);
5334 }
5335
5336 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5337 let round_idx = self.state.reviews.len() - 1;
5338 let needs_catchup_run = matches!(
5346 self.state.reviews[round_idx].e2e_status(),
5347 E2eStatus::Deferred | E2eStatus::ResourceBlocked
5348 );
5349 if needs_catchup_run {
5350 let round = self.state.reviews[round_idx].round;
5351 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5352 let commands = self.state.config.verify.e2e.clone();
5353 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5354 let cache_dir = self.state.config.cache_dir();
5355 let context = format!(
5356 "round {round}: verification unresolved, catching up before the final decision"
5357 );
5358 let (outcomes, verify_retried) = with_cache_lease(
5359 &mut self.state,
5360 cache_dir.as_deref(),
5361 "e2e",
5362 "e2e",
5363 worktree,
5364 &attempted_head,
5365 timeout,
5366 &context,
5367 |state, budget| {
5368 let shell = shell.to_vec();
5369 let commands = commands.clone();
5370 let context = context.clone();
5371 async move {
5372 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5373 .await
5374 }
5375 },
5376 )
5377 .await;
5378 let last = &mut self.state.reviews[round_idx];
5379 last.e2e = outcomes;
5380 last.verify_retried = verify_retried;
5381 last.verified_head = Some(attempted_head);
5388 last.verified_at = Some(Timestamp::now());
5389 if verify_inconclusive(&last.e2e) {
5390 self.state.save()?;
5397 return Ok(());
5398 }
5399 last.e2e_deferred = false;
5400 }
5401 let last = &self.state.reviews[round_idx];
5402 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5403
5404 match last.e2e_status() {
5405 E2eStatus::Failed => {
5406 let red: Vec<String> = last
5407 .e2e
5408 .iter()
5409 .filter(|o| !o.ok())
5410 .map(|o| {
5411 format!(
5412 "`{}` -> {:?}\n{}",
5413 o.command,
5414 o.code,
5415 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5416 )
5417 })
5418 .collect();
5419 self.state
5420 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5421 self.state.status = RunStatus::Blocked;
5422 }
5423 E2eStatus::ResourceBlocked => {
5428 self.state.event(
5429 "review",
5430 format!(
5431 "{why}; e2e could not run (shared build cache unavailable); not \
5432 deciding yet"
5433 ),
5434 );
5435 }
5436 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5437 self.state.event(
5438 "review",
5439 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5440 );
5441 self.record_contested_handoff();
5442 self.state.status = RunStatus::Gating;
5443 }
5444 }
5445 self.state.save()?;
5446 Ok(())
5447 }
5448
5449 async fn gate(&mut self) -> Result<()> {
5452 if self.state.status == RunStatus::Failed
5464 || self
5465 .state
5466 .base_sync
5467 .as_ref()
5468 .is_some_and(|s| s.conflict.is_some())
5469 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5470 != Some(RunStatus::Gating)
5471 {
5472 return Ok(());
5473 }
5474 if self.state.gate_ran {
5475 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5486 self.state.status = RunStatus::Blocked;
5487 self.state.save()?;
5488 }
5489 return Ok(());
5490 }
5491 let Some(winner) = self.state.winner().cloned() else {
5492 return Ok(());
5493 };
5494 self.state.status = RunStatus::Gating;
5495 let mut outcomes = self.run_gate(&winner).await?;
5496 loop {
5497 if verify_inconclusive(&outcomes) {
5508 self.state.save()?;
5509 return Ok(());
5510 }
5511 if outcomes.iter().all(CommandOutcome::ok) {
5512 break;
5513 }
5514 match self.gate_fix_round(&winner, &outcomes).await? {
5515 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5516 GateFix::Stop => break,
5517 GateFix::Defer => {
5518 self.state.save()?;
5519 return Ok(());
5520 }
5521 }
5522 }
5523 let passed = outcomes.iter().all(CommandOutcome::ok);
5524 self.state.gate = outcomes;
5525 self.state.gate_ran = true;
5526 if !passed {
5527 self.state.status = RunStatus::Blocked;
5528 let spent = self.state.gate_fixes.len();
5529 self.state.event(
5530 "gate",
5531 if spent == 0 {
5532 "gate failed; not merging".to_owned()
5533 } else {
5534 format!("gate failed after {spent} gate-fix round(s); not merging")
5535 },
5536 );
5537 }
5538 self.state.save()?;
5539 Ok(())
5540 }
5541
5542 async fn run_pre_gate(&mut self, winner: &Candidate) {
5552 let commands = self.state.config.verify.pre_gate.clone();
5553 if commands.is_empty() {
5554 return;
5555 }
5556 let shell = self.state.config.shell();
5557 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5558 let (outcomes, _) = run_commands(
5559 &mut self.state,
5560 "pre_gate",
5561 "pre_gate",
5562 0,
5563 &shell,
5564 &commands,
5565 &winner.worktree,
5566 timeout,
5567 )
5568 .await;
5569 for o in &outcomes {
5570 if !o.ok() {
5571 tracing::warn!(
5572 "pre_gate `{}` failed ({:?}); the gate decides",
5573 o.command,
5574 o.code
5575 );
5576 }
5577 self.state.event(
5578 "pre_gate",
5579 format!(
5580 "`{}` -> {}",
5581 o.command,
5582 if o.ok() {
5583 "pass".to_owned()
5584 } else {
5585 format!(
5586 "FAIL ({:?})\n{}",
5587 o.code,
5588 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5589 )
5590 }
5591 ),
5592 );
5593 }
5594 self.state.pre_gate = outcomes;
5595 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5596 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5597 Ok(head) => {
5598 self.state
5599 .event("pre_gate", format!("committed mechanical fixes ({head})"));
5600 self.state.pre_gate_commit = Some(head);
5601 }
5602 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5603 },
5604 Ok(false) => {}
5605 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5606 }
5607 if let Err(e) = self.state.save() {
5608 tracing::warn!("could not persist the pre_gate record: {e:#}");
5609 }
5610 }
5611
5612 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5615 self.run_pre_gate(winner).await;
5616 let shell = self.state.config.shell();
5617 let gate_commands = self.state.config.verify.gate.clone();
5618 let outcomes = if gate_commands.is_empty() {
5627 Vec::new()
5628 } else {
5629 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5630 let cache_dir = self.state.config.cache_dir();
5631 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5632 let (outcomes, _) = with_cache_lease(
5633 &mut self.state,
5634 cache_dir.as_deref(),
5635 "gate",
5636 "gate",
5637 &winner.worktree,
5638 &head,
5639 timeout,
5640 "final gate",
5641 |state, budget| {
5642 let shell = shell.clone();
5643 let gate_commands = gate_commands.clone();
5644 let worktree = winner.worktree.clone();
5645 async move {
5646 let (outcomes, timed_out_pids) = run_commands(
5647 state,
5648 "gate",
5649 "gate",
5650 0,
5651 &shell,
5652 &gate_commands,
5653 &worktree,
5654 budget,
5655 )
5656 .await;
5657 (outcomes, false, timed_out_pids)
5658 }
5659 },
5660 )
5661 .await;
5662 outcomes
5663 };
5664 if outcomes.is_empty() {
5665 self.state.event(
5670 "gate",
5671 "no gate commands configured; nothing to check, passing",
5672 );
5673 }
5674 for o in &outcomes {
5675 self.state.event(
5676 "gate",
5677 format!(
5678 "`{}` -> {}",
5679 o.command,
5680 if o.ok() {
5681 "pass".to_owned()
5682 } else {
5683 format!(
5684 "FAIL ({:?})\n{}",
5685 o.code,
5686 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5687 )
5688 }
5689 ),
5690 );
5691 }
5692 Ok(outcomes)
5693 }
5694
5695 async fn gate_fix_round(
5708 &mut self,
5709 winner: &Candidate,
5710 outcomes: &[CommandOutcome],
5711 ) -> Result<GateFix> {
5712 let cap = self.state.config.graph.gate_fix_rounds;
5713 let spent = self.state.gate_fixes.len();
5714 if spent >= cap {
5715 if cap > 0 {
5716 self.state.event(
5717 "gate",
5718 format!("{spent} gate-fix round(s) spent and the gate still fails"),
5719 );
5720 }
5721 return Ok(GateFix::Stop);
5722 }
5723 if !gate_fixable(outcomes) {
5724 self.state.event(
5725 "gate",
5726 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5727 command or similar); not spending a fix round on it",
5728 );
5729 return Ok(GateFix::Stop);
5730 }
5731 let min_free = self.state.config.disk.min_free_bytes;
5732 if min_free > 0 {
5733 match crate::disk::free_bytes(&winner.worktree) {
5734 Ok(free) if crate::disk::enough_space(free, min_free) => {}
5735 Ok(free) => {
5736 self.state.event(
5737 "gate",
5738 format!(
5739 "only {free} bytes free ({min_free} required by `[disk] \
5740 min_free_bytes`); not spending a fix round on a failure the disk \
5741 may explain"
5742 ),
5743 );
5744 return Ok(GateFix::Stop);
5745 }
5746 Err(e) => {
5747 self.state.event(
5748 "gate",
5749 format!("free disk space could not be measured ({e:#}); no fix round"),
5750 );
5751 return Ok(GateFix::Stop);
5752 }
5753 }
5754 }
5755
5756 let attempt = spent + 1;
5757 let run_id = self.state.id.clone();
5758 let prompts = self.state.config.prompts.clone();
5759 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5760 let base = self.landing_base();
5761 let (fix_spec, fix_seat_key) = self.fixer_spec(winner);
5762 let seat = self.seat(&fix_seat_key, &fix_spec.id);
5763 let job = SeatJob {
5764 prompt: prompt::gate_fix(
5765 &self.state.instruction,
5766 &failed,
5767 attempt,
5768 cap,
5769 &self.state.config.graph.language,
5770 ),
5771 spec: fix_spec,
5772 seat,
5773 cwd: winner.worktree.clone(),
5774 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5775 allow_write: true,
5776 sessions: self.state.config.graph.sessions,
5777 artifacts: agent::artifacts_dir(&self.state.dir()),
5778 stem: format!("gate-fix-{attempt}"),
5779 handover: None,
5780 };
5781 self.state.event(
5782 "gate",
5783 format!("gate failed; gate-fix round {attempt} of {cap}"),
5784 );
5785 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5786 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5787 let cache = self.state.config.cache_dir();
5788 let ctx = WaveCtx {
5789 carry_seats: false,
5790 run: &run_id,
5791 node: "gate-fix",
5792 prompts: &prompts,
5793 cache: cache.as_deref(),
5794 round: None,
5795 };
5796 let (seat, out) = run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5797 let mut record = GateFixRecord {
5798 agent: seat.agent.clone(),
5799 failed,
5800 notes: String::new(),
5801 committed: false,
5802 error: None,
5803 };
5804 match out {
5805 AgentOutcome::Ok(o) => {
5806 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5809 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5810 }
5811 }
5812 AgentOutcome::Dropped(_) => {
5813 record.error = Some("the CLI dropped the stream".to_owned());
5814 }
5815 AgentOutcome::Quota(o) => {
5816 self.state.quota.push(QuotaLoss {
5817 seat: seat.key.clone(),
5818 node: "gate-fix".to_owned(),
5819 at: Timestamp::now(),
5820 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5821 });
5822 record.error = Some("rate limited (quota); fixer could not run".to_owned());
5823 }
5824 AgentOutcome::Failed(e) => record.error = Some(e),
5825 }
5826 self.state.seats.insert(seat.key.clone(), seat);
5827 if let Ok(r) = git::rescue_commit(
5828 &winner.worktree,
5829 &format!("magi: gate fix {attempt} (uncommitted work)"),
5830 )
5831 .await
5832 {
5833 self.state.note_withheld("gate-fix", &r.withheld);
5834 }
5835 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5836 record.committed = after != before;
5837 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5838 let note = record.error.clone();
5839 self.state.gate_fixes.push(record);
5840 self.state.save()?;
5841 if !changed {
5842 self.state.event(
5843 "gate",
5844 match note {
5845 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5846 None => format!("gate-fix round {attempt}: the tree did not change"),
5847 },
5848 );
5849 return Ok(GateFix::Stop);
5850 }
5851 self.state.event(
5852 "gate",
5853 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5854 );
5855
5856 let commands = self.state.config.verify.e2e.clone();
5857 if !commands.is_empty() {
5858 let shell = self.state.config.shell();
5859 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5860 let cache_dir = self.state.config.cache_dir();
5861 let context = format!("gate-fix round {attempt}");
5862 let (e2e, _) = with_cache_lease(
5863 &mut self.state,
5864 cache_dir.as_deref(),
5865 "e2e",
5866 "e2e",
5867 &winner.worktree,
5868 &after,
5869 timeout,
5870 &context,
5871 |state, budget| {
5872 let shell = shell.clone();
5873 let commands = commands.clone();
5874 let context = context.clone();
5875 let worktree = winner.worktree.clone();
5876 async move {
5877 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5878 .await
5879 }
5880 },
5881 )
5882 .await;
5883 if verify_inconclusive(&e2e) {
5884 return Ok(GateFix::Defer);
5885 }
5886 if e2e.iter().any(|o| !o.ok()) {
5887 self.state.event(
5888 "gate",
5889 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
5890 );
5891 return Ok(GateFix::Stop);
5892 }
5893 }
5894 Ok(GateFix::Retry)
5895 }
5896
5897 async fn merge(&mut self) -> Result<()> {
5900 if self
5915 .state
5916 .base_sync
5917 .as_ref()
5918 .is_some_and(|s| s.conflict.is_some())
5919 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5920 != Some(RunStatus::Gating)
5921 || !self.state.gate_status().ok()
5930 {
5931 return Ok(());
5932 }
5933 if self.state.merge.is_some() {
5942 return Ok(());
5943 }
5944 let Some(winner) = self.state.winner().cloned() else {
5945 return Ok(());
5946 };
5947 let repo = self.state.repo.clone();
5948 let base = self.state.base_branch.clone();
5949 let mode = self.state.config.merge.mode;
5950 let style = self.state.config.merge.style;
5951 let facts = if is_review_run(&self.state) {
5952 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
5953 let start = review_base(
5954 &repo,
5955 &self.state.config.merge.remote,
5956 &base,
5957 &self.state.base_commit,
5958 &winner.branch,
5959 )
5960 .await;
5961 branch_facts(&repo, &start, &winner.branch).await
5962 } else {
5963 None
5964 };
5965 let leaked = if is_review_run(&self.state) {
5968 leaked_subjects(&self.state, &winner.branch).await
5969 } else {
5970 Some(Vec::new())
5971 };
5972 let pr = pr_message_with(&self.state, winner.label, facts.as_ref());
5973 let message = pr.commit_message();
5974
5975 let outcome = match mode {
5976 MergeMode::None => MergeOutcome {
5977 mode,
5978 ok: true,
5979 detail: manual_merge_command(style, &repo, &winner.branch, &message),
5980 empty: false,
5981 },
5982 MergeMode::Pr | MergeMode::Local
5983 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
5984 {
5985 MergeOutcome {
5986 mode,
5987 ok: false,
5988 detail: empty_candidate_detail(&self.state, &base),
5989 empty: true,
5990 }
5991 }
5992 MergeMode::Local => {
5993 let on = git::current_branch(&repo).await?;
5994 if on.as_deref() != Some(base.as_str()) {
5995 MergeOutcome {
5996 mode,
5997 ok: false,
5998 detail: format!(
5999 "{} has {} checked out, not the base branch {base}",
6000 repo.display(),
6001 on.unwrap_or_else(|| "a detached HEAD".to_owned())
6002 ),
6003 empty: false,
6004 }
6005 } else if !git::is_clean(&repo).await? {
6006 MergeOutcome {
6007 mode,
6008 ok: false,
6009 detail: format!("{} is dirty; refusing to merge", repo.display()),
6010 empty: false,
6011 }
6012 } else {
6013 let out = match style {
6014 MergeStyle::Merge => {
6015 git::merge_no_ff(&repo, &winner.branch, &message).await?
6016 }
6017 MergeStyle::Squash => {
6018 git::merge_squash(&repo, &winner.branch, &message).await?
6019 }
6020 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6021 };
6022 MergeOutcome {
6023 mode,
6024 ok: out.ok(),
6025 detail: if out.ok() { out.stdout } else { out.stderr },
6026 empty: false,
6027 }
6028 }
6029 }
6030 MergeMode::Pr => {
6031 let remote = self.state.config.merge.remote.clone();
6032 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6033 if !pushed.ok() {
6034 MergeOutcome {
6035 mode,
6036 ok: false,
6037 detail: pushed.stderr,
6038 empty: false,
6039 }
6040 } else {
6041 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6047 let out = match pr_merge_plan(found) {
6048 PrPlan::Create => {
6049 gh_pr_create(
6050 &winner.worktree,
6051 &base,
6052 &winner.branch,
6053 &pr.title,
6054 &pr.body,
6055 )
6056 .await
6057 }
6058 PrPlan::Adopt { url, title } => {
6059 self.state
6060 .event("merge", format!("Pr: adopted open pull request {url}"));
6061 if title != pr.title
6062 && (!is_review_run(&self.state)
6063 || leaked
6064 .as_deref()
6065 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6066 && let Err(e) =
6067 land::set_pr_title(&winner.worktree, &url, &pr.title).await
6068 {
6069 tracing::warn!("could not refresh title of {url}: {e:#}");
6070 self.state
6071 .event("merge", format!("Pr: title refresh failed: {e:#}"));
6072 }
6073 Ok(url)
6074 }
6075 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6076 };
6077 match out {
6078 Ok(url) => MergeOutcome {
6079 mode,
6080 ok: true,
6081 detail: url,
6082 empty: false,
6083 },
6084 Err(e) => MergeOutcome {
6085 mode,
6086 ok: false,
6087 detail: e.to_string(),
6088 empty: false,
6089 },
6090 }
6091 }
6092 }
6093 };
6094
6095 self.state.status = match (mode, outcome.ok) {
6096 (MergeMode::None, _) => RunStatus::Ready,
6097 (_, true) => RunStatus::Merged,
6098 (_, false) => RunStatus::Blocked,
6099 };
6100 self.state.event(
6101 "merge",
6102 format!(
6103 "{:?}: {}",
6104 mode,
6105 outcome.detail.lines().next().unwrap_or("")
6106 ),
6107 );
6108 self.state.merge = Some(outcome);
6109 self.state.save()?;
6110
6111 if self.state.config.graph.land
6117 && mode == MergeMode::Pr
6118 && self.state.status == RunStatus::Merged
6119 {
6120 self.run_land().await?;
6121 }
6122 self.settle_questions();
6127 Ok(())
6128 }
6129
6130 async fn run_land(&mut self) -> Result<()> {
6141 let url = self
6142 .state
6143 .merge
6144 .as_ref()
6145 .map(|m| m.detail.clone())
6146 .unwrap_or_default();
6147 let url = url.lines().next().unwrap_or("").trim().to_owned();
6148 if !url.starts_with("http") {
6149 return Ok(());
6150 }
6151 match land::land(&mut self.state, &url).await {
6154 Ok(pr) if self.state.parked => {
6155 let _ = pr;
6159 }
6160 Ok(pr) => {
6161 self.state.status = match pr.state {
6162 land::PrLifecycle::Merged => RunStatus::Merged,
6163 _ => RunStatus::Blocked,
6164 };
6165 if bump::should_release_bump(self.state.status)
6172 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6173 {
6174 self.state
6180 .event("bump", format!("release bump skipped: {e:#}"));
6181 }
6182 if self.state.status == RunStatus::Merged {
6185 crate::followup::after_merge(&mut self.state, &pr.url).await;
6186 }
6187 self.state.save()?;
6188 }
6189 Err(e) => {
6190 self.state.status = RunStatus::Blocked;
6191 self.state.event("land", format!("gave up: {e}"));
6192 self.state.save()?;
6193 }
6194 }
6195 Ok(())
6196 }
6197
6198 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6202 if let Some(existing) = self.state.seats.get(key)
6203 && existing.agent == agent
6204 {
6205 return existing.clone();
6206 }
6207 let fresh = if self.state.seats.contains_key(key) {
6210 handover_seat(key, agent, self.state.next_seat_seed())
6211 } else {
6212 SeatState::new(key, agent, self.state.seed)
6213 };
6214 self.state.seats.insert(key.to_owned(), fresh.clone());
6215 fresh
6216 }
6217
6218 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6224 match self.state.seats.get(key) {
6225 Some(s) if s.agent != spec.id => {
6226 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6227 }
6228 _ => spec,
6229 }
6230 }
6231
6232 fn view(&self, c: &Candidate) -> CandidateView {
6234 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6235 .unwrap_or_default();
6236 let (patch, _) = blind::sanitize_patch(
6237 &format!("candidate {} patch", c.label),
6238 &raw,
6239 &self.state.config.blind,
6240 );
6241 CandidateView {
6242 label: c.label,
6243 branch: c.branch.clone(),
6244 summary: c.summary.clone(),
6245 stat: c.stat.clone(),
6246 patch,
6247 }
6248 }
6249
6250 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6252 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6253 prompt::judge(
6254 "(see above)",
6255 &views,
6256 self.roles.judges.len(),
6257 base_short,
6258 "en",
6259 )
6260 }
6261
6262 fn vote_prompt_full(
6268 &self,
6269 j: usize,
6270 labels: &[char],
6271 language: &str,
6272 candidates: &[Candidate],
6273 base_short: &str,
6274 ) -> String {
6275 let mut text = format!(
6276 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6277 prompt::final_vote(labels, language),
6278 self.state.instruction,
6279 self.candidate_block(candidates, base_short)
6280 );
6281 if let Some(own) = self
6282 .state
6283 .judgements
6284 .get(j)
6285 .filter(|r| !r.ranking.is_empty())
6286 {
6287 let reasons = own
6288 .reasons
6289 .iter()
6290 .map(|(k, v)| format!("- {k}: {v}"))
6291 .collect::<Vec<_>>()
6292 .join("\n");
6293 text.push_str(&format!(
6294 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6295 own.ranking.iter().collect::<String>(),
6296 if reasons.is_empty() {
6297 ""
6298 } else {
6299 ", because:\n"
6300 }
6301 ));
6302 }
6303 if !self.state.deliberation.is_empty() {
6304 text.push_str("\n# What was argued before this vote\n");
6305 for t in self.transcript(&[], j) {
6306 text.push_str(&format!(
6307 "\n## {}{}\n\n{}\n",
6308 t.who,
6309 if t.is_self { " (you)" } else { "" },
6310 t.body.trim()
6311 ));
6312 }
6313 }
6314 text
6315 }
6316
6317 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6324 let mut turns = Vec::new();
6325 for j in &self.state.judgements {
6326 if j.ranking.is_empty() {
6327 continue;
6328 }
6329 let reasons = j
6330 .reasons
6331 .iter()
6332 .map(|(k, v)| format!("- {k}: {v}"))
6333 .collect::<Vec<_>>()
6334 .join("\n");
6335 turns.push(Turn {
6336 who: format!("Judge {} (opening ranking)", j.judge),
6337 is_self: j.judge == self_idx + 1,
6338 body: format!(
6339 "Ranked {}{}{reasons}",
6340 j.ranking.iter().collect::<String>(),
6341 if reasons.is_empty() {
6342 ""
6343 } else {
6344 ", because:\n"
6345 }
6346 ),
6347 });
6348 }
6349 for t in self
6350 .state
6351 .deliberation
6352 .iter()
6353 .flat_map(|r| r.turns.iter())
6354 .chain(current)
6355 {
6356 turns.push(Turn {
6357 who: format!("Judge {}", t.judge),
6358 is_self: t.judge == self_idx + 1,
6359 body: t.body.clone(),
6360 });
6361 }
6362 turns
6363 }
6364}
6365
6366fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6368 agent::has_session(spec.kind, seat, sessions)
6369}
6370
6371fn next_untried_in_roster<'a>(
6392 roster: &'a [AgentSpec],
6393 start: usize,
6394 tried: &BTreeSet<String>,
6395) -> Option<&'a AgentSpec> {
6396 roster
6397 .get(start + 1..)?
6398 .iter()
6399 .find(|s| !tried.contains(&s.id))
6400}
6401
6402fn next_for_seat<'a>(
6412 roster: &'a [AgentSpec],
6413 start: usize,
6414 round_tried: &BTreeSet<String>,
6415 carried_failed: &BTreeSet<String>,
6416) -> Option<&'a AgentSpec> {
6417 roster
6418 .get(start + 1..)?
6419 .iter()
6420 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6421 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6422}
6423
6424fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6430 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6431 return spec;
6432 };
6433 let ok = |id: &str| !h.failed.contains(id);
6434 if let Some(last) = h.last_ok.as_deref()
6435 && ok(last)
6436 && let Some(s) = roster.iter().find(|s| s.id == last)
6437 {
6438 return s.clone();
6439 }
6440 if ok(&spec.id) {
6441 return spec;
6442 }
6443 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6444 roster
6445 .get(from + 1..)
6446 .into_iter()
6447 .flatten()
6448 .chain(roster.iter())
6449 .find(|s| ok(&s.id))
6450 .cloned()
6451 .unwrap_or(spec)
6452}
6453
6454fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6458 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6459}
6460
6461const TIMED_OUT: &str = "timed out";
6464
6465impl FailClass {
6466 fn of(out: &AgentOutcome) -> Option<Self> {
6468 match out {
6469 AgentOutcome::Ok(_) => None,
6470 AgentOutcome::Quota(_) => Some(Self::Quota),
6471 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6472 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6473 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6474 }
6475 }
6476
6477 fn stem_word(&self) -> &'static str {
6479 match self {
6480 Self::Quota => "quota",
6481 _ => "handover",
6482 }
6483 }
6484}
6485
6486fn failure_signature(msg: &str) -> String {
6490 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
6491 let mut out = Vec::new();
6492 for word in line.split_whitespace() {
6493 if word.contains('/') || word.contains('\\') {
6494 out.push("<path>".to_owned());
6495 continue;
6496 }
6497 let mut w = String::new();
6498 let mut in_digits = false;
6499 for c in word.chars() {
6500 if c.is_ascii_digit() {
6501 if !in_digits {
6502 w.push('#');
6503 }
6504 in_digits = true;
6505 } else {
6506 in_digits = false;
6507 w.push(c);
6508 }
6509 }
6510 out.push(w);
6511 }
6512 out.join(" ").chars().take(120).collect()
6513}
6514
6515fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
6522 match cur {
6523 FailClass::Quota | FailClass::Timeout => true,
6524 FailClass::Other(_) => prev != Some(cur),
6525 }
6526}
6527
6528fn fail_reason(out: &AgentOutcome) -> String {
6530 match out {
6531 AgentOutcome::Ok(_) => String::new(),
6532 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
6533 AgentOutcome::Dropped(o) => format!(
6534 "the CLI dropped the stream ({})",
6535 o.dropped
6536 .as_ref()
6537 .map(|d| d.why.as_str())
6538 .unwrap_or("it ended without delivering its answer")
6539 ),
6540 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
6541 }
6542}
6543
6544fn record_handover(
6547 state: &mut RunState,
6548 node: &str,
6549 seat: &str,
6550 from: &str,
6551 to: &str,
6552 class: &FailClass,
6553 reason: &str,
6554) {
6555 let message = if *class == FailClass::Quota {
6556 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
6557 } else {
6558 format!("{seat}: handed over {from} -> {to} ({reason})")
6559 };
6560 state.event(node, message);
6561 state.handovers.push(Handover {
6562 at: Timestamp::now(),
6563 node: node.to_owned(),
6564 seat: seat.to_owned(),
6565 from: from.to_owned(),
6566 to: to.to_owned(),
6567 reason: reason.to_owned(),
6568 });
6569}
6570
6571fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
6585 commands.iter().any(|c| c.exit_code.is_none())
6586}
6587
6588fn verified_noop_claim(
6601 usable: bool,
6602 commands: &[agent::CommandEvidence],
6603 text: &str,
6604) -> Option<String> {
6605 (usable && !has_unconfirmed_command(commands))
6606 .then(|| verdict::verified_noop(text))
6607 .flatten()
6608}
6609
6610fn short(commit: &str) -> String {
6611 commit.chars().take(7).collect()
6612}
6613
6614fn make_executable(path: &Path) -> Result<()> {
6615 #[cfg(unix)]
6616 {
6617 use std::os::unix::fs::PermissionsExt as _;
6618 let mut perms = std::fs::metadata(path)?.permissions();
6619 perms.set_mode(0o755);
6620 std::fs::set_permissions(path, perms)?;
6621 }
6622 #[cfg(not(unix))]
6623 {
6624 let _ = path;
6625 }
6626 Ok(())
6627}
6628
6629struct WaveCtx<'a> {
6636 run: &'a str,
6639 node: &'a str,
6641 prompts: &'a Prompts,
6642 cache: Option<&'a Path>,
6644 round: Option<usize>,
6647 carry_seats: bool,
6651}
6652
6653async fn run_one(
6655 job: SeatJob,
6656 sem: Arc<Semaphore>,
6657 ctx: &WaveCtx<'_>,
6658 state: &mut RunState,
6659 attempt: usize,
6660) -> (SeatState, AgentOutcome) {
6661 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
6662 .await
6663 .pop()
6664 .expect("one job in, one result out");
6665 (seat, out)
6666}
6667
6668async fn wave(
6674 jobs: Vec<SeatJob>,
6675 sem: Arc<Semaphore>,
6676 ctx: &WaveCtx<'_>,
6677 state: &mut RunState,
6678 attempt: usize,
6679) -> Vec<(usize, SeatState, AgentOutcome)> {
6680 let WaveCtx {
6681 run,
6682 node,
6683 prompts,
6684 cache,
6685 round,
6686 carry_seats: _,
6687 } = *ctx;
6688 for job in &jobs {
6689 state.seat_started(node, &job.seat.key, job.timeout, attempt);
6690 }
6691 if let Err(e) = state.save() {
6692 tracing::warn!("could not persist in-progress seats: {e:#}");
6697 }
6698 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
6714 let wait_started = Instant::now();
6715 let cache_guard = if let Some(cache_dir) = cache {
6716 if jobs_had_a_writer {
6717 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
6718 let budget = jobs
6719 .iter()
6720 .map(|j| j.timeout)
6721 .max()
6722 .unwrap_or(Duration::from_secs(60));
6723 acquire_cache_lease(state, cache_dir, &owner, budget, node)
6724 .await
6725 .ok()
6726 } else {
6727 None
6728 }
6729 } else {
6730 None
6731 };
6732 let waited_for_lease = wait_started.elapsed();
6739 let mut set = tokio::task::JoinSet::new();
6740 let overlay = prompts.overlay(node);
6741 for (i, mut job) in jobs.into_iter().enumerate() {
6742 job.timeout = job.timeout.saturating_sub(waited_for_lease);
6743 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
6744 if cache.is_some() {
6745 job.prompt.push('\n');
6746 job.prompt
6747 .push_str(&prompt::build_cache_note(node, job.allow_write));
6748 }
6749 let sem = Arc::clone(&sem);
6750 let run = run.to_owned();
6751 let node = node.to_owned();
6752 let attachments = if node == "implement" {
6755 state.attachments.clone()
6756 } else {
6757 Vec::new()
6758 };
6759 let cache = cache
6770 .filter(|_| job.allow_write && cache_guard.is_some())
6771 .map(Path::to_path_buf);
6772 set.spawn(async move {
6773 let _permit = sem.acquire().await;
6774 let mut seat = job.seat;
6775 let out = agent::invoke(
6776 &job.spec,
6777 &mut seat,
6778 &Invocation {
6779 cwd: &job.cwd,
6780 prompt: &job.prompt,
6781 timeout: job.timeout,
6782 allow_write: job.allow_write,
6783 sessions: job.sessions,
6784 artifacts: &job.artifacts,
6785 stem: &job.stem,
6786 run: &run,
6787 node: &node,
6788 cache_dir: cache.as_deref(),
6789 attachments: &attachments,
6790 writable: &[],
6791 },
6792 )
6793 .await;
6794 let out = match out {
6795 Ok(o) if o.usable() => AgentOutcome::Ok(o),
6796 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
6797 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
6805 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
6806 Ok(o) => AgentOutcome::Failed(format!(
6807 "exited with {:?} and no usable output",
6808 o.exit_code
6809 )),
6810 Err(e) => AgentOutcome::Failed(e.to_string()),
6811 };
6812 (i, seat, out)
6813 });
6814 }
6815 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
6816 while let Some(joined) = set.join_next().await {
6817 let (i, seat, out) = match joined {
6818 Ok(v) => v,
6819 Err(e) => {
6823 tracing::error!("agent task panicked: {e}");
6824 continue;
6825 }
6826 };
6827 state.seat_finished(&seat.key);
6828 record_jobs(state, node, round, &seat.key, &out);
6829 if let Err(e) = state.save() {
6830 tracing::warn!("could not persist a seat's completion: {e:#}");
6831 }
6832 if collected.len() <= i {
6833 collected.resize_with(i + 1, || None);
6834 }
6835 collected[i] = Some((i, seat, out));
6836 }
6837 if state
6843 .active
6844 .values()
6845 .any(|a| a.node == node && a.attempt == attempt)
6846 {
6847 state
6848 .active
6849 .retain(|_, a| !(a.node == node && a.attempt == attempt));
6850 if let Err(e) = state.save() {
6851 tracing::warn!("could not persist the end of a wave: {e:#}");
6852 }
6853 }
6854 if let Some(cache_dir) = cache
6861 && jobs_had_a_writer
6862 {
6863 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
6864 }
6865 if let Some(guard) = cache_guard {
6866 guard.release();
6867 }
6868 collected.into_iter().flatten().collect()
6869}
6870
6871fn record_jobs(
6882 state: &mut RunState,
6883 node: &str,
6884 round: Option<usize>,
6885 seat: &str,
6886 out: &AgentOutcome,
6887) {
6888 let commands: &[agent::CommandEvidence] = match out {
6889 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
6890 AgentOutcome::Failed(_) => &[],
6891 };
6892 let checked_at = Timestamp::now();
6893 for c in commands {
6894 state.jobs.push(JobRecord {
6895 node: node.to_owned(),
6896 round,
6897 seat: seat.to_owned(),
6898 id: c.id.clone(),
6899 description: c.description.clone(),
6900 checked_at,
6901 status: match c.exit_code {
6902 Some(0) => JobStatus::Completed,
6903 Some(_) => JobStatus::Failed,
6904 None => JobStatus::Unknown,
6905 },
6906 exit_code: c.exit_code,
6907 result_summary: c.result_summary.clone(),
6908 source: c.source.clone(),
6909 });
6910 }
6911}
6912
6913fn round_is_clean(
6934 blocking: usize,
6935 e2e_ok: bool,
6936 answered: usize,
6937 expected: usize,
6938 quota_missing: usize,
6939 policy: IncompleteReviewPolicy,
6940) -> bool {
6941 if blocking != 0 || !e2e_ok {
6942 return false;
6943 }
6944 if answered == expected || policy == IncompleteReviewPolicy::Warn {
6945 return true;
6946 }
6947 answered > 0 && expected - answered <= quota_missing
6948}
6949
6950fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
6974 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
6975 return Some(RunStatus::Gating);
6976 }
6977 let last = reviews.last()?;
6978 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
6979 if reviews.len() < max_rounds && !stagnant {
6980 return None;
6981 }
6982 if last.incomplete() && last.blocking == 0 {
6983 return Some(RunStatus::Blocked);
6984 }
6985 if last.e2e_status() == E2eStatus::ResourceBlocked {
6986 return None;
6987 }
6988 Some(if last.e2e.iter().all(CommandOutcome::ok) {
6989 RunStatus::Gating
6990 } else {
6991 RunStatus::Blocked
6992 })
6993}
6994
6995fn retry_budget(full: Duration, nudged: bool) -> Duration {
7010 if nudged {
7011 (full / 4).max(Duration::from_secs(120)).min(full)
7012 } else {
7013 full
7014 }
7015}
7016
7017#[allow(clippy::too_many_arguments)]
7046async fn ask_json_wave<T>(
7047 jobs: Vec<SeatJob>,
7048 sem: Arc<Semaphore>,
7049 retries: usize,
7050 roster: &[AgentSpec],
7051 ctx: &WaveCtx<'_>,
7052 losses: &mut Vec<QuotaLoss>,
7053 state: &mut RunState,
7054 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7055) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7056where
7057 T: serde::de::DeserializeOwned + Send + 'static,
7058{
7059 ask_wave_with(
7060 jobs,
7061 sem,
7062 retries,
7063 roster,
7064 ctx,
7065 losses,
7066 state,
7067 &|text: &str| {
7068 let v = verdict::extract_json::<T>(text)?;
7069 validate(&v)?;
7070 Ok(v)
7071 },
7072 )
7073 .await
7074}
7075
7076#[allow(clippy::too_many_arguments)]
7085async fn ask_wave_with<T>(
7086 jobs: Vec<SeatJob>,
7087 sem: Arc<Semaphore>,
7088 retries: usize,
7089 roster: &[AgentSpec],
7090 ctx: &WaveCtx<'_>,
7091 losses: &mut Vec<QuotaLoss>,
7092 state: &mut RunState,
7093 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7094) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7095where
7096 T: Send + 'static,
7097{
7098 let n = jobs.len();
7099 let originals: Vec<SeatJob> = jobs;
7100 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7101 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7102 let mut nudges: Vec<usize> = vec![0; n];
7109 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7113 let mut tried: Vec<BTreeSet<String>> = specs
7114 .iter()
7115 .map(|s| BTreeSet::from([s.id.clone()]))
7116 .collect();
7117 let starts: Vec<usize> = specs
7118 .iter()
7119 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7120 .collect();
7121 let carry = ctx.carry_seats && !roster.is_empty();
7122 let carried: Vec<BTreeSet<String>> = originals
7125 .iter()
7126 .map(|j| {
7127 state
7128 .seat_history
7129 .get(&j.seat.key)
7130 .filter(|_| carry)
7131 .map(|h| h.failed.clone())
7132 .unwrap_or_default()
7133 })
7134 .collect();
7135 let mut prev: Vec<Option<FailClass>> = originals
7136 .iter()
7137 .map(|j| {
7138 state
7139 .seat_history
7140 .get(&j.seat.key)
7141 .filter(|_| carry)
7142 .and_then(|h| h.last_fail.clone())
7143 })
7144 .collect();
7145 let next_agent = |i: usize, tried: &BTreeSet<String>| -> Option<AgentSpec> {
7146 if carry {
7147 next_for_seat(roster, starts[i], tried, &carried[i]).cloned()
7148 } else {
7149 next_untried_in_roster(roster, starts[i], tried).cloned()
7150 }
7151 };
7152 let mut fresh: Vec<Option<String>> = vec![None; n];
7153 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7154 let mut pending: Vec<usize> = (0..n).collect();
7155
7156 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7161 for round in 0..max_rounds {
7162 if pending.is_empty() {
7163 break;
7164 }
7165 let mut batch = Vec::with_capacity(pending.len());
7166 let mut renudged: Vec<&str> = Vec::new();
7167 for &i in &pending {
7168 let src = &originals[i];
7169 let full: &str = match &src.handover {
7174 Some(h) if specs[i].id != src.spec.id => h,
7175 _ => &src.prompt,
7176 };
7177 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7180 (
7181 full.to_owned(),
7182 src.timeout,
7183 format!("{}-{word}-{}", src.stem, specs[i].id),
7184 )
7185 } else if nudges[i] == 0 {
7186 (full.to_owned(), src.timeout, src.stem.clone())
7187 } else {
7188 renudged.push(src.seat.key.as_str());
7189 let why = done[i]
7190 .as_ref()
7191 .and_then(|r| r.as_ref().err().map(ToString::to_string))
7192 .unwrap_or_else(|| "no parsable answer".to_owned());
7193 let nudge = prompt::nudge(&why);
7194 let nudged = has_context(&specs[i], &seats[i], src.sessions);
7195 let prompt = if nudged {
7196 nudge
7197 } else {
7198 format!("{full}\n\n---\n\n{nudge}")
7199 };
7200 (
7201 prompt,
7202 retry_budget(src.timeout, nudged),
7203 format!("{}-retry{}", src.stem, nudges[i]),
7204 )
7205 };
7206 batch.push(SeatJob {
7207 spec: specs[i].clone(),
7208 seat: seats[i].clone(),
7209 cwd: src.cwd.clone(),
7210 prompt,
7211 timeout,
7212 allow_write: src.allow_write,
7213 sessions: src.sessions,
7214 artifacts: src.artifacts.clone(),
7215 stem,
7216 handover: None,
7217 });
7218 }
7219
7220 if !renudged.is_empty() {
7221 state.event(
7222 ctx.node,
7223 format!("retry {round}: re-asking {}", renudged.join(", ")),
7224 );
7225 }
7226 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7227 let mut still = Vec::new();
7228 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7229 seats[i] = seat;
7230 let class = FailClass::of(&out);
7231 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7234 && nudges[i] < retries
7235 && !roster.is_empty();
7236 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7237 let next = should_hand_over(prev[i].as_ref(), &cur)
7238 .then(|| next_agent(i, &tried[i]))
7239 .flatten();
7240 if carry {
7241 let h = state
7242 .seat_history
7243 .entry(originals[i].seat.key.clone())
7244 .or_default();
7245 h.failed.insert(specs[i].id.clone());
7246 h.last_fail = Some(cur.clone());
7247 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7248 h.last_ok = None;
7249 }
7250 if let Err(e) = state.save() {
7253 tracing::warn!("could not persist a seat's failure history: {e:#}");
7254 }
7255 }
7256 if let Some(next) = next {
7257 record_handover(
7258 state,
7259 ctx.node,
7260 &originals[i].seat.key,
7261 &specs[i].id,
7262 &next.id,
7263 &cur,
7264 &fail_reason(&out),
7265 );
7266 tried[i].insert(next.id.clone());
7267 prev[i] = Some(cur.clone());
7268 seats[i] =
7269 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7270 specs[i] = next;
7271 fresh[i] = Some(cur.stem_word().to_owned());
7272 nudges[i] = 0;
7273 done[i] = Some(Err(anyhow::anyhow!(
7274 "handed over after: {}",
7275 fail_reason(&out)
7276 )));
7277 still.push(i);
7278 continue;
7279 }
7280 }
7281 if carry
7282 && !nudge_first
7283 && let Some(cur) = class.clone()
7284 {
7285 let h = state
7287 .seat_history
7288 .entry(originals[i].seat.key.clone())
7289 .or_default();
7290 h.failed.insert(specs[i].id.clone());
7291 h.last_fail = Some(cur);
7292 }
7293 let parsed = match out {
7294 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7295 AgentOutcome::Quota(o) => {
7296 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7297 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7298 }
7299 AgentOutcome::Dropped(o) => {
7303 let why = o
7304 .dropped
7305 .as_ref()
7306 .map(|d| d.why.as_str())
7307 .unwrap_or("the CLI ended the stream without delivering its answer");
7308 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7309 }
7310 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7311 };
7312 let quota = class == Some(FailClass::Quota);
7313 let failed = parsed.is_err();
7314 done[i] = Some(parsed);
7315 if carry && !failed {
7316 let h = state
7317 .seat_history
7318 .entry(originals[i].seat.key.clone())
7319 .or_default();
7320 h.failed.remove(&specs[i].id);
7321 h.last_ok = Some(specs[i].id.clone());
7322 h.last_fail = None;
7323 }
7324 let agent_failure = class.is_some()
7332 && !nudge_first
7333 && !roster.is_empty()
7334 && next_agent(i, &tried[i]).is_some();
7335 if failed && !quota && !agent_failure && nudges[i] < retries {
7336 nudges[i] += 1;
7337 still.push(i);
7338 }
7339 }
7340 pending = still;
7341 }
7342
7343 for (i, q) in last_quota.into_iter().enumerate() {
7346 if let Some(reset) = q {
7347 losses.push(QuotaLoss {
7348 seat: originals[i].seat.key.clone(),
7349 node: ctx.node.to_owned(),
7350 at: Timestamp::now(),
7351 reset,
7352 });
7353 }
7354 }
7355
7356 seats
7357 .into_iter()
7358 .zip(done)
7359 .zip(nudges)
7360 .map(|((seat, res), attempts)| {
7361 (
7362 seat,
7363 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7364 attempts,
7365 )
7366 })
7367 .collect()
7368}
7369
7370async fn acquire_cache_lease(
7383 state: &mut RunState,
7384 cache_dir: &Path,
7385 owner: &crate::cache::Owner,
7386 budget: Duration,
7387 context: &str,
7388) -> Result<crate::cache::Guard> {
7389 let home = crate::run::home();
7390 let started = Instant::now();
7391 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7392 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7393 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7394 Err(e) => {
7395 state.event(
7396 "verify",
7397 format!("{context}: could not check the shared build cache: {e:#}"),
7398 );
7399 if let Err(e2) = state.save() {
7400 tracing::warn!("could not persist a cache-check failure: {e2:#}");
7401 }
7402 return Err(e);
7403 }
7404 };
7405 state.event(
7406 "verify",
7407 format!(
7408 "{context}: waiting for the shared build cache at {} ({})",
7409 cache_dir.display(),
7410 busy.describe()
7411 ),
7412 );
7413 if let Err(e) = state.save() {
7414 tracing::warn!("could not persist a cache wait: {e:#}");
7415 }
7416 let remaining = budget.saturating_sub(started.elapsed());
7417 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7418 Ok(g) => Ok(g),
7419 Err(e) => {
7420 state.event("verify", format!("{context}: {e:#}"));
7421 if let Err(e2) = state.save() {
7422 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7423 }
7424 Err(e)
7425 }
7426 }
7427}
7428
7429#[allow(clippy::too_many_arguments)]
7450async fn with_cache_lease<'s, F, Fut>(
7451 state: &'s mut RunState,
7452 cache_dir: Option<&Path>,
7453 node: &str,
7454 seat: &str,
7455 worktree: &Path,
7456 head: &str,
7457 budget: Duration,
7458 context: &str,
7459 body: F,
7460) -> (Vec<CommandOutcome>, bool)
7461where
7462 F: FnOnce(&'s mut RunState, Duration) -> Fut,
7463 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7464{
7465 let Some(cache_dir) = cache_dir else {
7466 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7467 return (outcomes, retried);
7468 };
7469 let home = crate::run::home();
7470 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7471 let started = Instant::now();
7472 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
7473 Ok(g) => g,
7474 Err(e) => {
7475 return (
7476 vec![CommandOutcome {
7477 command: "(waiting for the shared build cache)".to_owned(),
7478 code: None,
7479 output_tail: e.to_string(),
7480 duration_ms: started.elapsed().as_millis() as u64,
7481 resource_blocked: true,
7482 }],
7483 false,
7484 );
7485 }
7486 };
7487 let identity = crate::cache::Identity::new(worktree, head);
7488 if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
7489 state.event(
7497 "verify",
7498 format!(
7499 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
7500 worktree.display(),
7501 short(head)
7502 ),
7503 );
7504 guard.release();
7505 return (
7506 vec![CommandOutcome {
7507 command: "(confirming the shared build cache is fresh)".to_owned(),
7508 code: None,
7509 output_tail: e.to_string(),
7510 duration_ms: started.elapsed().as_millis() as u64,
7511 resource_blocked: true,
7512 }],
7513 false,
7514 );
7515 }
7516 let remaining = budget.saturating_sub(started.elapsed());
7517 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
7518 if !timed_out_pids.is_empty() {
7523 wait_for_timed_out_children_to_die(&timed_out_pids).await;
7524 }
7525 guard.release();
7526 (outcomes, retried)
7527}
7528
7529async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
7541 wait_for_pids_with(
7542 pids,
7543 crate::proc::pid_alive,
7544 LEASE_RELEASE_POLL,
7545 LEASE_RELEASE_MAX_WAIT,
7546 )
7547 .await;
7548}
7549
7550async fn wait_for_pids_with<F: Fn(u32) -> bool>(
7556 pids: &[u32],
7557 alive: F,
7558 poll: Duration,
7559 max_wait: Duration,
7560) {
7561 let deadline = Instant::now() + max_wait;
7562 loop {
7563 if pids.iter().all(|&pid| !alive(pid)) {
7564 return;
7565 }
7566 if Instant::now() >= deadline {
7567 return;
7568 }
7569 tokio::time::sleep(poll).await;
7570 }
7571}
7572
7573fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
7580 outcomes.iter().any(|o| o.resource_blocked)
7581}
7582
7583enum GateFix {
7585 Retry,
7587 Stop,
7590 Defer,
7593}
7594
7595fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
7603 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
7604 red.peek().is_some()
7605 && red.all(|o| {
7606 !o.resource_blocked
7607 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
7608 && !o.output_tail.trim().is_empty()
7609 })
7610}
7611
7612fn e2e_outcome_label(o: &CommandOutcome) -> String {
7616 if o.ok() {
7617 return "pass".to_owned();
7618 }
7619 let reason = if o.build_failed() {
7620 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
7621 } else {
7622 format!("FAIL ({:?})", o.code)
7623 };
7624 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
7625}
7626
7627async fn run_e2e_with_retry(
7635 state: &mut RunState,
7636 shell: &[String],
7637 commands: &[String],
7638 worktree: &Path,
7639 timeout: Duration,
7640 context: &str,
7641) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
7642 let (mut e2e, mut timed_out_pids) = run_commands(
7643 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
7644 )
7645 .await;
7646 for o in &e2e {
7647 state.event(
7648 "verify",
7649 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
7650 );
7651 }
7652 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
7656 if verify_retried {
7657 state.event(
7658 "verify",
7659 format!(
7660 "{context}: verify could not build/link, not a test result — retrying once \
7661 before concluding"
7662 ),
7663 );
7664 let retried = run_commands(
7665 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
7666 )
7667 .await;
7668 e2e = retried.0;
7669 timed_out_pids.extend(retried.1);
7672 for o in &e2e {
7673 state.event(
7674 "verify",
7675 format!(
7676 "{context}: retry `{}` -> {}",
7677 o.command,
7678 e2e_outcome_label(o)
7679 ),
7680 );
7681 }
7682 }
7683 (e2e, verify_retried, timed_out_pids)
7684}
7685
7686#[allow(clippy::too_many_arguments)]
7702async fn run_commands(
7703 state: &mut RunState,
7704 node: &str,
7705 task: &str,
7706 attempt: usize,
7707 shell: &[String],
7708 commands: &[String],
7709 cwd: &Path,
7710 timeout: Duration,
7711) -> (Vec<CommandOutcome>, Vec<u32>) {
7712 if commands.is_empty() {
7713 return (Vec::new(), Vec::new());
7718 }
7719 let mut out = Vec::new();
7720 let mut timed_out_pids = Vec::new();
7721 let total = commands.len();
7722 for (idx, command) in commands.iter().enumerate() {
7723 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
7724 if let Err(e) = state.save() {
7725 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
7726 }
7727 let started = Instant::now();
7728 let mut cmd = tokio::process::Command::new(&shell[0]);
7729 cmd.quiet();
7730 cmd.args(&shell[1..])
7731 .arg(command)
7732 .current_dir(cwd)
7733 .stdin(std::process::Stdio::null())
7734 .stdout(std::process::Stdio::piped())
7735 .stderr(std::process::Stdio::piped())
7736 .kill_on_drop(true);
7737 let spawned = cmd.spawn();
7738 let (code, body) = match spawned {
7739 Ok(child) => {
7740 let pid = child.id();
7745 match tokio::time::timeout(timeout, child.wait_with_output()).await {
7746 Ok(Ok(o)) => {
7747 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
7748 body.push_str(&String::from_utf8_lossy(&o.stderr));
7749 (o.status.code(), body)
7750 }
7751 Ok(Err(e)) => (None, format!("failed to run: {e}")),
7752 Err(_) => {
7753 if let Some(pid) = pid {
7754 timed_out_pids.push(pid);
7755 }
7756 (None, format!("timed out after {}s", timeout.as_secs()))
7757 }
7758 }
7759 }
7760 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
7761 };
7762 out.push(CommandOutcome {
7763 command: command.clone(),
7764 code,
7765 output_tail: tail(&body, OUTPUT_TAIL),
7766 duration_ms: started.elapsed().as_millis() as u64,
7767 resource_blocked: false,
7768 });
7769 }
7770 state.task_finished(task);
7771 if let Err(e) = state.save() {
7772 tracing::warn!("could not persist the end of {task}: {e:#}");
7773 }
7774 (out, timed_out_pids)
7775}
7776
7777fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
7789 let repo = repo.display();
7790 match style {
7791 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
7792 MergeStyle::Squash => {
7793 let subject = message
7796 .lines()
7797 .next()
7798 .unwrap_or(branch)
7799 .replace(['\\', '"', '$', '`'], "");
7800 format!(
7801 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
7802 )
7803 }
7804 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
7805 }
7806}
7807
7808const PR_TITLE_MAX: usize = 240;
7819
7820fn english_title(text: &str, max: usize) -> Option<String> {
7831 let line = queue::first_line(text)?;
7832 let chars: Vec<char> = line.chars().collect();
7833 let (kept, mark) = if chars.len() <= max {
7834 (line.to_owned(), "")
7835 } else if let Some(end) = sentence_end(&chars, max) {
7836 (chars[..end].iter().collect::<String>(), "")
7837 } else {
7838 let room = max.saturating_sub(3);
7839 let cut = if chars[room].is_whitespace() {
7842 room
7843 } else {
7844 chars[..room]
7845 .iter()
7846 .rposition(|c| c.is_whitespace())
7847 .unwrap_or(room)
7848 };
7849 let head: String = chars[..cut].iter().collect();
7850 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
7851 (head.to_owned(), "...")
7852 };
7853 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
7854 return None;
7855 }
7856 Some(format!("{kept}{mark}"))
7857}
7858
7859fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
7863 const MIN: usize = 20;
7864 for i in MIN..max.min(chars.len()) {
7865 if !matches!(chars[i], '.' | '!' | '?') {
7866 continue;
7867 }
7868 let Some(&next) = chars.get(i + 1) else {
7869 continue;
7870 };
7871 if !next.is_whitespace() {
7872 continue;
7873 }
7874 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
7875 if after.is_some_and(|c| c.is_ascii_lowercase()) {
7876 continue;
7877 }
7878 let word: String = chars[..i]
7879 .iter()
7880 .rev()
7881 .take_while(|c| !c.is_whitespace())
7882 .collect::<Vec<_>>()
7883 .into_iter()
7884 .rev()
7885 .collect();
7886 let word = word.to_ascii_lowercase();
7887 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
7888 continue;
7889 }
7890 let end = chars[..i]
7891 .iter()
7892 .rposition(|c| !c.is_whitespace())
7893 .map_or(i, |p| p + 1);
7894 return Some(end);
7895 }
7896 None
7897}
7898
7899struct PrMessage {
7904 title: String,
7905 body: String,
7906}
7907
7908impl PrMessage {
7909 fn commit_message(&self) -> String {
7913 format!("{}\n\n{}", self.title, self.body)
7914 }
7915}
7916
7917fn title_marker(line: &str) -> Option<&str> {
7919 let line = line.trim();
7920 let head = line.get(..6)?;
7921 head.eq_ignore_ascii_case("title:")
7922 .then(|| line[6..].trim())
7923}
7924
7925fn summary_title(summary: &str) -> Option<String> {
7930 let first = summary.lines().find(|l| !l.trim().is_empty())?;
7931 let raw = title_marker(first)?;
7932 if raw.is_empty() {
7933 return None;
7934 }
7935 let title = queue::title_from(raw, PR_TITLE_MAX);
7936 let lower = title.to_ascii_lowercase();
7937 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7938 return None;
7939 }
7940 Some(title)
7941}
7942
7943const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
7945
7946const EXISTING_BRANCH: &str = "(existing branch)";
7948
7949fn is_review_run(state: &RunState) -> bool {
7953 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
7954}
7955
7956fn review_title(state: &RunState) -> Option<String> {
7961 english_subject(state.reviewed_commits.as_ref()?.first()?)
7962}
7963
7964fn english_subject(raw: &str) -> Option<String> {
7967 let raw = raw.trim();
7968 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
7969 return None;
7970 }
7971 let title = queue::title_from(raw, PR_TITLE_MAX);
7972 let lower = title.to_ascii_lowercase();
7973 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7974 return None;
7975 }
7976 Some(title)
7977}
7978
7979#[derive(Debug, Clone, PartialEq, Eq)]
7982struct BranchFacts {
7983 commits: Vec<(String, String)>,
7985 stat: String,
7987}
7988
7989const STAT_FILE_LINES: usize = 25;
7991const COMMITS_MAX_CHARS: usize = 20_000;
7993
7994async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
7997 let commits = git::commit_log(repo, base, branch).await.ok()?;
7998 if commits.is_empty() {
7999 return None;
8000 }
8001 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8002 let lines: Vec<&str> = stat.lines().collect();
8003 let stat = if lines.len() > STAT_FILE_LINES + 1 {
8004 let omitted = lines.len() - 1 - STAT_FILE_LINES;
8005 let more = format!(" ... {omitted} more file(s)");
8006 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8007 kept.push(&more);
8008 kept.push(lines[lines.len() - 1]);
8009 kept.join("\n")
8010 } else {
8011 lines.join("\n")
8012 };
8013 Some(BranchFacts { commits, stat })
8014}
8015
8016fn markdown_safe(text: &str) -> String {
8019 text.replace("</details>", "</details>")
8020 .replace("\x60\x60\x60", "~~~")
8021}
8022
8023fn neutral_title(state: &RunState, winner: char) -> String {
8024 format!(
8025 "chore: land candidate {} of run {}",
8026 winner.to_ascii_uppercase(),
8027 state.id
8028 )
8029}
8030
8031pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8037 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8038 ""
8039 } else {
8040 pr_title
8041 }
8042}
8043
8044pub fn landing_subject_source(state: &RunState) -> String {
8048 if is_review_run(state) {
8049 let winner = state.candidates.first().map_or('A', |c| c.label);
8050 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8051 }
8052 state.instruction.clone()
8053}
8054
8055fn summary_without_title(summary: &str) -> String {
8058 let mut lines = summary.trim().lines().peekable();
8059 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8060 lines.next();
8061 }
8062 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8063}
8064
8065#[cfg(test)]
8079fn pr_message(state: &RunState, winner: char) -> PrMessage {
8080 pr_message_with(state, winner, None)
8081}
8082
8083fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8086 let summary = state
8087 .candidates
8088 .iter()
8089 .find(|c| c.label == winner)
8090 .map(|c| c.summary.as_str())
8091 .unwrap_or_default();
8092 let review = is_review_run(state);
8095 let title = if review {
8096 facts
8097 .and_then(|f| english_subject(&f.commits.first()?.0))
8098 .or_else(|| review_title(state))
8099 .or_else(|| {
8100 state
8101 .candidates
8102 .iter()
8103 .find(|c| c.label == winner)
8104 .filter(|c| !c.branch.starts_with("magi/"))
8105 .and_then(|c| english_subject(&c.branch))
8106 })
8107 .unwrap_or_else(|| neutral_title(state, winner))
8108 } else {
8109 summary_title(summary).unwrap_or_else(|| {
8110 english_title(&state.instruction, PR_TITLE_MAX)
8111 .unwrap_or_else(|| neutral_title(state, winner))
8112 })
8113 };
8114
8115 let mut body = String::new();
8116 let what = summary_without_title(summary);
8117 if !what.is_empty() {
8118 body.push_str("## Summary\n\n");
8119 body.push_str(&what);
8120 body.push_str("\n\n");
8121 }
8122
8123 let notes: Vec<(usize, &str)> = state
8126 .reviews
8127 .iter()
8128 .filter_map(|r| {
8129 let n = r.fix.as_ref()?.notes.trim();
8130 (!n.is_empty()).then_some((r.round, n))
8131 })
8132 .collect();
8133 if !notes.is_empty() {
8134 body.push_str("## Review fixes\n\n");
8135 if let [(_, only)] = notes.as_slice() {
8136 body.push_str(only);
8137 body.push_str("\n\n");
8138 } else {
8139 for (round, n) in ¬es {
8140 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8141 }
8142 }
8143 }
8144 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8145
8146 let open = state.open_findings();
8147 if !open.is_empty() {
8148 body.push_str("## Open review findings\n\n");
8149 for f in &open {
8150 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8151 }
8152 body.push('\n');
8153 }
8154
8155 if let Some(fix) = fix
8156 && !fix.rejected.is_empty()
8157 {
8158 body.push_str("## Declined by the fixer\n\n");
8159 for r in &fix.rejected {
8160 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8161 }
8162 body.push('\n');
8163 }
8164
8165 if review {
8166 body.push_str("## Commits under review\n\n");
8168 if let Some(facts) = facts {
8169 let mut left = COMMITS_MAX_CHARS;
8170 for (i, (subject, text)) in facts.commits.iter().enumerate() {
8171 let mut entry = format!("- {}\n", markdown_safe(subject));
8172 for l in markdown_safe(text).lines() {
8173 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
8174 }
8175 if left == 0 {
8176 body.push_str(&format!(
8177 "- ... {} more commit(s)\n",
8178 facts.commits.len() - i
8179 ));
8180 break;
8181 }
8182 if entry.len() > left {
8183 let mut end = left;
8186 while !entry.is_char_boundary(end) {
8187 end -= 1;
8188 }
8189 entry.truncate(end);
8190 entry.push_str("\n ... (truncated)\n");
8191 left = 0;
8192 } else {
8193 left -= entry.len();
8194 }
8195 body.push_str(&entry);
8196 }
8197 if !facts.stat.trim().is_empty() {
8198 body.push_str(&format!(
8199 "\n## Diff stat\n\n```\n{}\n```\n",
8200 markdown_safe(facts.stat.trim())
8201 ));
8202 }
8203 } else {
8204 match &state.reviewed_commits {
8205 Some(subjects) => {
8206 for s in subjects {
8207 body.push_str(&format!("- {}\n", s.trim()));
8208 }
8209 }
8210 None => {
8211 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8214 body.push_str(rest.trim());
8215 body.push('\n');
8216 }
8217 }
8218 }
8219 } else {
8220 let task = state.instruction.trim();
8221 let task = if task.is_empty() {
8222 "(empty task)"
8223 } else {
8224 task
8225 };
8226 body.push_str(&format!(
8227 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8228 task.replace("</details>", "</details>")
8229 ));
8230 }
8231
8232 body.push_str(&format!(
8233 "\n---\nmagi:run/{} magi:candidate-{}\n",
8234 state.id,
8235 winner.to_ascii_lowercase()
8236 ));
8237
8238 let id = crate::scrub::Identity::current();
8241 PrMessage {
8242 title: crate::scrub::scrub(&title, &id),
8243 body: crate::scrub::scrub(&body, &id),
8244 }
8245}
8246
8247fn seeded_instruction(state: &RunState) -> String {
8251 match refs::describe(&state.seeds) {
8252 Some(facts) => format!(
8253 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8254 Candidates start from the unmerged branch named above, when there \
8255 is one, and carry any unmerged commit named by sha as a \
8256 cherry-pick. Check that this is what the task meant before \
8257 building on it.",
8258 state.instruction
8259 ),
8260 None => state.instruction.clone(),
8261 }
8262}
8263
8264async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8268 let base = &state.base_branch;
8269 let mut against = base.clone();
8270 if mode == MergeMode::Pr {
8271 let remote = &state.config.merge.remote;
8272 let tracking = format!("{remote}/{base}");
8273 let fetched = git::fetch(repo, remote, base).await;
8274 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8275 against = tracking;
8276 }
8277 }
8278 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8279}
8280
8281fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8284 let mut detail = format!(
8285 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8286 nothing to open a pull request for"
8287 );
8288 match refs::describe(&state.seeds) {
8289 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8290 None => detail.push_str(
8291 "\nThe task names no existing branch or commit; if it means to land work \
8292 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8293 ),
8294 }
8295 detail
8296}
8297
8298#[derive(Debug, PartialEq, Eq)]
8301enum PrPlan {
8302 Create,
8303 Adopt { url: String, title: String },
8304 Stop(String),
8305}
8306
8307fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8310 match found {
8311 Ok(land::OpenPr::None) => PrPlan::Create,
8312 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8313 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8314 "several open pull requests exist for this branch, not picking one: {}",
8315 urls.join(" ")
8316 )),
8317 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8318 }
8319}
8320
8321async fn gh_pr_create(
8323 cwd: &Path,
8324 base: &str,
8325 head: &str,
8326 title: &str,
8327 body: &str,
8328) -> Result<String> {
8329 let out = tokio::process::Command::new("gh")
8330 .args([
8331 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8332 ])
8333 .current_dir(cwd)
8334 .quiet()
8335 .stdin(std::process::Stdio::null())
8336 .output()
8337 .await
8338 .context("spawn gh")?;
8339 if out.status.success() {
8340 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8341 } else {
8342 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8343 }
8344}
8345
8346pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8355 let repo = state.repo.clone();
8356 let root = state.worktree_root();
8357 let winner = state.tally.as_ref().map(|t| t.winner);
8358 let mut removed = Vec::new();
8359
8360 for i in 0..state.candidates.len() {
8361 let c = state.candidates[i].clone();
8362 let is_winner = Some(c.label) == winner;
8363 if is_winner && !drop_winner {
8364 continue;
8365 }
8366 if c.worktree.exists() {
8367 git::worktree_remove(&repo, &c.worktree).await.ok();
8368 removed.push(c.worktree.to_string_lossy().into_owned());
8369 }
8370 let handed_over = state.released_branches.contains(&c.branch);
8373 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8374 git::branch_delete(&repo, &c.branch).await.ok();
8375 removed.push(c.branch.clone());
8376 }
8377 state.candidates[i].folded = true;
8378 }
8379
8380 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8381 let path = name.path();
8382 let keep = !drop_winner
8383 && winner.is_some_and(|w| {
8384 path.file_name()
8385 .is_some_and(|n| n == format!("cand-{w}").as_str())
8386 });
8387 if keep {
8388 continue;
8389 }
8390 git::worktree_remove(&repo, &path).await.ok();
8391 removed.push(path.to_string_lossy().into_owned());
8392 }
8393
8394 remove_if_empty(&root);
8403
8404 if state.enabled_worktree_config && drop_winner {
8405 git::release_worktree_config(&repo).await.ok();
8409 state.enabled_worktree_config = false;
8410 }
8411 state.save_under(home)?;
8412 Ok(removed)
8413}
8414
8415fn remove_if_empty(dir: &Path) {
8426 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
8427 std::fs::remove_dir(dir).ok();
8428 }
8429}
8430
8431pub fn worst_open(state: &RunState) -> Option<Severity> {
8433 state
8434 .reviews
8435 .last()?
8436 .reviews
8437 .iter()
8438 .flat_map(|r| r.findings.iter())
8439 .map(|f| f.severity)
8440 .max()
8441}
8442
8443#[cfg(test)]
8444mod tests {
8445 #[test]
8446 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
8447 let leaked = vec!["chore(deps): update a crate".to_owned()];
8448 let own = "fix(daemon): apply a chosen action";
8449 assert!(should_retitle("", own, &leaked));
8450 assert!(should_retitle(
8451 &format!("{REVIEW_PROMPT_OPENING} `x`"),
8452 own,
8453 &leaked
8454 ));
8455 assert!(should_retitle(
8456 "chore: land candidate A of run 1",
8457 own,
8458 &leaked
8459 ));
8460 assert!(should_retitle(
8461 "magi: candidate A (uncommitted work)",
8462 own,
8463 &leaked
8464 ));
8465 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
8466 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
8467 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
8468 }
8469
8470 #[test]
8471 fn pr_merge_plan_creates_adopts_or_stops() {
8472 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
8473 assert_eq!(
8474 pr_merge_plan(Ok(land::OpenPr::One {
8475 url: "u".into(),
8476 title: "t".into()
8477 })),
8478 PrPlan::Adopt {
8479 url: "u".into(),
8480 title: "t".into()
8481 }
8482 );
8483 let PrPlan::Stop(many) =
8484 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
8485 else {
8486 panic!("many must stop");
8487 };
8488 assert!(many.contains('a') && many.contains('b'));
8489 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
8490 panic!("a failed lookup must stop");
8491 };
8492 assert!(err.contains("bad token"));
8493 }
8494
8495 use super::*;
8496 use crate::run::GateStatus;
8497 use std::collections::BTreeMap;
8498 use std::time::Duration;
8499
8500 fn conductor() -> AgentSpec {
8501 AgentSpec {
8502 id: "conductor".to_owned(),
8503 kind: crate::config::AgentKind::Command,
8504 model: None,
8505 command: vec!["true".to_owned()],
8506 extra_args: Vec::new(),
8507 env: BTreeMap::new(),
8508 prompt_delivery: None,
8509 }
8510 }
8511
8512 fn spec(id: &str) -> AgentSpec {
8513 AgentSpec {
8514 id: id.to_owned(),
8515 kind: crate::config::AgentKind::Command,
8516 model: None,
8517 command: vec!["true".to_owned()],
8518 extra_args: Vec::new(),
8519 env: BTreeMap::new(),
8520 prompt_delivery: None,
8521 }
8522 }
8523
8524 fn ids(xs: &[&str]) -> BTreeSet<String> {
8525 xs.iter().map(|s| (*s).to_owned()).collect()
8526 }
8527
8528 #[test]
8529 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
8530 let roster = [spec("a"), spec("b"), spec("c")];
8531 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
8532 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8533 }
8534
8535 #[test]
8536 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
8537 let roster = [spec("a"), spec("b"), spec("c")];
8538 let failed = ids(&["a", "b", "c"]);
8539 let mut tried = ids(&["b"]);
8541 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8542 assert_eq!(first.id, "a");
8543 tried.insert(first.id.clone());
8544 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8545 assert_eq!(second.id, "c");
8546 tried.insert(second.id.clone());
8547 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
8548 }
8549
8550 #[test]
8551 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
8552 let roster = [spec("a"), spec("b")];
8553 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
8554 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8555 }
8556
8557 #[test]
8558 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
8559 let roster = [spec("a"), spec("b"), spec("c")];
8560 let h = SeatHistory {
8561 failed: ids(&["a"]),
8562 last_ok: Some("b".to_owned()),
8563 last_fail: None,
8564 };
8565 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
8566 let gone = SeatHistory {
8568 last_ok: Some("zzz".to_owned()),
8569 ..h.clone()
8570 };
8571 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
8572 let failed = SeatHistory {
8573 failed: ids(&["a", "b"]),
8574 last_ok: Some("b".to_owned()),
8575 last_fail: None,
8576 };
8577 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
8578 }
8579
8580 #[test]
8581 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
8582 let first = SeatState::new("review-1", "alpha", 7);
8583 let next = handover_seat("review-1", "gamma", 7);
8584 assert_ne!(first.claude_session, next.claude_session);
8585 }
8586
8587 #[test]
8588 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
8589 let mut state = state_with_summary("x", "y");
8590 let a = handover_seat("review-1", "beta", state.next_seat_seed());
8591 let b = handover_seat("review-1", "beta", state.next_seat_seed());
8592 assert_ne!(a.claude_session, b.claude_session);
8593 }
8594
8595 #[test]
8596 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
8597 let roster = [spec("a"), spec("b")];
8598 let h = SeatHistory {
8599 failed: ids(&["a", "b"]),
8600 ..SeatHistory::default()
8601 };
8602 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
8603 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
8604 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
8605 }
8606
8607 #[test]
8614 fn failure_signature_ignores_numbers_and_paths() {
8615 assert_eq!(
8616 failure_signature("exited with Some(2) and no usable output"),
8617 failure_signature("exited with Some(137) and no usable output")
8618 );
8619 assert_eq!(
8620 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
8621 failure_signature("cannot open /var/x.txt: denied")
8622 );
8623 assert_ne!(failure_signature("boom"), failure_signature("bang"));
8624 }
8625
8626 #[test]
8627 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
8628 let other = FailClass::Other("x".into());
8629 assert!(should_hand_over(None, &FailClass::Quota));
8630 assert!(should_hand_over(Some(&other), &FailClass::Quota));
8631 assert!(should_hand_over(
8632 Some(&FailClass::Timeout),
8633 &FailClass::Timeout
8634 ));
8635 assert!(should_hand_over(None, &other));
8636 assert!(!should_hand_over(Some(&other), &other));
8637 assert!(should_hand_over(
8638 Some(&other),
8639 &FailClass::Other("y".into())
8640 ));
8641 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
8643 assert!(should_hand_over(Some(&FailClass::Quota), &other));
8644 }
8645
8646 #[test]
8647 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
8648 let a = SeatState::new("judge-1", "alpha", 7);
8649 let b = handover_seat("judge-1", "beta", 7);
8650 assert_ne!(a.claude_session, b.claude_session);
8651 assert_eq!(b.turns, 0);
8652 }
8653
8654 #[test]
8655 fn a_timeout_is_classified_apart_from_other_failures() {
8656 assert_eq!(
8657 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
8658 Some(FailClass::Timeout)
8659 );
8660 assert!(matches!(
8661 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
8662 Some(FailClass::Other(_))
8663 ));
8664 }
8665
8666 #[test]
8667 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
8668 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8669 let tried = BTreeSet::from(["beta".to_owned()]);
8670 let next = next_untried_in_roster(&roster, 1, &tried);
8673 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
8674 }
8675
8676 #[test]
8677 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
8678 let roster = vec![spec("alpha"), spec("beta")];
8679 let tried = BTreeSet::from(["beta".to_owned()]);
8680 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8684 }
8685
8686 #[test]
8687 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
8688 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8689 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
8690 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8694 }
8695
8696 #[test]
8697 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
8698 let roster = vec![spec("a"), spec("a"), spec("b")];
8699 let tried = BTreeSet::from(["a".to_owned()]);
8700 let next = next_untried_in_roster(&roster, 0, &tried);
8701 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8702 }
8703
8704 #[test]
8705 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
8706 let roster = vec![spec("a"), spec("b")];
8707 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
8708 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
8709 }
8710
8711 #[test]
8712 fn remove_if_empty_only_ever_takes_a_bare_directory() {
8713 let dir = tempfile::tempdir().unwrap();
8714 let bay = dir.path().join("ffff");
8715
8716 remove_if_empty(&bay);
8718 assert!(!bay.exists());
8719
8720 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
8723 remove_if_empty(&bay);
8724 assert!(bay.exists(), "non-empty directory must survive");
8725
8726 std::fs::remove_dir(bay.join("cand-A")).unwrap();
8728 remove_if_empty(&bay);
8729 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
8730 }
8731
8732 #[test]
8741 fn a_full_panel_that_found_nothing_is_clean() {
8742 assert!(round_is_clean(
8743 0,
8744 true,
8745 2,
8746 2,
8747 0,
8748 IncompleteReviewPolicy::Block
8749 ));
8750 }
8751
8752 #[test]
8753 fn a_missing_seat_is_never_clean_under_the_default_policy() {
8754 assert!(!round_is_clean(
8755 0,
8756 true,
8757 1,
8758 2,
8759 0,
8760 IncompleteReviewPolicy::Block
8761 ));
8762 }
8763
8764 #[test]
8765 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
8766 assert!(!round_is_clean(
8767 1,
8768 true,
8769 1,
8770 2,
8771 0,
8772 IncompleteReviewPolicy::Warn
8773 ));
8774 }
8775
8776 #[test]
8777 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
8778 assert!(round_is_clean(
8779 0,
8780 true,
8781 1,
8782 2,
8783 0,
8784 IncompleteReviewPolicy::Warn
8785 ));
8786 }
8787
8788 #[test]
8789 fn a_full_panel_with_an_open_finding_is_not_clean() {
8790 assert!(!round_is_clean(
8791 1,
8792 true,
8793 2,
8794 2,
8795 0,
8796 IncompleteReviewPolicy::Block
8797 ));
8798 }
8799
8800 #[test]
8801 fn a_full_panel_with_a_red_e2e_is_not_clean() {
8802 assert!(!round_is_clean(
8803 0,
8804 false,
8805 2,
8806 2,
8807 0,
8808 IncompleteReviewPolicy::Block
8809 ));
8810 }
8811
8812 #[test]
8819 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
8820 assert!(round_is_clean(
8823 0,
8824 true,
8825 1,
8826 2,
8827 1,
8828 IncompleteReviewPolicy::Block
8829 ));
8830 }
8831
8832 #[test]
8833 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
8834 assert!(!round_is_clean(
8837 0,
8838 true,
8839 1,
8840 2,
8841 0,
8842 IncompleteReviewPolicy::Block
8843 ));
8844 }
8845
8846 #[test]
8847 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
8848 assert!(!round_is_clean(
8849 1,
8850 true,
8851 1,
8852 2,
8853 1,
8854 IncompleteReviewPolicy::Block
8855 ));
8856 assert!(!round_is_clean(
8857 0,
8858 false,
8859 1,
8860 2,
8861 1,
8862 IncompleteReviewPolicy::Block
8863 ));
8864 }
8865
8866 #[test]
8867 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
8868 assert!(!round_is_clean(
8872 0,
8873 true,
8874 0,
8875 2,
8876 2,
8877 IncompleteReviewPolicy::Block
8878 ));
8879 }
8880
8881 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
8882 CommandOutcome {
8883 command: "test".to_owned(),
8884 code,
8885 output_tail: String::new(),
8886 duration_ms: 0,
8887 resource_blocked,
8888 }
8889 }
8890
8891 #[test]
8892 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
8893 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
8894 assert!(
8895 !verify_inconclusive(&[outcome(Some(1), false)]),
8896 "an ordinary failure is still evidence about the patch"
8897 );
8898 assert!(verify_inconclusive(&[outcome(None, true)]));
8899 assert!(
8900 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
8901 "one inconclusive outcome taints the whole batch"
8902 );
8903 assert!(!verify_inconclusive(&[]));
8904 }
8905
8906 #[tokio::test]
8907 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
8908 let calls = std::sync::atomic::AtomicUsize::new(0);
8912 let started = Instant::now();
8913 wait_for_pids_with(
8914 &[123],
8915 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
8916 Duration::from_millis(5),
8917 Duration::from_secs(5),
8918 )
8919 .await;
8920 assert!(
8921 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
8922 "must keep checking rather than deciding on the first answer"
8923 );
8924 assert!(
8925 started.elapsed() < Duration::from_secs(1),
8926 "must return the moment it is confirmed dead, not wait out the ceiling"
8927 );
8928 }
8929
8930 #[tokio::test]
8931 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
8932 let started = Instant::now();
8933 wait_for_pids_with(
8934 &[123],
8935 |_| true, Duration::from_millis(5),
8937 Duration::from_millis(30),
8938 )
8939 .await;
8940 let elapsed = started.elapsed();
8941 assert!(
8942 elapsed >= Duration::from_millis(30),
8943 "must not give up before its own ceiling: {elapsed:?}"
8944 );
8945 assert!(
8946 elapsed < Duration::from_secs(1),
8947 "must not wait past its own ceiling either: {elapsed:?}"
8948 );
8949 }
8950
8951 #[tokio::test]
8952 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
8953 let started = Instant::now();
8954 wait_for_pids_with(
8955 &[],
8956 |_| true,
8957 Duration::from_secs(5),
8958 Duration::from_secs(5),
8959 )
8960 .await;
8961 assert!(
8962 started.elapsed() < Duration::from_millis(200),
8963 "an empty pid list has nothing to confirm"
8964 );
8965 }
8966
8967 fn review_round(
8973 clean: bool,
8974 blocking: usize,
8975 answered: usize,
8976 expected: usize,
8977 progressed: bool,
8978 e2e_ok: bool,
8979 ) -> ReviewRound {
8980 ReviewRound {
8981 round: 1,
8982 head: "h".to_owned(),
8983 verified_head: None,
8984 verified_at: None,
8985 reviews: Vec::new(),
8986 e2e: vec![CommandOutcome {
8987 command: "test".to_owned(),
8988 code: Some(if e2e_ok { 0 } else { 1 }),
8989 output_tail: String::new(),
8990 duration_ms: 0,
8991 resource_blocked: false,
8992 }],
8993 verify_retried: false,
8994 e2e_deferred: false,
8995 e2e_defer_reason: None,
8996 fix: None,
8997 blocking,
8998 answered,
8999 expected,
9000 clean,
9001 progressed,
9002 vote_split: false,
9003 reconsideration: Vec::new(),
9004 verdict: None,
9005 }
9006 }
9007
9008 #[test]
9009 fn review_conclusion_is_none_when_nothing_has_run() {
9010 assert_eq!(review_conclusion(&[], 3), None);
9011 }
9012
9013 #[test]
9014 fn review_conclusion_is_none_while_rounds_remain() {
9015 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9016 assert_eq!(review_conclusion(&rounds, 3), None);
9017 }
9018
9019 #[test]
9020 fn review_conclusion_is_gating_once_a_round_is_clean() {
9021 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9022 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9023 }
9024
9025 #[test]
9026 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9027 let rounds = vec![
9028 review_round(false, 1, 2, 2, true, true),
9029 review_round(false, 1, 2, 2, true, true),
9030 ];
9031 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9032 }
9033
9034 #[test]
9035 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9036 let rounds = vec![
9037 review_round(false, 1, 2, 2, true, true),
9038 review_round(false, 1, 2, 2, true, false),
9039 ];
9040 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9041 }
9042
9043 #[test]
9044 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9045 let mut blocked = review_round(false, 1, 2, 2, true, false);
9052 blocked.e2e[0].resource_blocked = true;
9053 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9054 assert_eq!(review_conclusion(&rounds, 2), None);
9055 }
9056
9057 #[test]
9058 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9059 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9061 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9062 }
9063
9064 #[test]
9065 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9066 let rounds = vec![
9067 review_round(false, 1, 2, 2, false, true),
9068 review_round(false, 1, 2, 2, false, true),
9069 ];
9070 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9071 }
9072
9073 fn secs(n: u64) -> Duration {
9074 Duration::from_secs(n)
9075 }
9076
9077 fn init_repo(dir: &Path) {
9080 let run = |args: &[&str]| {
9081 let out = std::process::Command::new("git")
9082 .args(args)
9083 .current_dir(dir)
9084 .quiet()
9085 .output()
9086 .expect("spawn git");
9087 assert!(
9088 out.status.success(),
9089 "git {args:?} failed: {}",
9090 String::from_utf8_lossy(&out.stderr)
9091 );
9092 };
9093 run(&["init", "-b", "main"]);
9094 run(&["config", "user.name", "magi test"]);
9095 run(&["config", "user.email", "magi@example.com"]);
9096 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9097 run(&["add", "-A"]);
9098 run(&["commit", "-m", "init"]);
9099 }
9100
9101 fn ask_test_home() {
9109 crate::run::pin_test_home();
9110 }
9111
9112 fn runner_at(status: RunStatus) -> Runner {
9115 let mut state = RunState::new(
9116 PathBuf::from("/nonexistent/repo"),
9117 "main".to_owned(),
9118 "deadbeef".to_owned(),
9119 "task".to_owned(),
9120 Config::default(),
9121 );
9122 state.status = status;
9123 Runner {
9124 state,
9125 roles: ResolvedRoles {
9126 implementers: Vec::new(),
9127 judges: Vec::new(),
9128 reviewers: Vec::new(),
9129 fixer: None,
9130 conductor: conductor(),
9131 implementer_roster: Vec::new(),
9132 judge_roster: Vec::new(),
9133 reviewer_roster: Vec::new(),
9134 },
9135 sem: Arc::new(Semaphore::new(1)),
9136 pause: Pause::new(),
9137 interrupt: Pause::new(),
9138 }
9139 }
9140
9141 #[test]
9145 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9146 crate::run::pin_test_home();
9147 let mut runner = runner_at(RunStatus::Implementing);
9148 let interrupt = Pause::new();
9149 runner.watch_interrupt(interrupt.clone());
9150
9151 interrupt.park_because("task a1b2 asked to run first");
9152
9153 assert!(runner.park_here().expect("park_here"));
9154 assert!(runner.state.parked);
9155 let last = runner.state.events.last().expect("a park event");
9156 assert_eq!(last.node, "park");
9157 assert!(
9158 last.message.contains("task a1b2 asked to run first"),
9159 "expected the interrupt reason in {:?}",
9160 last.message
9161 );
9162 }
9163
9164 #[test]
9172 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9173 crate::run::pin_test_home();
9174 let mut runner = runner_at(RunStatus::Implementing);
9175 let shutdown = Pause::new();
9176 runner.on_pause(shutdown.clone());
9177 let interrupt = Pause::new();
9178 runner.watch_interrupt(interrupt.clone());
9179
9180 assert!(!runner.park_here().expect("park_here"));
9182 assert!(!runner.state.parked);
9183
9184 interrupt.park_because("test");
9186 assert!(!shutdown.parked());
9187 assert!(runner.park_here().expect("park_here"));
9188 }
9189
9190 #[tokio::test]
9204 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9205 crate::run::pin_test_home();
9206 let mut runner = runner_at(RunStatus::Implementing);
9207 let interrupt = Pause::new();
9208 runner.watch_interrupt(interrupt.clone());
9209
9210 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9211 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9212
9213 let node = async move {
9217 started_tx.send(()).expect("send started");
9218 finish_rx.await.expect("recv finish");
9219 "node finished"
9220 };
9221
9222 let interrupter = async move {
9223 started_rx.await.expect("recv started");
9224 interrupt.park_because("higher-priority task waiting");
9226 tokio::task::yield_now().await;
9230 finish_tx.send(()).expect("send finish");
9231 };
9232
9233 let (node_result, ()) = tokio::join!(node, interrupter);
9234 assert_eq!(
9235 node_result, "node finished",
9236 "the in-flight call ran to completion"
9237 );
9238
9239 assert!(runner.park_here().expect("park_here"));
9242 assert!(runner.state.parked);
9243 }
9244
9245 #[test]
9251 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9252 crate::run::pin_test_home();
9253 let mut runner = runner_at(RunStatus::Judging);
9254 runner.state.config.agents = vec![conductor()];
9258 runner.state.candidates = vec![Candidate {
9259 index: 0,
9260 label: 'A',
9261 agent: "alpha".to_owned(),
9262 branch: "magi/x/A".to_owned(),
9263 worktree: PathBuf::from("/nonexistent/worktree"),
9264 summary: "did the thing".to_owned(),
9265 stat: "1 file changed".to_owned(),
9266 files: 1,
9267 commits: 1,
9268 empty: false,
9269 failed: None,
9270 verified_noop: None,
9271 duration_ms: 1234,
9272 folded: false,
9273 }];
9274 let run_id = runner.state.id.clone();
9275
9276 let interrupt = Pause::new();
9277 runner.watch_interrupt(interrupt.clone());
9278 interrupt.park_because("task c3d4 asked to run first");
9279 assert!(runner.park_here().expect("park_here"));
9280
9281 let resumed = Runner::resume(&run_id).expect("resume");
9282 assert_eq!(resumed.state.candidates.len(), 1);
9283 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9284 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9285 assert_eq!(resumed.state.status, runner.state.status);
9286 assert!(
9287 resumed.state.parked,
9288 "still parked until `execute` actually walks the graph again"
9289 );
9290 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9291 }
9292
9293 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9295 let mut q = ask::Question::new(
9296 run.to_owned(),
9297 "implement".to_owned(),
9298 "impl-A".to_owned(),
9299 "Which storage backend should the cache use?".to_owned(),
9300 String::new(),
9301 vec!["SQLite".to_owned(), "Redis".to_owned()],
9302 );
9303 store.put(&mut q).unwrap();
9304 q
9305 }
9306
9307 #[test]
9308 fn a_failed_runs_open_question_is_abandoned() {
9309 ask_test_home();
9310 let store = ask::Questions::open();
9311 let mut runner = runner_at(RunStatus::Failed);
9312 let run = runner.state.id.clone();
9313 let q = ask_open_question(&store, &run);
9314
9315 runner.settle_questions();
9316
9317 let back = store.get(&q.id).unwrap();
9318 assert!(
9319 !back.status.open(),
9320 "the seat that asked died with the run; nobody is left to read an answer"
9321 );
9322 assert!(
9323 back.detail.contains(&run) && back.detail.contains("failed"),
9324 "the reason names what the run became, not just that it is gone: {}",
9325 back.detail
9326 );
9327 }
9328
9329 #[test]
9330 fn a_merged_runs_open_question_is_abandoned_too() {
9331 ask_test_home();
9332 let store = ask::Questions::open();
9333 for status in [RunStatus::Merged, RunStatus::Ready] {
9336 let mut runner = runner_at(status);
9337 let run = runner.state.id.clone();
9338 let q = ask_open_question(&store, &run);
9339
9340 runner.settle_questions();
9341
9342 let back = store.get(&q.id).unwrap();
9343 assert!(
9344 !back.status.open(),
9345 "{status:?} run's question must not outlive the run"
9346 );
9347 }
9348 }
9349
9350 #[test]
9351 fn a_still_resumable_runs_open_question_is_left_alone() {
9352 ask_test_home();
9353 let store = ask::Questions::open();
9354 for status in [RunStatus::Blocked, RunStatus::Stalled] {
9360 let mut runner = runner_at(status);
9361 let run = runner.state.id.clone();
9362 let q = ask_open_question(&store, &run);
9363
9364 runner.settle_questions();
9365
9366 let back = store.get(&q.id).unwrap();
9367 assert!(
9368 back.status.open(),
9369 "{status:?} is still alive; the question must still be waiting"
9370 );
9371 }
9372 }
9373
9374 #[test]
9375 fn settle_questions_never_touches_an_already_answered_question() {
9376 ask_test_home();
9377 let store = ask::Questions::open();
9378 let mut runner = runner_at(RunStatus::Failed);
9379 let run = runner.state.id.clone();
9380 let mut q = ask_open_question(&store, &run);
9381 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
9382 .unwrap();
9383 store.put(&mut q).unwrap();
9384
9385 runner.settle_questions();
9390 runner.settle_questions();
9391
9392 let back = store.get(&q.id).unwrap();
9393 assert_eq!(
9394 back.status,
9395 ask::QuestionStatus::Answered,
9396 "a real answer is a decision on record, never overwritten by a sweep"
9397 );
9398 }
9399
9400 #[tokio::test]
9411 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
9412 crate::run::pin_test_home();
9413 let tmp = tempfile::tempdir().expect("tempdir");
9414 let repo = tmp.path().join("repo");
9415 std::fs::create_dir_all(&repo).unwrap();
9416 init_repo(&repo);
9417
9418 let mut config = Config::default();
9419 config.graph.worktree_root = Some(tmp.path().join("wt"));
9420
9421 let mut state = RunState::new(
9422 repo.clone(),
9423 "main".to_owned(),
9424 "deadbeef".to_owned(),
9425 "task".to_owned(),
9426 config,
9427 );
9428 let root = state.worktree_root();
9429 let wt_a = root.join("cand-A");
9430 let wt_b = root.join("cand-B");
9431 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
9432 .await
9433 .expect("worktree A");
9434 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
9435 .await
9436 .expect("worktree B");
9437
9438 state.candidates = vec![
9439 Candidate {
9440 index: 0,
9441 label: 'A',
9442 agent: "alpha".to_owned(),
9443 branch: "magi/x/A".to_owned(),
9444 worktree: wt_a.clone(),
9445 summary: String::new(),
9446 stat: String::new(),
9447 files: 0,
9448 commits: 0,
9449 empty: false,
9450 failed: None,
9451 verified_noop: None,
9452 duration_ms: 0,
9453 folded: false,
9454 },
9455 Candidate {
9456 index: 1,
9457 label: 'B',
9458 agent: "beta".to_owned(),
9459 branch: "magi/x/B".to_owned(),
9460 worktree: wt_b.clone(),
9461 summary: String::new(),
9462 stat: String::new(),
9463 files: 0,
9464 commits: 0,
9465 empty: false,
9466 failed: None,
9467 verified_noop: None,
9468 duration_ms: 0,
9469 folded: false,
9470 },
9471 ];
9472 state.tally = Some(Tally {
9473 first_choice: BTreeMap::from([('A', 1)]),
9474 borda: BTreeMap::new(),
9475 winner: 'A',
9476 rankings: 1,
9477 unanimous_initial: true,
9478 deliberated: false,
9479 changed_votes: 0,
9480 unanimous_final: true,
9481 tie_break: None,
9482 judges: 1,
9483 present: 1,
9484 quorum: 1,
9485 met_quorum: true,
9486 uncontested: None,
9487 });
9488 state.status = RunStatus::Ready;
9489
9490 fold_run(&mut state, false, &crate::run::home())
9491 .await
9492 .expect("fold_run");
9493
9494 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
9495 assert!(
9496 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9497 "the unmerged winner's branch survives"
9498 );
9499 assert!(
9500 !state.candidates[0].folded,
9501 "the winner is not marked folded"
9502 );
9503
9504 assert!(!wt_b.exists(), "the loser's worktree is removed");
9505 assert!(
9506 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
9507 "the loser's branch is removed"
9508 );
9509 assert!(state.candidates[1].folded, "the loser is marked folded");
9510 }
9511
9512 #[tokio::test]
9515 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
9516 let tmp = tempfile::tempdir().expect("tempdir");
9517 let repo = tmp.path().join("repo");
9518 std::fs::create_dir_all(&repo).unwrap();
9519 init_repo(&repo);
9520 let home = tmp.path().join("home");
9521
9522 let mut config = Config::default();
9523 config.graph.worktree_root = Some(tmp.path().join("wt"));
9524 let mut state = RunState::new(
9525 repo.clone(),
9526 "main".to_owned(),
9527 "deadbeef".to_owned(),
9528 "task".to_owned(),
9529 config,
9530 );
9531 git::git(&repo, &["branch", "magi/x/A", "main"])
9533 .await
9534 .expect("branch");
9535 state.candidates = vec![Candidate {
9536 index: 0,
9537 label: 'A',
9538 agent: "alpha".to_owned(),
9539 branch: "magi/x/A".to_owned(),
9540 worktree: state.worktree_root().join("cand-A"),
9541 summary: String::new(),
9542 stat: String::new(),
9543 files: 0,
9544 commits: 0,
9545 empty: false,
9546 failed: None,
9547 verified_noop: None,
9548 duration_ms: 0,
9549 folded: true,
9550 }];
9551 state.released_to = Some("20260901-000000-new1".to_owned());
9552 state.released_branches = vec!["magi/x/A".to_owned()];
9553
9554 fold_run(&mut state, true, &home).await.expect("fold_run");
9555
9556 assert!(
9557 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9558 "the handed-over branch survives a fold"
9559 );
9560 }
9561
9562 #[tokio::test]
9566 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
9567 let tmp = tempfile::tempdir().expect("tempdir");
9568 let repo = tmp.path().join("repo");
9569 std::fs::create_dir_all(&repo).unwrap();
9570 init_repo(&repo);
9571 let run = |args: &[&str]| {
9572 let out = std::process::Command::new("git")
9573 .quiet()
9574 .args(args)
9575 .current_dir(&repo)
9576 .output()
9577 .expect("spawn git");
9578 assert!(out.status.success(), "git {args:?}");
9579 };
9580 run(&["branch", "magi/x/A"]);
9581 run(&["checkout", "-q", "-b", "magi/x/B"]);
9582 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
9583 run(&["add", "-A"]);
9584 run(&["commit", "-q", "-m", "work"]);
9585 run(&["checkout", "-q", "main"]);
9586
9587 let mut state = RunState::new(
9588 repo.clone(),
9589 "main".to_owned(),
9590 "deadbeef".to_owned(),
9591 "task".to_owned(),
9592 Config::default(),
9593 );
9594 state.seeds = vec![refs::Seed {
9595 token: "magi/27b2/A".to_owned(),
9596 kind: refs::SeedKind::Unresolved,
9597 sha: String::new(),
9598 branch: true,
9599 detail: "no branch or commit named magi/27b2/A".to_owned(),
9600 }];
9601
9602 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
9603 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
9604 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
9605 let detail = empty_candidate_detail(&state, "main");
9606 assert!(detail.starts_with("empty candidate"), "{detail}");
9607 assert!(detail.contains("magi/27b2/A"), "{detail}");
9608 }
9609
9610 #[tokio::test]
9619 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
9620 let tmp = tempfile::tempdir().expect("tempdir");
9621 let repo = tmp.path().join("repo");
9622 std::fs::create_dir_all(&repo).unwrap();
9623 init_repo(&repo);
9624
9625 let mut config = Config::default();
9626 config.merge.mode = MergeMode::Local;
9627
9628 let mut state = RunState::new(
9629 repo.clone(),
9630 "main".to_owned(),
9631 "deadbeef".to_owned(),
9632 "task".to_owned(),
9633 config,
9634 );
9635 state.candidates = vec![Candidate {
9636 index: 0,
9637 label: 'A',
9638 agent: "alpha".to_owned(),
9639 branch: "does-not-exist".to_owned(),
9640 worktree: repo.clone(),
9641 summary: String::new(),
9642 stat: String::new(),
9643 files: 0,
9644 commits: 0,
9645 empty: false,
9646 failed: None,
9647 verified_noop: None,
9648 duration_ms: 0,
9649 folded: false,
9650 }];
9651 state.tally = Some(Tally {
9652 first_choice: BTreeMap::from([('A', 1)]),
9653 borda: BTreeMap::new(),
9654 winner: 'A',
9655 rankings: 1,
9656 unanimous_initial: true,
9657 deliberated: false,
9658 changed_votes: 0,
9659 unanimous_final: true,
9660 tie_break: None,
9661 judges: 0,
9662 present: 0,
9663 quorum: 0,
9664 met_quorum: true,
9665 uncontested: Some("only candidate A produced a change".to_owned()),
9666 });
9667 state.reviews = vec![ReviewRound {
9668 round: 1,
9669 head: "deadbeef".to_owned(),
9670 verified_head: None,
9671 verified_at: None,
9672 reviews: Vec::new(),
9673 e2e: Vec::new(),
9674 fix: None,
9675 blocking: 0,
9676 answered: 0,
9677 expected: 0,
9678 clean: true,
9679 verify_retried: false,
9680 e2e_deferred: false,
9681 e2e_defer_reason: None,
9682 progressed: false,
9683 vote_split: false,
9684 reconsideration: Vec::new(),
9685 verdict: None,
9686 }];
9687 state.gate = vec![CommandOutcome {
9688 command: "test".to_owned(),
9689 code: Some(0),
9690 output_tail: String::new(),
9691 duration_ms: 0,
9692 resource_blocked: false,
9693 }];
9694 state.gate_ran = true;
9695 state.status = RunStatus::Ready;
9700 state.merge = Some(MergeOutcome {
9701 mode: MergeMode::Local,
9702 ok: false,
9703 detail: "already concluded".to_owned(),
9704 empty: false,
9705 });
9706
9707 let mut runner = Runner {
9708 state,
9709 roles: ResolvedRoles {
9710 implementers: Vec::new(),
9711 judges: Vec::new(),
9712 reviewers: Vec::new(),
9713 fixer: None,
9714 conductor: conductor(),
9715 implementer_roster: Vec::new(),
9716 judge_roster: Vec::new(),
9717 reviewer_roster: Vec::new(),
9718 },
9719 sem: Arc::new(Semaphore::new(1)),
9720 pause: Pause::new(),
9721 interrupt: Pause::new(),
9722 };
9723
9724 runner.merge().await.expect("merge");
9725
9726 assert_eq!(
9727 runner.state.status,
9728 RunStatus::Ready,
9729 "a concluded run's status must not change on reentry"
9730 );
9731 assert_eq!(
9732 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
9733 Some("already concluded"),
9734 "merge must not run again once the node already recorded an outcome"
9735 );
9736 }
9737
9738 #[tokio::test]
9747 async fn merge_refuses_a_gate_that_has_not_actually_run() {
9748 let tmp = tempfile::tempdir().expect("tempdir");
9749 let repo = tmp.path().join("repo");
9750 std::fs::create_dir_all(&repo).unwrap();
9751 init_repo(&repo);
9752
9753 let mut config = Config::default();
9754 config.merge.mode = MergeMode::Local;
9755
9756 let mut state = RunState::new(
9757 repo.clone(),
9758 "main".to_owned(),
9759 "deadbeef".to_owned(),
9760 "task".to_owned(),
9761 config,
9762 );
9763 state.candidates = vec![Candidate {
9764 index: 0,
9765 label: 'A',
9766 agent: "alpha".to_owned(),
9767 branch: "does-not-exist".to_owned(),
9768 worktree: repo.clone(),
9769 summary: String::new(),
9770 stat: String::new(),
9771 files: 0,
9772 commits: 0,
9773 empty: false,
9774 failed: None,
9775 verified_noop: None,
9776 duration_ms: 0,
9777 folded: false,
9778 }];
9779 state.tally = Some(Tally {
9780 first_choice: BTreeMap::from([('A', 1)]),
9781 borda: BTreeMap::new(),
9782 winner: 'A',
9783 rankings: 1,
9784 unanimous_initial: true,
9785 deliberated: false,
9786 changed_votes: 0,
9787 unanimous_final: true,
9788 tie_break: None,
9789 judges: 0,
9790 present: 0,
9791 quorum: 0,
9792 met_quorum: true,
9793 uncontested: Some("only candidate A produced a change".to_owned()),
9794 });
9795 state.reviews = vec![ReviewRound {
9796 round: 1,
9797 head: "deadbeef".to_owned(),
9798 verified_head: None,
9799 verified_at: None,
9800 reviews: Vec::new(),
9801 e2e: Vec::new(),
9802 fix: None,
9803 blocking: 0,
9804 answered: 0,
9805 expected: 0,
9806 clean: true,
9807 verify_retried: false,
9808 e2e_deferred: false,
9809 e2e_defer_reason: None,
9810 progressed: false,
9811 vote_split: false,
9812 reconsideration: Vec::new(),
9813 verdict: None,
9814 }];
9815 state.gate = Vec::new();
9817 state.gate_ran = false;
9818 state.status = RunStatus::Gating;
9819
9820 let mut runner = Runner {
9821 state,
9822 roles: ResolvedRoles {
9823 implementers: Vec::new(),
9824 judges: Vec::new(),
9825 reviewers: Vec::new(),
9826 fixer: None,
9827 conductor: conductor(),
9828 implementer_roster: Vec::new(),
9829 judge_roster: Vec::new(),
9830 reviewer_roster: Vec::new(),
9831 },
9832 sem: Arc::new(Semaphore::new(1)),
9833 pause: Pause::new(),
9834 interrupt: Pause::new(),
9835 };
9836
9837 runner.merge().await.expect("merge");
9838
9839 assert!(
9840 runner.state.merge.is_none(),
9841 "an empty gate must never be read as a passing one: {:?}",
9842 runner.state.merge
9843 );
9844 }
9845
9846 #[tokio::test]
9853 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
9854 let tmp = tempfile::tempdir().expect("tempdir");
9855 let repo = tmp.path().join("repo");
9856 std::fs::create_dir_all(&repo).unwrap();
9857 init_repo(&repo);
9858
9859 let config = Config::default();
9861
9862 let mut state = RunState::new(
9863 repo.clone(),
9864 "main".to_owned(),
9865 "deadbeef".to_owned(),
9866 "task".to_owned(),
9867 config,
9868 );
9869 state.candidates = vec![Candidate {
9870 index: 0,
9871 label: 'A',
9872 agent: "alpha".to_owned(),
9873 branch: "does-not-exist".to_owned(),
9874 worktree: repo.clone(),
9875 summary: String::new(),
9876 stat: String::new(),
9877 files: 0,
9878 commits: 0,
9879 empty: false,
9880 failed: None,
9881 verified_noop: None,
9882 duration_ms: 0,
9883 folded: false,
9884 }];
9885 state.tally = Some(Tally {
9886 first_choice: BTreeMap::from([('A', 1)]),
9887 borda: BTreeMap::new(),
9888 winner: 'A',
9889 rankings: 1,
9890 unanimous_initial: true,
9891 deliberated: false,
9892 changed_votes: 0,
9893 unanimous_final: true,
9894 tie_break: None,
9895 judges: 0,
9896 present: 0,
9897 quorum: 0,
9898 met_quorum: true,
9899 uncontested: Some("only candidate A produced a change".to_owned()),
9900 });
9901 state.reviews = vec![ReviewRound {
9902 round: 1,
9903 head: "deadbeef".to_owned(),
9904 verified_head: None,
9905 verified_at: None,
9906 reviews: Vec::new(),
9907 e2e: Vec::new(),
9908 fix: None,
9909 blocking: 0,
9910 answered: 0,
9911 expected: 0,
9912 clean: true,
9913 verify_retried: false,
9914 e2e_deferred: false,
9915 e2e_defer_reason: None,
9916 progressed: false,
9917 vote_split: false,
9918 reconsideration: Vec::new(),
9919 verdict: None,
9920 }];
9921
9922 let mut runner = Runner {
9923 state,
9924 roles: ResolvedRoles {
9925 implementers: Vec::new(),
9926 judges: Vec::new(),
9927 reviewers: Vec::new(),
9928 fixer: None,
9929 conductor: conductor(),
9930 implementer_roster: Vec::new(),
9931 judge_roster: Vec::new(),
9932 reviewer_roster: Vec::new(),
9933 },
9934 sem: Arc::new(Semaphore::new(1)),
9935 pause: Pause::new(),
9936 interrupt: Pause::new(),
9937 };
9938
9939 runner.gate().await.expect("gate");
9940 assert!(
9941 runner.state.gate_ran,
9942 "zero configured commands is still a real attempt, not an unrun gate"
9943 );
9944 assert!(runner.state.gate.is_empty());
9945 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
9946 assert_ne!(
9947 runner.state.status,
9948 RunStatus::Blocked,
9949 "a gate with nothing to check must not read as failed"
9950 );
9951
9952 runner.merge().await.expect("merge");
9953 assert_eq!(
9954 runner.state.status,
9955 RunStatus::Ready,
9956 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
9957 );
9958 }
9959
9960 #[tokio::test]
9971 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
9972 crate::run::pin_test_home();
9973 let home = crate::run::home();
9974
9975 let tmp = tempfile::tempdir().expect("tempdir");
9976 let repo = tmp.path().join("repo");
9977 std::fs::create_dir_all(&repo).unwrap();
9978 init_repo(&repo);
9979 let cache_dir = tmp.path().join("target");
9982
9983 let mut config = Config::default();
9984 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
9985 config.graph.timeout_verify = Some(2);
9988
9989 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
9990 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
9991 .expect("no io error acquiring directly")
9992 {
9993 crate::cache::AcquireOutcome::Acquired(g) => g,
9994 crate::cache::AcquireOutcome::Busy(b) => {
9995 panic!("expected the direct acquire to win the lease first: {b:?}")
9996 }
9997 };
9998
9999 let mut state = RunState::new(
10000 repo.clone(),
10001 "main".to_owned(),
10002 "deadbeef".to_owned(),
10003 "task".to_owned(),
10004 config,
10005 );
10006 state.candidates = vec![Candidate {
10007 index: 0,
10008 label: 'A',
10009 agent: "alpha".to_owned(),
10010 branch: "does-not-exist".to_owned(),
10011 worktree: repo.clone(),
10012 summary: String::new(),
10013 stat: String::new(),
10014 files: 0,
10015 commits: 0,
10016 empty: false,
10017 failed: None,
10018 verified_noop: None,
10019 duration_ms: 0,
10020 folded: false,
10021 }];
10022 state.tally = Some(Tally {
10023 first_choice: BTreeMap::from([('A', 1)]),
10024 borda: BTreeMap::new(),
10025 winner: 'A',
10026 rankings: 1,
10027 unanimous_initial: true,
10028 deliberated: false,
10029 changed_votes: 0,
10030 unanimous_final: true,
10031 tie_break: None,
10032 judges: 0,
10033 present: 0,
10034 quorum: 0,
10035 met_quorum: true,
10036 uncontested: Some("only candidate A produced a change".to_owned()),
10037 });
10038 state.reviews = vec![ReviewRound {
10039 round: 1,
10040 head: "deadbeef".to_owned(),
10041 verified_head: None,
10042 verified_at: None,
10043 reviews: Vec::new(),
10044 e2e: Vec::new(),
10045 fix: None,
10046 blocking: 0,
10047 answered: 0,
10048 expected: 0,
10049 clean: true,
10050 verify_retried: false,
10051 e2e_deferred: false,
10052 e2e_defer_reason: None,
10053 progressed: false,
10054 vote_split: false,
10055 reconsideration: Vec::new(),
10056 verdict: None,
10057 }];
10058
10059 let mut runner = Runner {
10060 state,
10061 roles: ResolvedRoles {
10062 implementers: Vec::new(),
10063 judges: Vec::new(),
10064 reviewers: Vec::new(),
10065 fixer: None,
10066 conductor: conductor(),
10067 implementer_roster: Vec::new(),
10068 judge_roster: Vec::new(),
10069 reviewer_roster: Vec::new(),
10070 },
10071 sem: Arc::new(Semaphore::new(1)),
10072 pause: Pause::new(),
10073 interrupt: Pause::new(),
10074 };
10075
10076 let started = std::time::Instant::now();
10077 runner.gate().await.expect("gate");
10078 assert!(
10079 started.elapsed() < Duration::from_secs(1),
10080 "a gate with nothing to run must never wait on a lease it never needed"
10081 );
10082 assert!(
10083 runner.state.gate_ran,
10084 "zero commands is still a real, immediate attempt"
10085 );
10086 assert!(runner.state.gate.is_empty());
10087 assert_ne!(
10088 runner.state.status,
10089 RunStatus::Blocked,
10090 "must not read as resource-blocked on a lease it never asked for"
10091 );
10092 }
10093
10094 #[tokio::test]
10104 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10105 crate::run::pin_test_home();
10106
10107 let tmp = tempfile::tempdir().expect("tempdir");
10108 let repo = tmp.path().join("repo");
10109 std::fs::create_dir_all(&repo).unwrap();
10110 init_repo(&repo);
10111
10112 let mut config = Config::default();
10113 config.verify.gate = vec![
10114 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10115 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10116 .to_owned(),
10117 ];
10118
10119 let mut state = RunState::new(
10120 repo.clone(),
10121 "main".to_owned(),
10122 "deadbeef".to_owned(),
10123 "task".to_owned(),
10124 config,
10125 );
10126 let run_id = state.id.clone();
10127 state.candidates = vec![Candidate {
10128 index: 0,
10129 label: 'A',
10130 agent: "alpha".to_owned(),
10131 branch: "does-not-exist".to_owned(),
10132 worktree: repo.clone(),
10133 summary: String::new(),
10134 stat: String::new(),
10135 files: 0,
10136 commits: 0,
10137 empty: false,
10138 failed: None,
10139 verified_noop: None,
10140 duration_ms: 0,
10141 folded: false,
10142 }];
10143 state.tally = Some(Tally {
10144 first_choice: BTreeMap::from([('A', 1)]),
10145 borda: BTreeMap::new(),
10146 winner: 'A',
10147 rankings: 1,
10148 unanimous_initial: true,
10149 deliberated: false,
10150 changed_votes: 0,
10151 unanimous_final: true,
10152 tie_break: None,
10153 judges: 0,
10154 present: 0,
10155 quorum: 0,
10156 met_quorum: true,
10157 uncontested: Some("only candidate A produced a change".to_owned()),
10158 });
10159 state.reviews = vec![ReviewRound {
10160 round: 1,
10161 head: "deadbeef".to_owned(),
10162 verified_head: None,
10163 verified_at: None,
10164 reviews: Vec::new(),
10165 e2e: Vec::new(),
10166 fix: None,
10167 blocking: 0,
10168 answered: 0,
10169 expected: 0,
10170 clean: true,
10171 verify_retried: false,
10172 e2e_deferred: false,
10173 e2e_defer_reason: None,
10174 progressed: false,
10175 vote_split: false,
10176 reconsideration: Vec::new(),
10177 verdict: None,
10178 }];
10179
10180 let mut runner = Runner {
10181 state,
10182 roles: ResolvedRoles {
10183 implementers: Vec::new(),
10184 judges: Vec::new(),
10185 reviewers: Vec::new(),
10186 fixer: None,
10187 conductor: conductor(),
10188 implementer_roster: Vec::new(),
10189 judge_roster: Vec::new(),
10190 reviewer_roster: Vec::new(),
10191 },
10192 sem: Arc::new(Semaphore::new(1)),
10193 pause: Pause::new(),
10194 interrupt: Pause::new(),
10195 };
10196
10197 let started_marker = repo.join("started.marker");
10198 let release_marker = repo.join("release.marker");
10199 let poller = tokio::spawn(async move {
10200 for _ in 0..100 {
10205 if started_marker.exists()
10206 && let Ok(s) = crate::run::RunState::load(&run_id)
10207 && let Some(a) = s.active.get("gate")
10208 {
10209 std::fs::write(&release_marker, b"go").expect("release marker");
10210 return Some(a.clone());
10211 }
10212 tokio::time::sleep(Duration::from_millis(50)).await;
10213 }
10214 None
10215 });
10216
10217 runner.gate().await.expect("gate");
10218 let captured = poller.await.expect("poller task");
10219 let captured = captured.expect(
10220 "the poller never saw a `gate` task entry in run.json while the command was \
10221 still blocked on its own release marker",
10222 );
10223
10224 assert_eq!(captured.task.as_deref(), Some("gate"));
10225 assert_eq!(captured.node, "gate");
10226 assert_eq!(captured.index, Some(1));
10227 assert_eq!(captured.total, Some(1));
10228 assert!(
10229 captured
10230 .command
10231 .as_deref()
10232 .is_some_and(|c| c.contains("started.marker")),
10233 "{captured:?}"
10234 );
10235
10236 assert!(
10237 runner.state.active.is_empty(),
10238 "the entry must be cleared once the command actually finished: {:?}",
10239 runner.state.active
10240 );
10241 assert!(runner.state.gate_ran);
10242 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10243 }
10244
10245 #[tokio::test]
10248 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10249 use crate::verdict::{Finding, ReviewVote, Severity};
10250 crate::run::pin_test_home();
10251 let tmp = tempfile::tempdir().expect("tempdir");
10252 let repo = tmp.path().join("repo");
10253 std::fs::create_dir_all(&repo).unwrap();
10254 init_repo(&repo);
10255
10256 for (severity, vote, expect) in [
10257 (Severity::Major, ReviewVote::Reject, true),
10258 (Severity::Minor, ReviewVote::Reject, false),
10259 (Severity::Major, ReviewVote::Approve, false),
10260 ] {
10261 let mut round = review_round(false, 1, 1, 1, false, true);
10262 round.reviews = vec![ReviewRecord {
10263 reviewer: 1,
10264 agent: "alpha".to_owned(),
10265 summary: String::new(),
10266 findings: vec![Finding {
10267 id: "R1-1-1".to_owned(),
10268 severity,
10269 file: None,
10270 line: None,
10271 title: "t".to_owned(),
10272 detail: String::new(),
10273 }],
10274 vote: Some(vote),
10275 failed: None,
10276 duration_ms: 0,
10277 attempts: 0,
10278 }];
10279 let mut state = RunState::new(
10280 repo.clone(),
10281 "main".to_owned(),
10282 "deadbeef".to_owned(),
10283 "task".to_owned(),
10284 Config::default(),
10285 );
10286 state.reviews = vec![round];
10287 let mut runner = Runner {
10288 state,
10289 roles: ResolvedRoles {
10290 implementers: Vec::new(),
10291 judges: Vec::new(),
10292 reviewers: Vec::new(),
10293 fixer: None,
10294 conductor: conductor(),
10295 implementer_roster: Vec::new(),
10296 judge_roster: Vec::new(),
10297 reviewer_roster: Vec::new(),
10298 },
10299 sem: Arc::new(Semaphore::new(1)),
10300 pause: Pause::new(),
10301 interrupt: Pause::new(),
10302 };
10303 let shell = runner.state.config.shell();
10304 runner
10305 .stop_reviewing("round budget spent", &shell, &repo)
10306 .await
10307 .expect("stop_reviewing");
10308 assert_eq!(runner.state.status, RunStatus::Gating);
10309 assert_eq!(
10310 runner.state.contested_handoff.is_some(),
10311 expect,
10312 "{severity:?} + {vote:?}"
10313 );
10314 }
10315 }
10316
10317 #[tokio::test]
10330 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10331 crate::run::pin_test_home();
10332 let home = crate::run::home();
10333
10334 let tmp = tempfile::tempdir().expect("tempdir");
10335 let repo = tmp.path().join("repo");
10336 std::fs::create_dir_all(&repo).unwrap();
10337 init_repo(&repo);
10338 let head = crate::git::rev_parse(&repo, "HEAD")
10339 .await
10340 .expect("rev-parse");
10341 let cache_dir = tmp.path().join("target");
10344
10345 let mut config = Config::default();
10346 config.verify.e2e = vec![format!(
10347 "CARGO_TARGET_DIR='{}' test -f README.md",
10348 cache_dir.display()
10349 )];
10350 config.graph.review_rounds = 1;
10351 config.graph.timeout_verify = Some(2);
10354
10355 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10356 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10357 .expect("no io error acquiring directly")
10358 {
10359 crate::cache::AcquireOutcome::Acquired(g) => g,
10360 crate::cache::AcquireOutcome::Busy(b) => {
10361 panic!("expected the direct acquire to win the lease first: {b:?}")
10362 }
10363 };
10364
10365 let mut state = RunState::new(
10366 repo.clone(),
10367 "main".to_owned(),
10368 head.clone(),
10369 "task".to_owned(),
10370 config,
10371 );
10372 state.candidates = vec![Candidate {
10373 index: 0,
10374 label: 'A',
10375 agent: "alpha".to_owned(),
10376 branch: "does-not-exist".to_owned(),
10377 worktree: repo.clone(),
10378 summary: String::new(),
10379 stat: String::new(),
10380 files: 0,
10381 commits: 0,
10382 empty: false,
10383 failed: None,
10384 verified_noop: None,
10385 duration_ms: 0,
10386 folded: false,
10387 }];
10388 state.tally = Some(Tally {
10389 first_choice: BTreeMap::from([('A', 1)]),
10390 borda: BTreeMap::new(),
10391 winner: 'A',
10392 rankings: 1,
10393 unanimous_initial: true,
10394 deliberated: false,
10395 changed_votes: 0,
10396 unanimous_final: true,
10397 tie_break: None,
10398 judges: 0,
10399 present: 0,
10400 quorum: 0,
10401 met_quorum: true,
10402 uncontested: Some("only candidate A produced a change".to_owned()),
10403 });
10404 state.reviews = vec![ReviewRound {
10408 round: 1,
10409 head: head.clone(),
10410 verified_head: None,
10411 verified_at: None,
10412 reviews: Vec::new(),
10413 e2e: Vec::new(),
10414 fix: None,
10415 blocking: 1,
10416 answered: 1,
10417 expected: 1,
10418 clean: false,
10419 verify_retried: false,
10420 e2e_deferred: true,
10421 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
10422 progressed: false,
10423 vote_split: false,
10424 reconsideration: Vec::new(),
10425 verdict: None,
10426 }];
10427
10428 let mut runner = Runner {
10429 state,
10430 roles: ResolvedRoles {
10431 implementers: Vec::new(),
10432 judges: Vec::new(),
10433 reviewers: Vec::new(),
10434 fixer: None,
10435 conductor: conductor(),
10436 implementer_roster: Vec::new(),
10437 judge_roster: Vec::new(),
10438 reviewer_roster: Vec::new(),
10439 },
10440 sem: Arc::new(Semaphore::new(1)),
10441 pause: Pause::new(),
10442 interrupt: Pause::new(),
10443 };
10444
10445 let shell = runner.state.config.shell();
10446 runner
10447 .stop_reviewing("round budget spent", &shell, &repo)
10448 .await
10449 .expect("stop_reviewing");
10450
10451 let last = runner.state.reviews.last().expect("round record");
10452 assert_eq!(
10453 last.e2e_status(),
10454 E2eStatus::ResourceBlocked,
10455 "the shared cache is still held; the attempt must read as blocked, not deferred or \
10456 failed: {last:?}"
10457 );
10458 assert_eq!(
10459 last.verified_head.as_deref(),
10460 Some(head.as_str()),
10461 "which commit this attempt targeted is known even though nothing finished checking \
10462 it"
10463 );
10464 let first_attempt_at = last
10465 .verified_at
10466 .expect("when this attempt ran is known too");
10467 assert_ne!(
10468 runner.state.status,
10469 RunStatus::Blocked,
10470 "contention is evidence about the machine, not the patch — it must not settle the \
10471 run as blocked: {:?}",
10472 runner.state.status
10473 );
10474 assert!(
10475 !runner
10476 .state
10477 .events
10478 .iter()
10479 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
10480 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
10481 runner.state.events
10482 );
10483
10484 runner
10489 .stop_reviewing("round budget spent", &shell, &repo)
10490 .await
10491 .expect("stop_reviewing retry");
10492 assert_eq!(
10493 runner.state.reviews.len(),
10494 1,
10495 "no new round was started: {:?}",
10496 runner.state.reviews
10497 );
10498 let last = runner.state.reviews.last().expect("round record");
10499 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
10500 assert!(
10501 last.verified_at.expect("still known") > first_attempt_at,
10502 "a second reentry must be a fresh attempt, not a stale copy of the first"
10503 );
10504 assert_ne!(runner.state.status, RunStatus::Blocked);
10505
10506 held.release();
10507 }
10508
10509 #[tokio::test]
10521 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
10522 crate::run::pin_test_home();
10523 let home = crate::run::home();
10524
10525 let tmp = tempfile::tempdir().expect("tempdir");
10526 let repo = tmp.path().join("repo");
10527 std::fs::create_dir_all(&repo).unwrap();
10528 init_repo(&repo);
10529 let head = crate::git::rev_parse(&repo, "HEAD")
10530 .await
10531 .expect("rev-parse");
10532 let cache_dir = tmp.path().join("target");
10533
10534 let mut config = Config::default();
10535 config.verify.e2e = vec![format!(
10536 "CARGO_TARGET_DIR='{}' test -f README.md",
10537 cache_dir.display()
10538 )];
10539 config.graph.review_rounds = 1;
10540 config.graph.timeout_verify = Some(2);
10541
10542 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10543 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10544 .expect("no io error acquiring directly")
10545 {
10546 crate::cache::AcquireOutcome::Acquired(g) => g,
10547 crate::cache::AcquireOutcome::Busy(b) => {
10548 panic!("expected the direct acquire to win the lease first: {b:?}")
10549 }
10550 };
10551
10552 let mut state = RunState::new(
10553 repo.clone(),
10554 "main".to_owned(),
10555 head.clone(),
10556 "task".to_owned(),
10557 config,
10558 );
10559 state.candidates = vec![Candidate {
10560 index: 0,
10561 label: 'A',
10562 agent: "alpha".to_owned(),
10563 branch: "does-not-exist".to_owned(),
10564 worktree: repo.clone(),
10565 summary: String::new(),
10566 stat: String::new(),
10567 files: 0,
10568 commits: 0,
10569 empty: false,
10570 failed: None,
10571 verified_noop: None,
10572 duration_ms: 0,
10573 folded: false,
10574 }];
10575 state.tally = Some(Tally {
10576 first_choice: BTreeMap::from([('A', 1)]),
10577 borda: BTreeMap::new(),
10578 winner: 'A',
10579 rankings: 1,
10580 unanimous_initial: true,
10581 deliberated: false,
10582 changed_votes: 0,
10583 unanimous_final: true,
10584 tie_break: None,
10585 judges: 0,
10586 present: 0,
10587 quorum: 0,
10588 met_quorum: true,
10589 uncontested: Some("only candidate A produced a change".to_owned()),
10590 });
10591 state.reviews = vec![ReviewRound {
10595 round: 1,
10596 head: head.clone(),
10597 verified_head: Some(head.clone()),
10598 verified_at: Some(jiff::Timestamp::now()),
10599 reviews: Vec::new(),
10600 e2e: vec![CommandOutcome {
10601 command: format!(
10602 "CARGO_TARGET_DIR='{}' test -f README.md",
10603 cache_dir.display()
10604 ),
10605 code: None,
10606 output_tail: "waiting for the shared build cache".to_owned(),
10607 duration_ms: 0,
10608 resource_blocked: true,
10609 }],
10610 fix: None,
10611 blocking: 1,
10612 answered: 1,
10613 expected: 1,
10614 clean: false,
10615 verify_retried: false,
10616 e2e_deferred: false,
10617 e2e_defer_reason: None,
10618 progressed: false,
10619 vote_split: false,
10620 reconsideration: Vec::new(),
10621 verdict: None,
10622 }];
10623
10624 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
10625 let mut runner = Runner {
10626 state,
10627 roles: ResolvedRoles {
10628 implementers: Vec::new(),
10629 judges: Vec::new(),
10630 reviewers: Vec::new(),
10631 fixer: None,
10632 conductor: conductor(),
10633 implementer_roster: Vec::new(),
10634 judge_roster: Vec::new(),
10635 reviewer_roster: Vec::new(),
10636 },
10637 sem: Arc::new(Semaphore::new(1)),
10638 pause: Pause::new(),
10639 interrupt: Pause::new(),
10640 };
10641
10642 runner.review_loop().await.expect("review_loop");
10647
10648 assert_eq!(
10649 runner.state.reviews.len(),
10650 1,
10651 "no new round was started on top of the unresolved one: {:?}",
10652 runner.state.reviews
10653 );
10654 let last = &runner.state.reviews[0];
10655 assert_eq!(
10656 last.e2e_status(),
10657 E2eStatus::ResourceBlocked,
10658 "still contended: {last:?}"
10659 );
10660 assert!(
10661 last.verified_at.expect("still known") > first_attempt_at,
10662 "review_loop must have actually retried the check, not left it exactly as found"
10663 );
10664 assert_ne!(
10665 runner.state.status,
10666 RunStatus::Blocked,
10667 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
10668 runner.state.status
10669 );
10670
10671 held.release();
10672 }
10673
10674 #[tokio::test]
10675 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
10676 crate::run::pin_test_home();
10677 let tmp = tempfile::tempdir().expect("tempdir");
10678 let repo = tmp.path().join("repo");
10679 std::fs::create_dir_all(&repo).unwrap();
10680 init_repo(&repo);
10681
10682 let mut config = Config::default();
10683 config.merge.mode = MergeMode::Pr;
10684 config.graph.land = true;
10685 config.graph.land_approval = false;
10686
10687 let mut state = RunState::new(
10688 repo.clone(),
10689 "main".to_owned(),
10690 "deadbeef".to_owned(),
10691 "task".to_owned(),
10692 config,
10693 );
10694 state.candidates = vec![Candidate {
10695 index: 0,
10696 label: 'A',
10697 agent: "alpha".to_owned(),
10698 branch: "does-not-exist".to_owned(),
10699 worktree: repo.clone(),
10700 summary: String::new(),
10701 stat: String::new(),
10702 files: 0,
10703 commits: 0,
10704 empty: false,
10705 failed: None,
10706 verified_noop: None,
10707 duration_ms: 0,
10708 folded: false,
10709 }];
10710 state.tally = Some(Tally {
10711 first_choice: BTreeMap::from([('A', 1)]),
10712 borda: BTreeMap::new(),
10713 winner: 'A',
10714 rankings: 1,
10715 unanimous_initial: true,
10716 deliberated: false,
10717 changed_votes: 0,
10718 unanimous_final: true,
10719 tie_break: None,
10720 judges: 0,
10721 present: 0,
10722 quorum: 0,
10723 met_quorum: true,
10724 uncontested: Some("only candidate A produced a change".to_owned()),
10725 });
10726 state.reviews = vec![ReviewRound {
10727 round: 1,
10728 head: "deadbeef".to_owned(),
10729 verified_head: None,
10730 verified_at: None,
10731 reviews: Vec::new(),
10732 e2e: Vec::new(),
10733 fix: None,
10734 blocking: 0,
10735 answered: 0,
10736 expected: 0,
10737 clean: true,
10738 verify_retried: false,
10739 e2e_deferred: false,
10740 e2e_defer_reason: None,
10741 progressed: false,
10742 vote_split: false,
10743 reconsideration: Vec::new(),
10744 verdict: None,
10745 }];
10746 state.gate = vec![CommandOutcome {
10747 command: "test".to_owned(),
10748 code: Some(0),
10749 output_tail: String::new(),
10750 duration_ms: 0,
10751 resource_blocked: false,
10752 }];
10753 state.gate_ran = true;
10754 state.status = RunStatus::Landing;
10758 state.merge = Some(MergeOutcome {
10759 mode: MergeMode::Pr,
10760 ok: true,
10761 detail: "https://example.invalid/x/y/pull/1".to_owned(),
10762 empty: false,
10763 });
10764
10765 ask_test_home();
10769 let store = ask::Questions::open();
10770 let q = ask_open_question(&store, &state.id);
10771
10772 let mut runner = Runner {
10773 state,
10774 roles: ResolvedRoles {
10775 implementers: Vec::new(),
10776 judges: Vec::new(),
10777 reviewers: Vec::new(),
10778 fixer: None,
10779 conductor: conductor(),
10780 implementer_roster: Vec::new(),
10781 judge_roster: Vec::new(),
10782 reviewer_roster: Vec::new(),
10783 },
10784 sem: Arc::new(Semaphore::new(1)),
10785 pause: Pause::new(),
10786 interrupt: Pause::new(),
10787 };
10788
10789 runner.execute().await.expect("execute");
10794
10795 assert_eq!(
10796 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10797 Some("https://example.invalid/x/y/pull/1"),
10798 "reentry must not push again or open a second pull request over the \
10799 one `land` is already watching"
10800 );
10801 assert_ne!(
10802 runner.state.status,
10803 RunStatus::Landing,
10804 "land could not actually reach the fake pull request, so it must \
10805 have given up rather than left the run silently parked forever"
10806 );
10807 assert_eq!(runner.state.status, RunStatus::Blocked);
10811 assert!(
10812 store.get(&q.id).unwrap().status.open(),
10813 "Blocked is still alive; settle_questions must have been a no-op here"
10814 );
10815 }
10816
10817 fn state_with_round(round: ReviewRound) -> RunState {
10818 let mut s = RunState::new(
10819 PathBuf::from("/repo"),
10820 "main".to_owned(),
10821 "abc1234".to_owned(),
10822 "add retries".to_owned(),
10823 Config::default(),
10824 );
10825 s.reviews = vec![round];
10826 s
10827 }
10828
10829 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
10830 crate::verdict::Finding {
10831 id: id.to_owned(),
10832 severity,
10833 file: None,
10834 line: None,
10835 title: title.to_owned(),
10836 detail: String::new(),
10837 }
10838 }
10839
10840 #[test]
10841 fn pr_body_names_open_findings_and_declined_ones() {
10842 let round = ReviewRound {
10843 round: 2,
10844 head: "deadbee".to_owned(),
10845 verified_head: None,
10846 verified_at: None,
10847 reviews: vec![ReviewRecord {
10848 attempts: 0,
10849 reviewer: 1,
10850 agent: "alpha".to_owned(),
10851 summary: String::new(),
10852 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
10853 vote: None,
10854 failed: None,
10855 duration_ms: 0,
10856 }],
10857 e2e: vec![CommandOutcome {
10858 command: "cargo test".to_owned(),
10859 code: Some(0),
10860 output_tail: String::new(),
10861 duration_ms: 0,
10862 resource_blocked: false,
10863 }],
10864 verify_retried: false,
10865 e2e_deferred: false,
10866 e2e_defer_reason: None,
10867 fix: Some(FixRecord {
10868 agent: "alpha".to_owned(),
10869 addressed: Vec::new(),
10870 rejected: vec![crate::verdict::Rejection {
10871 id: "R1-1-1".to_owned(),
10872 why: "not reachable from any caller".to_owned(),
10873 }],
10874 notes: String::new(),
10875 committed: true,
10876 failed: None,
10877 duration_ms: 0,
10878 continuation: None,
10879 }),
10880 blocking: 0,
10881 answered: 1,
10882 expected: 1,
10883 clean: false,
10884 progressed: true,
10885 vote_split: false,
10886 reconsideration: Vec::new(),
10887 verdict: None,
10888 };
10889 let state = state_with_round(round);
10890 let body = pr_message(&state, 'A').body;
10891
10892 assert!(body.contains("add retries"), "the task must still be there");
10893 assert!(body.contains("R2-1-1"), "{body}");
10894 assert!(body.contains("unused import"), "{body}");
10895 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
10896 assert!(
10897 body.contains("not reachable from any caller"),
10898 "the reason it was declined: {body}"
10899 );
10900 }
10901
10902 #[test]
10903 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
10904 let round = ReviewRound {
10905 round: 1,
10906 head: "deadbee".to_owned(),
10907 verified_head: None,
10908 verified_at: None,
10909 reviews: vec![ReviewRecord {
10910 attempts: 0,
10911 reviewer: 1,
10912 agent: "alpha".to_owned(),
10913 summary: String::new(),
10914 findings: Vec::new(),
10915 vote: None,
10916 failed: None,
10917 duration_ms: 0,
10918 }],
10919 e2e: Vec::new(),
10920 verify_retried: false,
10921 e2e_deferred: false,
10922 e2e_defer_reason: None,
10923 fix: None,
10924 blocking: 0,
10925 answered: 1,
10926 expected: 1,
10927 clean: true,
10928 progressed: false,
10929 vote_split: false,
10930 reconsideration: Vec::new(),
10931 verdict: None,
10932 };
10933 let state = state_with_round(round);
10934 let body = pr_message(&state, 'A').body;
10935 assert!(!body.contains("Open review findings"), "{body}");
10936 assert!(!body.contains("Declined"), "{body}");
10937 }
10938
10939 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
10940 let mut state = RunState::new(
10941 PathBuf::from("/repo"),
10942 "main".to_owned(),
10943 "abc1234".to_owned(),
10944 instruction.to_owned(),
10945 Config::default(),
10946 );
10947 state.candidates.push(Candidate {
10948 index: 0,
10949 label: 'A',
10950 agent: "alpha".to_owned(),
10951 branch: "magi/x/A".to_owned(),
10952 worktree: PathBuf::from("/wt"),
10953 summary: summary.to_owned(),
10954 stat: String::new(),
10955 files: 1,
10956 commits: 1,
10957 empty: false,
10958 failed: None,
10959 verified_noop: None,
10960 folded: false,
10961 duration_ms: 0,
10962 });
10963 state
10964 }
10965
10966 fn review_state(subjects: &[&str]) -> RunState {
10967 let mut state = state_with_summary(
10968 "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",
10969 "",
10970 );
10971 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
10972 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
10973 state
10974 }
10975
10976 #[tokio::test]
10979 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
10980 ask_test_home();
10981 let tmp = tempfile::tempdir().unwrap();
10982 let repo = tmp.path().join("repo");
10983 std::fs::create_dir_all(&repo).unwrap();
10984 init_repo(&repo);
10985 let origin = tmp.path().join("origin.git");
10986 let g = |dir: &Path, args: &[&str]| {
10987 let out = std::process::Command::new("git")
10988 .args(args)
10989 .current_dir(dir)
10990 .quiet()
10991 .output()
10992 .expect("spawn git");
10993 assert!(
10994 out.status.success(),
10995 "git {args:?}: {}",
10996 String::from_utf8_lossy(&out.stderr)
10997 );
10998 };
10999 g(
11000 tmp.path(),
11001 &[
11002 "clone",
11003 "--bare",
11004 "-q",
11005 repo.to_str().unwrap(),
11006 origin.to_str().unwrap(),
11007 ],
11008 );
11009 g(
11010 &repo,
11011 &["remote", "add", "origin", origin.to_str().unwrap()],
11012 );
11013 let c1 = git::rev_parse(&repo, "main").await.unwrap();
11014
11015 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11017 g(&repo, &["add", "-A"]);
11018 g(
11019 &repo,
11020 &["commit", "-q", "-m", "chore(deps): update a crate"],
11021 );
11022 g(&repo, &["push", "-q", "origin", "main"]);
11023 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11024 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11025 g(&repo, &["add", "-A"]);
11026 g(
11027 &repo,
11028 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11029 );
11030 g(&repo, &["checkout", "-q", "main"]);
11031
11032 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11033 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11034 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11036 assert_eq!(fallback, c1);
11037
11038 let mut state = review_state(&[
11039 "chore(deps): update a crate",
11040 "fix(daemon): apply a chosen action",
11041 ]);
11042 state.repo = repo.clone();
11043 state.base_branch = "main".to_owned();
11044 state.base_commit = c1;
11045 refresh_reviewed_commits(&mut state, "feat/own").await;
11046 assert_eq!(
11047 state.reviewed_commits,
11048 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11049 );
11050 assert_eq!(
11051 review_title(&state).as_deref(),
11052 Some("fix(daemon): apply a chosen action")
11053 );
11054 assert_eq!(
11055 leaked_subjects(&state, "feat/own").await,
11056 Some(vec!["chore(deps): update a crate".to_owned()])
11057 );
11058 state.config.merge.remote = "nowhere".to_owned();
11061 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11062 }
11063
11064 #[test]
11065 fn pr_message_review_single_commit_uses_its_subject() {
11066 let state = review_state(&["feat(nats): per-role user"]);
11067 let m = pr_message(&state, 'A');
11068 assert_eq!(m.title, "feat(nats): per-role user");
11069 assert!(!m.body.contains("Review the work already"), "{}", m.body);
11070 assert!(m.body.contains("## Commits under review"), "{}", m.body);
11071 }
11072
11073 #[test]
11074 fn pr_message_review_multi_commit_takes_the_oldest() {
11075 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11076 let m = pr_message(&state, 'A');
11077 assert_eq!(m.title, "feat: the change");
11078 for s in ["feat: the change", "fix: typo", "fix: again"] {
11079 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11080 }
11081 }
11082
11083 #[test]
11084 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11085 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11086 let mut state = review_state(&[first, "fix: later fixup"]);
11087 state.candidates[0].branch = "機能/ログイン".to_owned();
11088 let m = pr_message(&state, 'A');
11089 assert!(
11090 m.title.starts_with("chore: land candidate A of run"),
11091 "{}",
11092 m.title
11093 );
11094 }
11095 }
11096
11097 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11098 BranchFacts {
11099 commits: commits
11100 .iter()
11101 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11102 .collect(),
11103 stat: stat.to_owned(),
11104 }
11105 }
11106
11107 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11108 let mut r = review_round(true, 0, 1, 1, true, true);
11109 r.round = round;
11110 r.fix = notes.map(|n| FixRecord {
11111 agent: "fixer".to_owned(),
11112 addressed: Vec::new(),
11113 rejected: Vec::new(),
11114 notes: n.to_owned(),
11115 committed: true,
11116 failed: None,
11117 duration_ms: 0,
11118 continuation: None,
11119 });
11120 r
11121 }
11122
11123 #[test]
11124 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11125 let state = review_state(&["ignored"]);
11126 let f = facts(
11127 &[
11128 (
11129 "fix(login): resolve PATH on macOS",
11130 "Login shells skip rc files.",
11131 ),
11132 ("fix: address review", ""),
11133 ],
11134 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11135 );
11136 let m = pr_message_with(&state, 'A', Some(&f));
11137 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11138 assert!(
11139 m.body
11140 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
11141 "{}",
11142 m.body
11143 );
11144 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11145 assert!(m.body.contains("## Diff stat"), "{}", m.body);
11146 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11147 for banned in [
11148 "Review the work already",
11149 "no task statement",
11150 "Original task",
11151 ] {
11152 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11153 }
11154 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11155 }
11156
11157 #[test]
11158 fn pr_message_review_truncates_a_huge_first_commit_body() {
11159 let state = review_state(&["ignored"]);
11160 let f = facts(
11161 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11162 "s",
11163 );
11164 let m = pr_message_with(&state, 'A', Some(&f));
11165 assert!(m.body.len() < 30_000, "{}", m.body.len());
11166 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11167 assert!(
11168 m.body.contains("- ... 1 more commit(s)"),
11169 "{}",
11170 m.body.len()
11171 );
11172 assert!(m.body.ends_with("magi:candidate-a\n"));
11173 }
11174
11175 #[test]
11176 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11177 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11178 assert_eq!(m.title, "feat: x");
11179 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11180 assert!(!m.body.contains("Diff stat"), "{}", m.body);
11181 assert!(!m.body.contains("no task statement"), "{}", m.body);
11182 }
11183
11184 #[test]
11185 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11186 let mut state = review_state(&["日本語の件名"]);
11187 state.candidates[0].branch = "fix/macos-login-path".to_owned();
11188 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11189 state.candidates[0].branch = "機能/ログイン".to_owned();
11190 assert!(
11191 pr_message(&state, 'A')
11192 .title
11193 .starts_with("chore: land candidate A")
11194 );
11195 }
11196
11197 #[test]
11198 fn pr_message_review_fixes_survive_a_clean_final_round() {
11199 let mut state = review_state(&["feat: x"]);
11200 state.reviews = vec![
11201 round_with_notes(1, Some("handled the PATH case")),
11202 round_with_notes(2, None),
11203 ];
11204 let body = pr_message(&state, 'A').body;
11205 assert!(
11206 body.contains("## Review fixes\n\nhandled the PATH case\n"),
11207 "{body}"
11208 );
11209 assert!(!body.contains("### Round"), "{body}");
11210
11211 state.reviews = vec![
11212 round_with_notes(1, Some("first fix")),
11213 round_with_notes(2, Some("")),
11214 round_with_notes(3, Some("second fix")),
11215 round_with_notes(4, None),
11216 ];
11217 let body = pr_message(&state, 'A').body;
11218 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11219 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11220 assert!(!body.contains("### Round 2"), "{body}");
11221 }
11222
11223 #[test]
11224 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11225 let state = state_with_summary(
11226 "add retries to the client",
11227 "TITLE: feat: retries\n\nDid it.",
11228 );
11229 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11230 assert_eq!(m.title, "feat: retries");
11231 assert!(
11232 m.body.contains("<summary>Original task</summary>"),
11233 "{}",
11234 m.body
11235 );
11236 assert!(!m.body.contains("Commits under review"), "{}", m.body);
11237 assert!(
11238 m.body
11239 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11240 "{}",
11241 m.body
11242 );
11243 }
11244
11245 #[test]
11246 fn pr_message_review_bounds_a_long_english_subject() {
11247 let long = format!("feat: {}", "word ".repeat(100));
11248 let m = pr_message(&review_state(&[&long]), 'A');
11249 assert!(m.title.starts_with("feat: word"), "{}", m.title);
11250 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11251 }
11252
11253 #[test]
11254 fn pr_message_implementation_run_is_unchanged_by_review_support() {
11255 let state = state_with_summary("add retries\n\ndetails", "- did some things");
11256 let m = pr_message(&state, 'A');
11257 assert_eq!(m.title, "add retries");
11258 assert!(m.body.contains("<summary>Original task</summary>"));
11259 assert!(!m.body.contains("Commits under review"));
11260 assert_eq!(landing_subject_source(&state), state.instruction);
11261 }
11262
11263 #[test]
11264 fn review_run_squash_subject_is_the_change_not_the_prompt() {
11265 let state = review_state(&["feat: the change", "fix: typo"]);
11266 let source = landing_subject_source(&state);
11267 assert_eq!(land::merge_subject("", &source), "feat: the change");
11268 assert_eq!(
11269 land::merge_subject("magi: candidate A (uncommitted work)", &source),
11270 "feat: the change"
11271 );
11272 assert_eq!(
11274 land::merge_subject("feat: renamed by hand", &source),
11275 "feat: renamed by hand"
11276 );
11277 let blank = review_state(&["日本語"]);
11278 assert!(
11279 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11280 );
11281 }
11282
11283 #[test]
11284 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11285 let state = review_state(&["feat: the change"]);
11286 let source = landing_subject_source(&state);
11287 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11288 assert_eq!(
11289 land::merge_subject(landing_title(&state, old), &source),
11290 "feat: the change"
11291 );
11292 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11293 let task = state_with_summary("add retries", "");
11294 assert_eq!(landing_title(&task, old), old);
11295 }
11296
11297 #[test]
11298 fn pr_message_describes_the_change_not_the_task() {
11299 let state = state_with_summary(
11300 "今回やってほしいこと: results projector を直す",
11301 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11302 );
11303 let m = pr_message(&state, 'A');
11304 assert_eq!(m.title, "fix(web): batch the runs list reads");
11305 assert!(
11306 m.body.starts_with("## Summary\n\n- reads run.json once"),
11307 "{}",
11308 m.body
11309 );
11310 assert!(!m.body.contains("TITLE:"), "{}", m.body);
11311 let task_at = m.body.find("今回やってほしいこと").unwrap();
11312 let details_at = m.body.find("<details>").unwrap();
11313 assert!(
11314 details_at < task_at,
11315 "the task lives inside <details>: {}",
11316 m.body
11317 );
11318 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11319 assert!(m.body.contains("magi:candidate-a"));
11320 }
11321
11322 #[test]
11323 fn pr_message_falls_back_to_the_task_without_a_title_line() {
11324 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11325 let m = pr_message(&state, 'A');
11326 assert_eq!(m.title, "add retries");
11327 assert!(
11328 m.body.contains("## Summary\n\n- did some things"),
11329 "{}",
11330 m.body
11331 );
11332
11333 let none = RunState::new(
11334 PathBuf::from("/repo"),
11335 "main".to_owned(),
11336 "abc1234".to_owned(),
11337 "add retries".to_owned(),
11338 Config::default(),
11339 );
11340 let m = pr_message(&none, 'A');
11341 assert_eq!(m.title, "add retries");
11342 assert!(!m.body.contains("## Summary"), "{}", m.body);
11343 }
11344
11345 #[test]
11346 fn pr_message_refuses_the_candidate_commit_subject() {
11347 for bad in [
11348 "TITLE: magi: candidate A (uncommitted work)",
11349 "TITLE: chore: stuff (uncommitted work)",
11350 "TITLE: ",
11351 ] {
11352 let state = state_with_summary("add retries", bad);
11353 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
11354 }
11355 }
11356
11357 #[test]
11358 fn pr_message_bounds_a_very_long_task_and_title() {
11359 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
11360 let state = state_with_summary(&long, "- nothing");
11361 let m = pr_message(&state, 'A');
11362 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11363 assert!(!m.title.contains('\n'));
11364
11365 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
11366 let m = pr_message(&state, 'A');
11367 assert!(m.title.starts_with("feat: "));
11368 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11369 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
11370 }
11371
11372 fn long_title_of(instruction: &str) -> String {
11373 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
11374 }
11375
11376 #[test]
11377 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
11378 let first = "Make the landing path keep a readable title for long tasks";
11379 let line = format!(
11380 "{first}. {}",
11381 "And then keep going with more words ".repeat(20)
11382 );
11383 let t = long_title_of(&line);
11384 assert_eq!(t, first);
11385 assert!(!t.starts_with("chore: land"));
11386 }
11387
11388 #[test]
11389 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
11390 let line = "word ".repeat(200);
11391 let t = long_title_of(&line);
11392 assert!(t.ends_with("word..."), "{t}");
11393 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
11394 }
11395
11396 #[test]
11397 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
11398 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
11399 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
11400 }
11401 }
11402
11403 #[test]
11404 fn pr_message_title_limit_is_exact() {
11405 let at = "a".repeat(PR_TITLE_MAX);
11406 assert_eq!(long_title_of(&at), at);
11407 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
11408 assert!(over.ends_with("..."), "{over}");
11409 assert_eq!(over.chars().count(), PR_TITLE_MAX);
11410 }
11411
11412 #[test]
11413 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
11414 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
11415 let t = long_title_of(&line);
11416 assert!(t.ends_with("..."), "{t}");
11417 assert!(t.is_ascii(), "{t}");
11418 }
11419
11420 #[test]
11421 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
11422 let line = format!(
11423 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
11424 "plus more filler words ".repeat(20)
11425 );
11426 let t = long_title_of(&line);
11427 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
11428 }
11429
11430 #[test]
11431 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
11432 let line = format!("\n\n# {}", "title words ".repeat(40));
11433 let state = state_with_summary(&line, "- nothing");
11434 let m = pr_message(&state, 'A');
11435 assert!(m.title.starts_with("title words"), "{}", m.title);
11436 assert_eq!(
11437 land::merge_subject(&m.title, &landing_subject_source(&state)),
11438 m.title
11439 );
11440 assert_eq!(
11442 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
11443 "feat: renamed by hand"
11444 );
11445 }
11446
11447 #[test]
11448 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
11449 let mut state = state_with_summary(
11453 "add retries",
11454 "TITLE: fix(web): batch reads\n- reads run.json once",
11455 );
11456 state.config.graph.language = "ja".to_owned();
11457 let m = pr_message(&state, 'A');
11458 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
11459
11460 let task = "今回やってほしいこと: results projector を直す";
11463 let mut state = state_with_summary(task, "- no title line");
11464 state.config.graph.language = "ja".to_owned();
11465 let m = pr_message(&state, 'A');
11466 assert_eq!(
11467 m.title,
11468 format!("chore: land candidate A of run {}", state.id)
11469 );
11470 assert!(
11471 m.body.contains(&format!(
11472 "<summary>Original task</summary>\n\n{task}\n\n</details>"
11473 )),
11474 "{}",
11475 m.body
11476 );
11477 }
11478
11479 #[test]
11480 fn pr_message_scrubs_home_paths_and_addresses() {
11481 let state = state_with_summary(
11482 "fix it in /Users/someone/src/x",
11483 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
11484 );
11485 let m = pr_message(&state, 'A');
11486 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
11487 assert!(!m.body.contains(leak), "{}", m.body);
11488 }
11489 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
11490 }
11491
11492 #[test]
11493 fn pr_message_survives_a_task_that_closes_details() {
11494 let state = state_with_summary("a </details> b", "TITLE: fix: x");
11495 let m = pr_message(&state, 'A');
11496 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
11497 }
11498
11499 #[test]
11500 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
11501 let cmd = manual_merge_command(
11502 MergeStyle::Squash,
11503 Path::new("/repo"),
11504 "b",
11505 "fix: \"quoted\" $(x) `y`\n\nbody",
11506 );
11507 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
11508 }
11509
11510 #[test]
11511 fn manual_merge_command_matches_the_configured_style() {
11512 let repo = Path::new("/repo");
11513 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
11514
11515 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
11516 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
11517
11518 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
11519 assert_eq!(
11520 squash,
11521 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
11522 \"Merge magi run 0832 (candidate A)\""
11523 );
11524
11525 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
11526 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
11527 }
11528
11529 #[test]
11530 fn a_nudge_gets_a_quarter_of_the_budget() {
11531 assert_eq!(retry_budget(secs(1200), true), secs(300));
11533 assert_eq!(retry_budget(secs(3600), true), secs(900));
11534 }
11535
11536 #[test]
11537 fn a_resent_prompt_keeps_the_whole_budget() {
11538 assert_eq!(retry_budget(secs(1200), false), secs(1200));
11541 assert_eq!(retry_budget(secs(60), false), secs(60));
11542 }
11543
11544 #[test]
11545 fn the_floor_never_exceeds_the_original_budget() {
11546 assert_eq!(retry_budget(secs(60), true), secs(60));
11550 assert_eq!(retry_budget(secs(480), true), secs(120));
11551 assert_eq!(retry_budget(secs(0), true), secs(0));
11552 }
11553
11554 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
11555 agent::CommandEvidence {
11556 id: "item1".to_owned(),
11557 description: "cargo test".to_owned(),
11558 exit_code,
11559 result_summary: String::new(),
11560 source: "codex".to_owned(),
11561 }
11562 }
11563
11564 #[test]
11565 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
11566 assert!(!has_unconfirmed_command(&[]));
11570 }
11571
11572 #[test]
11573 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
11574 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
11578 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
11579 assert!(!has_unconfirmed_command(&[
11580 evidence(Some(0)),
11581 evidence(Some(101))
11582 ]));
11583 }
11584
11585 #[test]
11586 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
11587 assert!(has_unconfirmed_command(&[
11588 evidence(Some(0)),
11589 evidence(None)
11590 ]));
11591 }
11592
11593 #[test]
11594 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
11595 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11596 assert_eq!(
11597 verified_noop_claim(true, &[], text).as_deref(),
11598 Some("already fixed by b32cfc4, on main.")
11599 );
11600 }
11601
11602 #[test]
11603 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
11604 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11607 assert!(verified_noop_claim(false, &[], text).is_none());
11608 }
11609
11610 #[test]
11611 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
11612 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11613 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
11614 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
11616 }
11617
11618 #[test]
11619 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
11620 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
11621 }
11622
11623 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
11626 runner.state.candidates = shape
11627 .iter()
11628 .enumerate()
11629 .map(|(i, &(empty, verified))| Candidate {
11630 index: i,
11631 label: (b'A' + i as u8) as char,
11632 agent: "sonnet".to_owned(),
11633 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
11634 worktree: PathBuf::from(format!("/wt/{i}")),
11635 summary: String::new(),
11636 stat: String::new(),
11637 files: 0,
11638 commits: 0,
11639 empty,
11640 failed: None,
11641 verified_noop: verified.map(str::to_owned),
11642 duration_ms: 0,
11643 folded: false,
11644 })
11645 .collect();
11646 }
11647
11648 #[test]
11649 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
11650 ask_test_home();
11651 let mut runner = runner_at(RunStatus::Implementing);
11652 set_candidates(
11653 &mut runner,
11654 &[
11655 (true, Some("already on main at b32cfc4")),
11656 (true, Some("same fix, see the existing test")),
11657 ],
11658 );
11659
11660 runner
11661 .after_implement()
11662 .expect("a verified no-op is not an error");
11663
11664 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
11665 }
11666
11667 #[test]
11668 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
11669 ask_test_home();
11670 let mut runner = runner_at(RunStatus::Implementing);
11671 set_candidates(
11675 &mut runner,
11676 &[(true, Some("already on main at b32cfc4")), (true, None)],
11677 );
11678
11679 let err = runner
11680 .after_implement()
11681 .expect_err("an unverified empty candidate must still fail the run");
11682
11683 assert!(
11684 err.to_string().contains("no candidate produced a change"),
11685 "{err}"
11686 );
11687 assert_eq!(runner.state.status, RunStatus::Failed);
11688 }
11689
11690 #[test]
11691 fn after_implement_still_fails_an_ordinary_all_empty_run() {
11692 ask_test_home();
11693 let mut runner = runner_at(RunStatus::Implementing);
11694 set_candidates(&mut runner, &[(true, None), (true, None)]);
11695
11696 let err = runner
11697 .after_implement()
11698 .expect_err("no candidate declared anything; this is an ordinary failure");
11699
11700 assert!(
11701 err.to_string().contains("no candidate produced a change"),
11702 "{err}"
11703 );
11704 assert_eq!(runner.state.status, RunStatus::Failed);
11705 }
11706}