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 bail!(
480 "cannot read {tracking} ({why}); refusing to branch off the local \
481 `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
482 base / remote in magi.toml"
483 )
484}
485
486struct FixClaim {
503 path: PathBuf,
504}
505
506impl FixClaim {
507 fn acquire(dir: &Path) -> Result<Self> {
508 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
509 let path = dir.join("fix.lock");
510 match Self::create(&path) {
511 Ok(claim) => Ok(claim),
512 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
513 if Self::reclaim_if_dead(&path) {
514 Self::create(&path).with_context(|| format!("lock {}", path.display()))
515 } else {
516 bail!(
517 "another `magi fix` is already running for this run ({} exists)",
518 path.display()
519 )
520 }
521 }
522 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
523 }
524 }
525
526 fn create(path: &Path) -> std::io::Result<Self> {
527 let mut f = std::fs::OpenOptions::new()
528 .write(true)
529 .create_new(true)
530 .open(path)?;
531 use std::io::Write as _;
532 writeln!(f, "{}", std::process::id())?;
534 Ok(Self {
535 path: path.to_owned(),
536 })
537 }
538
539 fn reclaim_if_dead(path: &Path) -> bool {
543 let dead = std::fs::read_to_string(path)
544 .ok()
545 .and_then(|body| body.trim().parse::<u32>().ok())
546 .is_some_and(|pid| !crate::proc::pid_alive(pid));
547 dead && std::fs::remove_file(path).is_ok()
548 }
549}
550
551impl Drop for FixClaim {
552 fn drop(&mut self) {
553 let _ = std::fs::remove_file(&self.path);
554 }
555}
556
557impl Runner {
558 pub async fn start(
560 repo: &Path,
561 instruction: String,
562 config: Config,
563 origin: Origin,
564 ) -> Result<Self> {
565 Self::start_naming(repo, instruction, "", config, origin).await
566 }
567
568 pub async fn start_naming(
572 repo: &Path,
573 instruction: String,
574 also_scan: &str,
575 config: Config,
576 origin: Origin,
577 ) -> Result<Self> {
578 let repo = git::toplevel(repo).await?;
579 let missing = agent::missing_programs(&config.agents);
580 if !missing.is_empty() {
581 bail!(
582 "these agent programs are not on PATH: {}. Fix the roster in \
583 magi.toml or install them.",
584 missing.join(", ")
585 );
586 }
587 let base_branch =
588 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
589 .await?;
590 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
591 let roles = config.resolve_roles()?;
592 let max_parallel = config.graph.max_parallel.max(1);
593 let seeds = refs::resolve(
596 &repo,
597 &base_commit,
598 &config.merge.remote,
599 &format!("{also_scan}\n{instruction}"),
600 )
601 .await;
602 refs::plan(&repo, &seeds).await?;
603 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
604 state.origin = Some(origin);
608 for seed in &seeds {
609 state.event(
610 "seed",
611 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
612 );
613 }
614 state.seeds = seeds;
615 state.event("start", format!("run {} created", state.id));
616 state.save()?;
617 Ok(Self {
618 state,
619 roles,
620 sem: Arc::new(Semaphore::new(max_parallel)),
621 pause: Pause::new(),
622 interrupt: Pause::new(),
623 })
624 }
625
626 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
640 Self::review_taking_over(repo, branch, config, None, origin).await
641 }
642
643 pub async fn review_taking_over(
650 repo: &Path,
651 branch: &str,
652 config: Config,
653 takeover: Option<crate::handover::Takeover>,
654 origin: Origin,
655 ) -> Result<Self> {
656 let repo = git::toplevel(repo).await?;
657 let missing = agent::missing_programs(&config.agents);
658 if !missing.is_empty() {
659 bail!(
660 "these agent programs are not on PATH: {}. Fix the roster in \
661 magi.toml or install them.",
662 missing.join(", ")
663 );
664 }
665 let base_branch =
666 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
667 .await?;
668 if base_branch == branch {
669 bail!("`{branch}` is the base branch; there is nothing to review against");
670 }
671 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
672
673 let roles = config.resolve_roles()?;
674 let max_parallel = config.graph.max_parallel.max(1);
675 let mut state = RunState::new(
676 repo.clone(),
677 base_branch,
678 base_commit.clone(),
679 String::new(),
680 config,
681 );
682 state.origin = Some(origin);
683
684 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
691 earlier: Vec::new(),
692 home: crate::run::home(),
693 choice: None,
694 });
695 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
696 if let Some(released) = &released {
697 state.event(
698 "release",
699 format!(
700 "took `{branch}` over from run {}: its worktree was released: {}",
701 crate::run::short_of(&released.old_id),
702 released.audit
703 ),
704 );
705 }
706 if let Some(choice) = takeover.choice.as_ref()
710 && let Err(e) =
711 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
712 .await
713 {
714 if let Some(released) = &released {
715 released.restore(&repo, branch).await;
716 }
717 return Err(e.context("applying the owner's answer about the diverged branch"));
718 }
719 let opened =
720 Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
721 if opened.is_err()
722 && let Some(released) = &released
723 {
724 released.restore(&repo, branch).await;
725 }
726 opened
727 }
728
729 async fn open_review(
732 repo: &Path,
733 branch: &str,
734 mut state: RunState,
735 roles: ResolvedRoles,
736 max_parallel: usize,
737 base_commit: String,
738 ) -> Result<Self> {
739 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
740 let start = review_base(
743 repo,
744 &state.config.merge.remote,
745 &state.base_branch,
746 &base_commit,
747 branch,
748 )
749 .await;
750 let log = git::log_oneline(repo, &start, branch)
751 .await
752 .unwrap_or_default();
753 let instruction = format!(
754 "Review the work already on branch `{branch}`. There is no task \
755 statement: what the change claims to do is whatever its commits \
756 say.\n\n{}",
757 if log.trim().is_empty() {
758 "(no commit messages)"
759 } else {
760 log.trim()
761 }
762 );
763 state.instruction = instruction;
764 state.reviewed_commits = Some(
765 git::subjects(repo, &start, branch)
766 .await
767 .unwrap_or_default(),
768 );
769
770 let worktree = state.worktree_root().join("under-review");
773 if let Some(parent) = worktree.parent() {
774 tokio::fs::create_dir_all(parent).await.ok();
775 }
776 let path = worktree.to_string_lossy().to_string();
777 git::git(repo, &["worktree", "add", &path, branch])
778 .await
779 .with_context(|| {
780 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
781 })?;
782
783 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
784 .await
785 .unwrap_or(0);
786 if commits == 0 {
787 git::worktree_remove(repo, &worktree).await.ok();
788 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
789 }
790 let files = git::changed_files(&worktree, &base_commit, "HEAD")
791 .await
792 .map(|f| f.len())
793 .unwrap_or(0);
794 if files == 0
795 && let (Ok(head_tree), Ok(base_tree)) = (
796 git::tree_of(&worktree, "HEAD").await,
797 git::tree_of(&worktree, &base_commit).await,
798 )
799 && head_tree == base_tree
800 {
801 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
802 git::worktree_remove(repo, &worktree).await.ok();
803 bail!(
804 "`{branch}` at {} has a tree identical to base {}; this usually means \
805 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
806 against `{}/{branch}`) rather than an empty change",
807 short(&head),
808 short(&base_commit),
809 state.config.merge.remote
810 );
811 }
812 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
813 .await
814 .unwrap_or_default();
815
816 state.candidates.push(Candidate {
817 index: 0,
818 label: 'A',
819 agent: EXISTING_BRANCH.to_owned(),
822 branch: branch.to_owned(),
823 worktree,
824 summary: String::new(),
825 stat,
826 files,
827 commits,
828 empty: false,
829 failed: None,
830 verified_noop: None,
831 duration_ms: 0,
832 folded: false,
833 });
834 state.tally = Some(Tally {
835 first_choice: BTreeMap::from([('A', 0)]),
836 borda: BTreeMap::new(),
837 winner: 'A',
838 rankings: 0,
839 unanimous_initial: false,
840 deliberated: false,
841 changed_votes: 0,
842 unanimous_final: false,
843 tie_break: None,
844 judges: 0,
848 present: 0,
849 quorum: 0,
850 met_quorum: true,
851 uncontested: Some("review-only run: nothing competed".to_owned()),
852 });
853 state.status = RunStatus::Reviewing;
854 state.event(
855 "start",
856 format!(
857 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
858 state.id
859 ),
860 );
861 state.save()?;
862 Ok(Self {
863 state,
864 roles,
865 sem: Arc::new(Semaphore::new(max_parallel)),
866 pause: Pause::new(),
867 interrupt: Pause::new(),
868 })
869 }
870
871 pub fn resume(id: &str) -> Result<Self> {
873 let state = RunState::load(id)?;
874 if let Some(to) = &state.released_to {
875 bail!(
876 "run {} cannot be resumed: its worktree was released to run {}",
877 state.short(),
878 crate::run::short_of(to)
879 );
880 }
881 let roles = state.config.resolve_roles()?;
882 let max_parallel = state.config.graph.max_parallel.max(1);
883 Ok(Self {
884 state,
885 roles,
886 sem: Arc::new(Semaphore::new(max_parallel)),
887 pause: Pause::new(),
888 interrupt: Pause::new(),
889 })
890 }
891
892 pub async fn execute(&mut self) -> Result<()> {
899 let result = self.execute_graph().await;
900 self.mark_driver_exited();
901 let ended = if result.is_err() {
902 Some(crate::notices::run_stopped(&self.state.id, &self.state))
903 } else {
904 crate::notices::run_ended(&self.state)
905 };
906 if let Some(notice) = ended {
907 crate::notices::raise(notice);
908 }
909 result
910 }
911
912 fn mark_driver_exited(&mut self) {
921 self.state.driver_exited = true;
922 let pid = std::process::id();
923 let Ok(mut disk) = RunState::load(&self.state.id) else {
924 return;
925 };
926 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
927 return;
928 }
929 disk.driver_exited = true;
930 if let Err(e) = disk.save() {
931 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
932 }
933 }
934
935 async fn execute_graph(&mut self) -> Result<()> {
936 self.state.parked = false;
941 self.state.clear_active();
948 if let Ok(disk) = RunState::load(&self.state.id)
966 && let Some(to) = &disk.released_to
967 {
968 bail!(
969 "run {} cannot continue: its worktree was released to run {}",
970 self.state.short(),
971 crate::run::short_of(to)
972 );
973 }
974 let pid = std::process::id();
975 self.state.driver_pid = Some(pid);
976 self.state.driver_started_at = crate::proc::process_started_at(pid);
977 self.state.driver_exited = false;
978 self.state.save()?;
979 if self.state.status == RunStatus::Stalled {
992 if self.recover_stall().await? {
993 self.finish_after_tally().await?;
994 } else {
995 self.state.save()?;
997 }
998 return Ok(());
999 }
1000 if self.state.status == RunStatus::Landing {
1010 self.run_land().await?;
1011 self.settle_questions();
1016 return Ok(());
1017 }
1018 self.prep().await?;
1019 if self.park_here()? {
1020 return Ok(());
1021 }
1022 self.advise().await?;
1023 if self.park_here()? {
1024 return Ok(());
1025 }
1026 self.implement().await?;
1027 if self.park_here()? {
1028 return Ok(());
1029 }
1030 if self.state.status == RunStatus::VerifiedNoop {
1034 return Ok(());
1035 }
1036 self.judge().await?;
1037 if self.park_here()? {
1038 return Ok(());
1039 }
1040 self.deliberate().await?;
1041 if self.park_here()? {
1042 return Ok(());
1043 }
1044 self.vote().await?;
1045 if self.park_here()? {
1046 return Ok(());
1047 }
1048 self.tally()?;
1049 if self.state.status == RunStatus::Stalled {
1054 self.state.save()?;
1058 return Ok(());
1059 }
1060 self.finish_after_tally().await?;
1061 Ok(())
1062 }
1063
1064 fn park_here(&mut self) -> Result<bool> {
1071 if !self.pause.parked() && !self.interrupt.parked() {
1076 return Ok(false);
1077 }
1078 let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1079 Some(reason) => format!(
1080 "parked after `{}` ({reason}) — resume to carry on from here",
1081 self.state.status.as_str()
1082 ),
1083 None => format!(
1084 "parked after `{}` — resume to carry on from here",
1085 self.state.status.as_str()
1086 ),
1087 };
1088 self.state.event("park", why);
1089 self.state.parked = true;
1090 self.state.save()?;
1091 Ok(true)
1092 }
1093
1094 pub fn on_pause(&mut self, pause: Pause) {
1096 self.pause = pause;
1097 }
1098
1099 pub fn watch_interrupt(&mut self, pause: Pause) {
1105 self.interrupt = pause;
1106 }
1107
1108 fn settle_questions(&mut self) {
1128 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1129 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1130 }
1131 }
1132
1133 async fn finish_after_tally(&mut self) -> Result<()> {
1136 self.fold_losers().await?;
1137 self.sync_to_base().await?;
1142 if self.state.status == RunStatus::AlreadyInBase {
1143 return Ok(());
1144 }
1145 self.review_loop().await?;
1146 self.sync_to_base().await?;
1147 if self.state.status == RunStatus::AlreadyInBase {
1148 return Ok(());
1149 }
1150 self.gate().await?;
1151 self.merge().await?;
1152 self.state.save()?;
1153 Ok(())
1154 }
1155
1156 async fn prep(&mut self) -> Result<()> {
1159 if !self.state.candidates.is_empty() {
1160 return Ok(());
1161 }
1162 self.state.status = RunStatus::Prep;
1163 let repo = self.state.repo.clone();
1164 let base = self.state.base_commit.clone();
1165 let plan = refs::plan(&repo, &self.state.seeds).await?;
1166 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1167 let root = self.state.worktree_root();
1168 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1169
1170 let hooks_dir = self.state.dir().join("hooks");
1173 if self.state.config.blind.commit_msg_hook {
1174 std::fs::create_dir_all(&hooks_dir)
1175 .with_context(|| format!("create {}", hooks_dir.display()))?;
1176 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1177 let path = hooks_dir.join("commit-msg");
1178 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1179 make_executable(&path)?;
1180 git::acquire_worktree_config(&repo).await?;
1188 self.state.enabled_worktree_config = true;
1189 }
1190
1191 for (index, (spec, label)) in self
1192 .roles
1193 .implementers
1194 .clone()
1195 .into_iter()
1196 .zip(labels)
1197 .enumerate()
1198 {
1199 let branch = self.state.branch_for(label);
1200 let worktree = root.join(format!("cand-{label}"));
1201 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1202 if self.state.config.blind.commit_msg_hook {
1203 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1204 }
1205 git::local_exclude(&worktree, "/.magi/").await?;
1206 for pick in &plan.picks {
1207 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1208 self.state.status = RunStatus::Blocked;
1209 self.state
1210 .event("prep", format!("cannot apply referenced commit: {e}"));
1211 self.state.save()?;
1212 return Err(e);
1213 }
1214 }
1215 self.state.candidates.push(Candidate {
1216 index,
1217 label,
1218 agent: spec.id.clone(),
1219 branch,
1220 worktree,
1221 summary: String::new(),
1222 stat: String::new(),
1223 files: 0,
1224 commits: 0,
1225 empty: false,
1226 failed: None,
1227 verified_noop: None,
1228 duration_ms: 0,
1229 folded: false,
1230 });
1231 }
1232
1233 for j in 1..=self.roles.judges.len() {
1234 let wt = root.join(format!("judge-{j}"));
1235 if !wt.exists() {
1236 git::worktree_add_detached(&repo, &wt, &base).await?;
1237 }
1238 }
1239
1240 if self.state.config.graph.advise {
1248 for k in 1..=self.state.config.graph.advisors {
1249 let wt = root.join(format!("advisor-{k}"));
1250 if !wt.exists() {
1251 git::worktree_add_detached(&repo, &wt, &base).await?;
1252 }
1253 }
1254 }
1255
1256 let authors: Vec<&str> = self
1261 .roles
1262 .implementers
1263 .iter()
1264 .map(|a| a.id.as_str())
1265 .collect();
1266 let overlap: Vec<String> = self
1267 .roles
1268 .judges
1269 .iter()
1270 .enumerate()
1271 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1272 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1273 .collect();
1274 if !overlap.is_empty() {
1275 let note = format!(
1276 "{} also authored a candidate; blind, but the panel is less \
1277 independent than {} distinct agents would be",
1278 overlap.join(", "),
1279 self.roles.judges.len()
1280 );
1281 self.state.event("prep", note);
1282 }
1283
1284 self.state.event(
1285 "prep",
1286 format!(
1287 "{} candidates, {} judges, base {} ({})",
1288 self.state.candidates.len(),
1289 self.roles.judges.len(),
1290 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1291 self.state.base_branch
1292 ),
1293 );
1294 self.state.status = RunStatus::Implementing;
1295 self.state.save()?;
1296 Ok(())
1297 }
1298
1299 async fn advise(&mut self) -> Result<()> {
1332 let implement_untouched = self
1333 .state
1334 .candidates
1335 .iter()
1336 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1337 if !self.state.config.graph.advise || self.state.advise_attempted {
1338 return Ok(());
1339 }
1340 if !implement_untouched {
1341 self.state.event(
1342 "advise",
1343 "skipping the design-deliberation stage: at least one \
1344 candidate already shows implementation progress, so this \
1345 run is past the point the stage exists to run before"
1346 .to_owned(),
1347 );
1348 self.state.advise_attempted = true;
1349 self.state.save()?;
1350 return Ok(());
1351 }
1352 let run_id = self.state.id.clone();
1353 let prompts = self.state.config.prompts.clone();
1354 let instruction = self.state.instruction.clone();
1355 let language = self.state.config.graph.language.clone();
1356 let root = self.state.worktree_root();
1357 let n = self.state.config.graph.advisors;
1358 let where_recorded = self.state.dir().join("run.json");
1359
1360 let seats = match self.state.config.advisors() {
1361 Ok(seats) if !seats.is_empty() => seats,
1362 Ok(_) => {
1363 self.state.event(
1364 "advise",
1365 format!(
1366 "[graph] advisors is 0; skipping the design-deliberation \
1367 stage and continuing without a synthesis brief (see {})",
1368 where_recorded.display()
1369 ),
1370 );
1371 self.state.advise_attempted = true;
1372 self.state.save()?;
1373 return Ok(());
1374 }
1375 Err(e) => {
1376 self.state.event(
1377 "advise",
1378 format!(
1379 "could not resolve advisor seats ({e:#}); continuing \
1380 without a design-deliberation brief (see {})",
1381 where_recorded.display()
1382 ),
1383 );
1384 self.state.advise_attempted = true;
1385 self.state.save()?;
1386 return Ok(());
1387 }
1388 };
1389
1390 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1391 let artifacts = agent::artifacts_dir(&self.state.dir());
1392 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1393
1394 let mut jobs = Vec::new();
1395 for (i, spec) in seats.iter().cloned().enumerate() {
1396 let seat_key = format!("advisor-{}", i + 1);
1397 let seat = self.seat(&seat_key, &spec.id);
1398 jobs.push(SeatJob {
1399 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1400 spec,
1401 seat,
1402 cwd: worktrees[i % worktrees.len()].clone(),
1403 timeout,
1404 allow_write: false,
1405 sessions: false,
1406 artifacts: artifacts.clone(),
1407 stem: seat_key,
1408 handover: None,
1409 });
1410 }
1411
1412 self.state.event(
1413 "advise",
1414 format!(
1415 "{} advisor seat(s) sketching a design in parallel",
1416 jobs.len()
1417 ),
1418 );
1419 let mut quota_losses = Vec::new();
1420 let cache = self.state.config.cache_dir();
1421 let ctx = WaveCtx {
1422 carry_seats: false,
1423 run: &run_id,
1424 node: "advise",
1425 prompts: &prompts,
1426 cache: cache.as_deref(),
1427 round: None,
1428 };
1429 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1430 let results = ask_json_wave::<Proposal>(
1431 jobs,
1432 Arc::clone(&self.sem),
1433 self.state.config.graph.retries,
1434 &advisor_roster,
1435 &ctx,
1436 &mut quota_losses,
1437 &mut self.state,
1438 &|p: &Proposal| p.validate(),
1439 )
1440 .await;
1441 self.state.quota.extend(quota_losses);
1442
1443 let mut records = Vec::with_capacity(results.len());
1444 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1445 let agent_id = seat.agent.clone();
1446 self.state.seats.insert(seat.key.clone(), seat);
1447 match res {
1448 Ok((proposal, out)) => {
1449 self.state
1450 .event("advise", format!("advisor-{} proposed a design", i + 1));
1451 records.push(advise::AdvisorRecord::proposed(
1452 i + 1,
1453 agent_id,
1454 proposal,
1455 out.duration_ms,
1456 ));
1457 }
1458 Err(e) => {
1459 self.state.event(
1460 "advise",
1461 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1462 );
1463 records.push(advise::AdvisorRecord::failed(
1464 i + 1,
1465 agent_id,
1466 e.to_string(),
1467 ));
1468 }
1469 }
1470 }
1471
1472 let mut advice = advise::Advice {
1473 records,
1474 synthesis: None,
1475 };
1476 if advice.proposals().is_empty() {
1477 self.state.event(
1478 "advise",
1479 "no advisor produced a usable proposal; continuing without a \
1480 synthesis brief"
1481 .to_owned(),
1482 );
1483 } else {
1484 match self
1485 .synthesize_brief(
1486 &advice,
1487 &instruction,
1488 &language,
1489 &worktrees[0],
1490 &artifacts,
1491 &run_id,
1492 &prompts,
1493 cache.as_deref(),
1494 )
1495 .await
1496 {
1497 Ok(Some(text)) => {
1498 self.state.event(
1499 "advise",
1500 "synthesized a design brief for the implementer".to_owned(),
1501 );
1502 advice.synthesis = Some(text);
1503 }
1504 Ok(None) => {
1505 self.state.event(
1506 "advise",
1507 "the synthesis seat produced nothing usable; continuing \
1508 without a design brief"
1509 .to_owned(),
1510 );
1511 }
1512 Err(e) => {
1513 self.state.event(
1514 "advise",
1515 format!("could not synthesize a design brief: {e:#}"),
1516 );
1517 }
1518 }
1519 }
1520 advise::apply_reflection(&mut advice);
1521
1522 self.state.advice = Some(advice);
1523 self.state.advise_attempted = true;
1524 self.state.save()?;
1525 Ok(())
1526 }
1527
1528 #[allow(clippy::too_many_arguments)]
1540 async fn synthesize_brief(
1541 &mut self,
1542 advice: &advise::Advice,
1543 instruction: &str,
1544 language: &str,
1545 cwd: &Path,
1546 artifacts: &Path,
1547 run_id: &str,
1548 prompts: &Prompts,
1549 cache: Option<&Path>,
1550 ) -> Result<Option<String>> {
1551 let chain = agent::pick_chain(
1552 &self.state.config.agents,
1553 self.state.config.roles.synthesizer.as_ref(),
1554 &agent::installed,
1555 "synthesizer",
1556 )?;
1557 let proposals = advice.proposals();
1558 let mut prompt = prompt::with_overlay(
1559 prompt::synthesize_brief(instruction, &proposals, language),
1560 prompts.overlay("advise"),
1561 );
1562 if cache.is_some() {
1563 prompt.push('\n');
1568 prompt.push_str(&prompt::build_cache_note("advise", false));
1569 }
1570 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1571 let mut last = None;
1576 for (n, spec) in chain.iter().enumerate() {
1577 if n > 0 {
1578 self.state
1579 .event("advise", format!("synthesis falling back to {}", spec.id));
1580 }
1581 let mut seat = self.seat("advise-synthesis", &spec.id);
1582 let outcome = agent::invoke(
1583 spec,
1584 &mut seat,
1585 &Invocation {
1586 cwd,
1587 prompt: &prompt,
1588 timeout,
1589 allow_write: false,
1590 sessions: false,
1591 artifacts,
1592 stem: &if n == 0 {
1593 "advise-synthesis".to_owned()
1594 } else {
1595 format!("advise-synthesis-{}", spec.id)
1596 },
1597 run: run_id,
1598 node: "advise",
1599 cache_dir: None,
1600 attachments: &[],
1601 writable: &[],
1602 },
1603 )
1604 .await;
1605 if outcome.is_ok() {
1606 self.state.seats.insert(seat.key.clone(), seat);
1607 }
1608 let advance = agent::chain_advances(&outcome);
1609 last = Some(outcome);
1610 if !advance {
1611 break;
1612 }
1613 }
1614 let out = last.expect("a chain holds at least one agent")?;
1617 if !out.usable() {
1618 return Ok(None);
1619 }
1620 let text =
1621 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1622 Ok((!text.trim().is_empty()).then_some(text))
1623 }
1624
1625 async fn implement(&mut self) -> Result<()> {
1628 let run_id = self.state.id.clone();
1633 let prompts = self.state.config.prompts.clone();
1634 let todo: Vec<usize> = self
1635 .state
1636 .candidates
1637 .iter()
1638 .enumerate()
1639 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1640 .map(|(i, _)| i)
1641 .collect();
1642 if todo.is_empty() {
1643 return self.after_implement();
1644 }
1645 self.state.status = RunStatus::Implementing;
1646
1647 let language = self.state.config.graph.language.clone();
1648 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1649 let sessions = self.state.config.graph.sessions;
1650 let artifacts = agent::artifacts_dir(&self.state.dir());
1651 let brief = self
1655 .state
1656 .advice
1657 .as_ref()
1658 .and_then(|a| a.synthesis.as_deref())
1659 .map(str::to_owned);
1660 let attachments = self.state.attachments.clone();
1661
1662 let mut jobs = Vec::new();
1663 for &i in &todo {
1664 let (index, label, worktree) = {
1665 let c = &self.state.candidates[i];
1666 (c.index, c.label, c.worktree.clone())
1667 };
1668 let spec = self.roles.implementers[index].clone();
1669 let seat_key = format!("impl-{label}");
1670 let seat = self.seat(&seat_key, &spec.id);
1671 let instruction = seeded_instruction(&self.state);
1672 jobs.push(SeatJob {
1673 spec,
1674 seat,
1675 prompt: prompt::implement(
1676 &instruction,
1677 &worktree.to_string_lossy(),
1678 &language,
1679 brief.as_deref(),
1680 &attachments,
1681 ),
1682 cwd: worktree,
1683 timeout,
1684 allow_write: true,
1685 sessions,
1686 artifacts: artifacts.clone(),
1687 stem: format!("impl-{label}"),
1688 handover: None,
1689 });
1690 }
1691
1692 self.state.event(
1693 "implement",
1694 format!("{} candidates in parallel", jobs.len()),
1695 );
1696 let mut sent = jobs.clone();
1702 let cache = self.state.config.cache_dir();
1703 let ctx = WaveCtx {
1704 carry_seats: false,
1705 run: &run_id,
1706 node: "implement",
1707 prompts: &prompts,
1708 cache: cache.as_deref(),
1709 round: None,
1710 };
1711 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1712 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1713 .await;
1714 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1715 .await;
1716 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1717 .await;
1718
1719 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1720 let seat_key = seat.key.clone();
1721 let agent = seat.agent.clone();
1730 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1731 self.state.seats.insert(seat.key.clone(), seat);
1732 let label = self.state.candidates[i].label;
1733 let worktree = self.state.candidates[i].worktree.clone();
1734 let base = self.state.base_commit.clone();
1735
1736 let (summary, duration, failed, verified_claim) = match out {
1737 AgentOutcome::Ok(o) => {
1738 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1739 let failed = (!o.usable()).then(|| {
1740 if o.timed_out {
1741 "agent timed out".to_owned()
1742 } else {
1743 format!("agent exited with {:?}", o.exit_code)
1744 }
1745 });
1746 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1747 (text, o.duration_ms, failed, verified_claim)
1748 }
1749 AgentOutcome::Dropped(o) => {
1755 let why = o
1756 .dropped
1757 .as_ref()
1758 .map(|d| d.why.as_str())
1759 .unwrap_or("the CLI ended the stream without delivering its answer");
1760 (
1761 String::new(),
1762 o.duration_ms,
1763 Some(format!("the CLI dropped the stream ({why})")),
1764 None,
1765 )
1766 }
1767 AgentOutcome::Quota(o) => {
1768 self.state.quota.push(QuotaLoss {
1769 seat: seat_key,
1770 node: "implement".to_owned(),
1771 at: Timestamp::now(),
1772 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1773 });
1774 (
1775 String::new(),
1776 o.duration_ms,
1777 Some("rate limited (quota); produced no change".to_owned()),
1778 None,
1779 )
1780 }
1781 AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1782 };
1783
1784 let rescued = match git::rescue_commit(
1787 &worktree,
1788 &format!("magi: candidate {label} (uncommitted work)"),
1789 )
1790 .await
1791 {
1792 Ok(r) => {
1793 self.state.note_withheld("implement", &r.withheld);
1794 r.committed
1795 }
1796 Err(_) => false,
1797 };
1798 let commits = git::commits_ahead(&worktree, &base, "HEAD")
1799 .await
1800 .unwrap_or(0);
1801 let patch = git::diff(&worktree, &base, "HEAD")
1802 .await
1803 .unwrap_or_default();
1804 let stat = git::diff_stat(&worktree, &base, "HEAD")
1805 .await
1806 .unwrap_or_default();
1807 let files = git::changed_files(&worktree, &base, "HEAD")
1808 .await
1809 .map(|f| f.len())
1810 .unwrap_or(0);
1811 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1812
1813 let c = &mut self.state.candidates[i];
1814 if !exhausted_the_fallback_chain {
1815 c.agent = agent;
1816 }
1817 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1818 c.stat = stat;
1819 c.files = files;
1820 c.commits = commits;
1821 c.duration_ms = duration;
1822 c.empty = commits == 0 || patch.trim().is_empty();
1823 c.failed = match failed {
1826 Some(_) if c.empty => failed,
1827 _ => None,
1828 };
1829 c.verified_noop = if c.empty { verified_claim } else { None };
1834 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1835 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1836 (None, true, Some(_), _) => {
1837 format!("candidate {label}: no change produced (agent-verified no-op)")
1838 }
1839 (None, true, None, _) => format!("candidate {label}: no change produced"),
1840 (None, false, _, true) => {
1841 format!(
1842 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1843 )
1844 }
1845 (None, false, _, false) => {
1846 format!("candidate {label}: {files} files, {commits} commits")
1847 }
1848 };
1849 self.state.event("implement", note);
1850 self.state.save()?;
1851 }
1852
1853 self.after_implement()
1854 }
1855
1856 async fn resume_undelivered(
1884 &mut self,
1885 results: &mut [(usize, SeatState, AgentOutcome)],
1886 sent: &[SeatJob],
1887 prompts: &Prompts,
1888 run_id: &str,
1889 ) {
1890 for (wi, seat, out) in results.iter_mut() {
1891 let Some(dropped) = (match &*out {
1892 AgentOutcome::Dropped(o) => o.dropped.clone(),
1893 _ => None,
1894 }) else {
1895 continue;
1896 };
1897 let Some(job) = sent.get(*wi) else { continue };
1898 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1900 self.state.event(
1901 "implement",
1902 format!(
1903 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1904 work is in the tree",
1905 seat.key, dropped.output_tokens, dropped.why
1906 ),
1907 );
1908 continue;
1909 }
1910 if !has_context(&job.spec, seat, job.sessions) {
1918 self.state.event(
1919 "implement",
1920 format!(
1921 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
1922 is no session left to resume",
1923 seat.key, dropped.output_tokens, dropped.why
1924 ),
1925 );
1926 continue;
1927 }
1928 self.state.event(
1929 "implement",
1930 format!(
1931 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
1932 conversation",
1933 seat.key, dropped.output_tokens, dropped.why
1934 ),
1935 );
1936 let mut retry = job.clone();
1937 retry.seat = seat.clone();
1938 retry.prompt = prompt::resume_after_drop(&dropped.why);
1939 retry.timeout = retry_budget(job.timeout, true);
1940 retry.stem = format!("{}-resume", job.stem);
1941 let cache = self.state.config.cache_dir();
1942 let ctx = WaveCtx {
1943 carry_seats: false,
1944 run: run_id,
1945 node: "implement",
1946 prompts,
1947 cache: cache.as_deref(),
1948 round: None,
1949 };
1950 let (resumed_seat, resumed) =
1951 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
1952 *seat = resumed_seat;
1953 *out = resumed;
1954 }
1955 }
1956
1957 async fn resume_seat_handovers(
2022 &mut self,
2023 results: &mut [(usize, SeatState, AgentOutcome)],
2024 sent: &mut [SeatJob],
2025 prompts: &Prompts,
2026 run_id: &str,
2027 ) {
2028 let instruction = seeded_instruction(&self.state);
2029 let language = self.state.config.graph.language.clone();
2030 let brief = self
2031 .state
2032 .advice
2033 .as_ref()
2034 .and_then(|a| a.synthesis.as_deref())
2035 .map(str::to_owned);
2036 let attachments = self.state.attachments.clone();
2037 for (wi, seat, out) in results.iter_mut() {
2038 let Some(job) = sent.get_mut(*wi) else {
2039 continue;
2040 };
2041 let start = self
2046 .roles
2047 .implementer_roster
2048 .iter()
2049 .position(|s| s.id == job.spec.id)
2050 .unwrap_or(0);
2051 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2052 let mut fallback_attempt = 0usize;
2053 let mut prev: Option<FailClass> = None;
2054 while let Some(cur) = FailClass::of(&*out) {
2055 if !should_hand_over(prev.as_ref(), &cur) {
2056 break;
2057 }
2058 let Some(next) =
2059 next_untried_in_roster(&self.roles.implementer_roster, start, &tried).cloned()
2060 else {
2061 break;
2062 };
2063 tried.insert(next.id.clone());
2064 fallback_attempt += 1;
2065
2066 if let Ok(r) = git::rescue_commit(
2067 &job.cwd,
2068 &format!(
2069 "magi: candidate {} (uncommitted work before {} fallback)",
2070 seat.key,
2071 if cur == FailClass::Quota {
2072 "quota"
2073 } else {
2074 "handover"
2075 }
2076 ),
2077 )
2078 .await
2079 {
2080 self.state.note_withheld("implement", &r.withheld);
2081 }
2082
2083 record_handover(
2084 &mut self.state,
2085 "implement",
2086 &seat.key,
2087 &seat.agent,
2088 &next.id,
2089 &cur,
2090 &fail_reason(&*out),
2091 );
2092 prev = Some(cur.clone());
2093
2094 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2095 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2096 job.spec = next.clone();
2104 let mut retry = job.clone();
2105 retry.seat = new_seat;
2106 retry.prompt = prompt::implement(
2107 &instruction,
2108 &job.cwd.to_string_lossy(),
2109 &language,
2110 brief.as_deref(),
2111 &attachments,
2112 );
2113 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2114 let cache = self.state.config.cache_dir();
2115 let ctx = WaveCtx {
2116 carry_seats: false,
2117 run: run_id,
2118 node: "implement",
2119 prompts,
2120 cache: cache.as_deref(),
2121 round: None,
2122 };
2123 let (fallback_seat, fallback_out) = run_one(
2124 retry,
2125 Arc::clone(&self.sem),
2126 &ctx,
2127 &mut self.state,
2128 fallback_attempt,
2129 )
2130 .await;
2131 *seat = fallback_seat;
2132 *out = fallback_out;
2133 }
2134 }
2135 }
2136
2137 async fn resume_unconfirmed_commands(
2161 &mut self,
2162 results: &mut [(usize, SeatState, AgentOutcome)],
2163 sent: &[SeatJob],
2164 prompts: &Prompts,
2165 run_id: &str,
2166 ) {
2167 for (wi, seat, out) in results.iter_mut() {
2168 let AgentOutcome::Ok(o) = &*out else {
2169 continue;
2170 };
2171 if !has_unconfirmed_command(&o.commands) {
2172 continue;
2173 }
2174 let Some(job) = sent.get(*wi) else { continue };
2175 if !has_context(&job.spec, seat, job.sessions) {
2176 self.state.event(
2177 "implement",
2178 format!(
2179 "{}: the reply named a command whose own CLI never confirmed the exit \
2180 status of, but there is no session left to resume",
2181 seat.key
2182 ),
2183 );
2184 continue;
2185 }
2186 self.state.event(
2187 "implement",
2188 format!(
2189 "{}: the reply named a command whose own CLI never confirmed the exit \
2190 status of; resuming the conversation",
2191 seat.key
2192 ),
2193 );
2194 let mut retry = job.clone();
2195 retry.seat = seat.clone();
2196 retry.prompt = prompt::resume_incomplete(
2197 "a command in your last reply had no confirmed exit status",
2198 );
2199 retry.timeout = retry_budget(job.timeout, true);
2200 retry.stem = format!("{}-confirm", job.stem);
2201 let cache = self.state.config.cache_dir();
2202 let ctx = WaveCtx {
2203 carry_seats: false,
2204 run: run_id,
2205 node: "implement",
2206 prompts,
2207 cache: cache.as_deref(),
2208 round: None,
2209 };
2210 let (resumed_seat, resumed) =
2211 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2212 *seat = resumed_seat;
2213 *out = resumed;
2214 }
2215 }
2216
2217 async fn continue_fix_report(
2238 &mut self,
2239 mut seat: SeatState,
2240 parse_err: String,
2241 job: &SeatJob,
2242 prompts: &Prompts,
2243 run_id: &str,
2244 round: usize,
2245 ) -> (
2246 SeatState,
2247 Option<FixReport>,
2248 Option<String>,
2249 ContinuationRecord,
2250 ) {
2251 let mut last_err = parse_err;
2252 let mut cumulative_wait_ms = 0u64;
2253 let mut attempts = 0usize;
2254 loop {
2255 if !has_context(&job.spec, &seat, job.sessions) {
2256 self.state.event(
2257 "fix",
2258 format!(
2259 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2260 session left to resume into"
2261 ),
2262 );
2263 let outcome = if attempts == 0 {
2264 ContinuationOutcome::NoSession
2265 } else {
2266 ContinuationOutcome::Exhausted
2267 };
2268 return (
2269 seat,
2270 None,
2271 Some(format!("unparsable fix report: {last_err}")),
2272 ContinuationRecord {
2273 attempts,
2274 cumulative_wait_ms,
2275 outcome,
2276 },
2277 );
2278 }
2279 if attempts >= MAX_FIX_CONTINUATIONS {
2280 self.state.event(
2281 "fix",
2282 format!(
2283 "round {round}: fixer's reply still had no adoption report after \
2284 {attempts} continuation(s) ({last_err}); giving up"
2285 ),
2286 );
2287 return (
2288 seat,
2289 None,
2290 Some(format!(
2291 "unparsable fix report after {attempts} continuation(s): {last_err}"
2292 )),
2293 ContinuationRecord {
2294 attempts,
2295 cumulative_wait_ms,
2296 outcome: ContinuationOutcome::Exhausted,
2297 },
2298 );
2299 }
2300 attempts += 1;
2301 self.state.event(
2302 "fix",
2303 format!(
2304 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2305 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2306 ),
2307 );
2308 let mut retry = job.clone();
2309 retry.seat = seat.clone();
2310 retry.prompt = prompt::resume_incomplete(&last_err);
2311 retry.timeout = retry_budget(job.timeout, true);
2312 retry.stem = format!("{}-continue{attempts}", job.stem);
2313 let cache = self.state.config.cache_dir();
2314 let ctx = WaveCtx {
2315 carry_seats: false,
2316 run: run_id,
2317 node: "fix",
2318 prompts,
2319 cache: cache.as_deref(),
2320 round: Some(round),
2321 };
2322 let (resumed_seat, resumed_out) = run_one(
2323 retry,
2324 Arc::clone(&self.sem),
2325 &ctx,
2326 &mut self.state,
2327 attempts,
2328 )
2329 .await;
2330 seat = resumed_seat;
2331 match resumed_out {
2332 AgentOutcome::Ok(o) => {
2333 cumulative_wait_ms += o.duration_ms;
2334 match verdict::extract_json::<FixReport>(&o.text) {
2335 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2336 self.state.event(
2337 "fix",
2338 format!(
2339 "round {round}: fixer's adoption report recovered after \
2340 {attempts} continuation(s)"
2341 ),
2342 );
2343 return (
2344 seat,
2345 Some(report),
2346 None,
2347 ContinuationRecord {
2348 attempts,
2349 cumulative_wait_ms,
2350 outcome: ContinuationOutcome::Resumed,
2351 },
2352 );
2353 }
2354 Ok(_) => {
2362 last_err = "the reply parsed, but it reported a command whose own CLI \
2363 never confirmed an exit status"
2364 .to_owned();
2365 }
2366 Err(e) => last_err = e.to_string(),
2367 }
2368 }
2369 AgentOutcome::Quota(o) => {
2370 cumulative_wait_ms += o.duration_ms;
2371 self.state.quota.push(QuotaLoss {
2372 seat: seat.key.clone(),
2373 node: "fix".to_owned(),
2374 at: Timestamp::now(),
2375 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2376 });
2377 self.state.event(
2378 "fix",
2379 format!(
2380 "round {round}: continuation rate limited (quota); not retrying now"
2381 ),
2382 );
2383 return (
2384 seat,
2385 None,
2386 Some("rate limited (quota) while recovering the fix report".to_owned()),
2387 ContinuationRecord {
2388 attempts,
2389 cumulative_wait_ms,
2390 outcome: ContinuationOutcome::QuotaLost,
2391 },
2392 );
2393 }
2394 AgentOutcome::Dropped(o) => {
2395 cumulative_wait_ms += o.duration_ms;
2396 let why = o
2397 .dropped
2398 .as_ref()
2399 .map(|d| d.why.as_str())
2400 .unwrap_or("the CLI ended the stream without delivering its answer");
2401 last_err = format!("the CLI dropped the stream ({why})");
2402 }
2403 AgentOutcome::Failed(e) => last_err = e,
2404 }
2405 }
2406 }
2407
2408 fn after_implement(&mut self) -> Result<()> {
2409 if self.state.leaks.is_empty() {
2411 let cfg = self.state.config.blind.clone();
2412 let mut leaks = Vec::new();
2413 for c in &self.state.candidates {
2414 let Some(patch) =
2415 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2416 else {
2417 continue;
2418 };
2419 leaks.extend(blind::scan(
2420 &format!("candidate {} patch", c.label),
2421 &patch,
2422 &cfg.vendor_tokens,
2423 ));
2424 }
2425 if !leaks.is_empty() {
2426 let summary = leaks
2427 .iter()
2428 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2429 .collect::<Vec<_>>()
2430 .join(", ");
2431 match cfg.on_leak {
2432 LeakPolicy::Fail => {
2433 self.state.status = RunStatus::Failed;
2434 self.state
2435 .event("blind", format!("vendor text in a patch: {summary}"));
2436 self.state.leaks = leaks;
2437 self.state.save()?;
2438 self.settle_questions();
2439 bail!(
2440 "blind.on_leak = \"fail\" and vendor text reached a \
2441 judged patch: {summary}"
2442 );
2443 }
2444 LeakPolicy::Redact => self.state.event(
2445 "blind",
2446 format!("redacting vendor text for judging: {summary}"),
2447 ),
2448 LeakPolicy::Warn => self.state.event(
2449 "blind",
2450 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2451 ),
2452 }
2453 self.state.leaks = leaks;
2454 }
2455 }
2456
2457 if self.state.viable().is_empty() {
2458 if self.state.all_candidates_verified_noop() {
2459 self.state.status = RunStatus::VerifiedNoop;
2470 self.state.save()?;
2471 self.settle_questions();
2472 return Ok(());
2473 }
2474 self.state.status = RunStatus::Failed;
2475 self.state.save()?;
2476 self.settle_questions();
2477 bail!("no candidate produced a change; nothing to judge");
2478 }
2479 self.state.status = RunStatus::Judging;
2480 self.state.save()?;
2481 Ok(())
2482 }
2483
2484 async fn judge(&mut self) -> Result<()> {
2487 let run_id = self.state.id.clone();
2492 let prompts = self.state.config.prompts.clone();
2493 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2494 return Ok(());
2495 }
2496 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2497 if viable.len() == 1 {
2498 self.state.judge_skipped = true;
2505 self.state.event(
2506 "judge",
2507 format!(
2508 "only candidate {} produced a change; judging skipped",
2509 viable[0].label
2510 ),
2511 );
2512 self.state.save()?;
2513 return Ok(());
2514 }
2515 self.state.status = RunStatus::Judging;
2516
2517 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2518 let language = self.state.config.graph.language.clone();
2519 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2520 let sessions = self.state.config.graph.sessions;
2521 let artifacts = agent::artifacts_dir(&self.state.dir());
2522 let root = self.state.worktree_root();
2523 let base_short = short(&self.state.base_commit);
2524
2525 let mut jobs = Vec::new();
2526 let mut orders = Vec::new();
2527 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2528 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2529 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2530 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2531 let seat_key = format!("judge-{}", j + 1);
2532 let seat = self.seat(&seat_key, &spec.id);
2533 jobs.push(SeatJob {
2534 prompt: prompt::judge(
2535 &self.state.instruction,
2536 &views,
2537 self.roles.judges.len(),
2538 &base_short,
2539 &language,
2540 ),
2541 spec,
2542 seat,
2543 cwd: root.join(format!("judge-{}", j + 1)),
2544 timeout,
2545 allow_write: false,
2546 sessions,
2547 artifacts: artifacts.clone(),
2548 stem: format!("judge-{}", j + 1),
2549 handover: None,
2550 });
2551 }
2552
2553 self.state.event(
2554 "judge",
2555 format!(
2556 "{} judges ranking {} candidates blind",
2557 jobs.len(),
2558 viable.len()
2559 ),
2560 );
2561 let labels_for_check = labels.clone();
2562 let mut quota_losses = Vec::new();
2563 let cache = self.state.config.cache_dir();
2564 let ctx = WaveCtx {
2565 carry_seats: false,
2566 run: &run_id,
2567 node: "judge",
2568 prompts: &prompts,
2569 cache: cache.as_deref(),
2570 round: None,
2571 };
2572 let results = ask_json_wave::<Ranking>(
2573 jobs,
2574 Arc::clone(&self.sem),
2575 self.state.config.graph.retries,
2576 &self.roles.judge_roster,
2577 &ctx,
2578 &mut quota_losses,
2579 &mut self.state,
2580 &move |r: &Ranking| r.validate(&labels_for_check),
2581 )
2582 .await;
2583 self.state.quota.extend(quota_losses);
2584
2585 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2586 let agent_id = seat.agent.clone();
2587 self.state.seats.insert(seat.key.clone(), seat);
2588 let mut record = Judgement {
2589 judge: j + 1,
2590 seat: format!("judge-{}", j + 1),
2591 agent: agent_id,
2592 ranking: Vec::new(),
2593 reasons: BTreeMap::new(),
2594 confidence: None,
2595 order: orders[j].clone(),
2596 failed: None,
2597 duration_ms: 0,
2598 };
2599 match res {
2600 Ok((ranking, out)) => {
2601 record.ranking = ranking.normalized();
2602 record.reasons = ranking.reasons;
2603 record.confidence = ranking.confidence;
2604 record.duration_ms = out.duration_ms;
2605 self.state.event(
2606 "judge",
2607 format!(
2608 "judge {} ranked {}",
2609 j + 1,
2610 record.ranking.iter().collect::<String>()
2611 ),
2612 );
2613 }
2614 Err(e) => {
2615 record.failed = Some(e.to_string());
2616 self.state
2617 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2618 }
2619 }
2620 self.state.judgements.push(record);
2621 self.state.save()?;
2622 }
2623 Ok(())
2624 }
2625
2626 async fn deliberate(&mut self) -> Result<()> {
2629 let run_id = self.state.id.clone();
2634 let prompts = self.state.config.prompts.clone();
2635 if !self.state.deliberation.is_empty() {
2636 return Ok(());
2637 }
2638 let tops: Vec<char> = self
2639 .state
2640 .judgements
2641 .iter()
2642 .filter_map(|j| j.ranking.first().copied())
2643 .collect();
2644 let rounds = self.state.config.graph.deliberate_rounds;
2645 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2646 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2647 self.state.event(
2648 "deliberate",
2649 format!("judges agreed on {} outright; no deliberation", tops[0]),
2650 );
2651 }
2652 self.state.status = RunStatus::Voting;
2653 self.state.save()?;
2654 return Ok(());
2655 }
2656
2657 self.state.status = RunStatus::Deliberating;
2658 self.state.event(
2659 "deliberate",
2660 format!(
2661 "split: first choices were {} — opening {rounds} round(s)",
2662 tops.iter().collect::<String>()
2663 ),
2664 );
2665
2666 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2667 let language = self.state.config.graph.language.clone();
2668 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2669 let sessions = self.state.config.graph.sessions;
2670 let artifacts = agent::artifacts_dir(&self.state.dir());
2671 let root = self.state.worktree_root();
2672 let base_short = short(&self.state.base_commit);
2673
2674 for round in 1..=rounds {
2678 let mut turns: Vec<DeliberationTurn> = Vec::new();
2679 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2680 if self.state.judgements[j].failed.is_some() {
2681 continue;
2682 }
2683 let seat_key = format!("judge-{}", j + 1);
2684 let spec = self.occupant(&seat_key, spec);
2685 let mut seat = self.seat(&seat_key, &spec.id);
2686 let transcript = self.transcript(&turns, j);
2687 let build = |context: Option<&str>| {
2688 prompt::deliberate(
2689 &self.state.instruction,
2690 context,
2691 &transcript,
2692 round,
2693 rounds,
2694 &language,
2695 )
2696 };
2697 let block = self.candidate_block(&viable, &base_short);
2698 let full = build(Some(&block));
2699 let text = if has_context(&spec, &seat, sessions) {
2700 build(None)
2701 } else {
2702 full.clone()
2703 };
2704 let job = SeatJob {
2705 spec,
2706 seat: seat.clone(),
2707 prompt: text,
2708 cwd: root.join(format!("judge-{}", j + 1)),
2709 timeout,
2710 allow_write: false,
2711 sessions,
2712 artifacts: artifacts.clone(),
2713 stem: format!("delib-{round}-judge-{}", j + 1),
2714 handover: Some(full),
2715 };
2716 let cache = self.state.config.cache_dir();
2717 let ctx = WaveCtx {
2718 carry_seats: false,
2719 run: &run_id,
2720 node: "deliberate",
2721 prompts: &prompts,
2722 cache: cache.as_deref(),
2723 round: None,
2724 };
2725 let mut losses = Vec::new();
2729 let mut results = ask_wave_with::<String>(
2730 vec![job],
2731 Arc::clone(&self.sem),
2732 0,
2733 &self.roles.judge_roster,
2734 &ctx,
2735 &mut losses,
2736 &mut self.state,
2737 &|text: &str| {
2738 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2739 },
2740 )
2741 .await;
2742 self.state.quota.extend(losses);
2743 let (updated, res, _) = results.pop().expect("one job in, one result out");
2744 seat = updated;
2745 let agent_id = seat.agent.clone();
2746 self.state.seats.insert(seat.key.clone(), seat);
2747 let body = match res {
2748 Ok((body, _)) => body,
2749 Err(e) => {
2752 self.state
2753 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2754 continue;
2755 }
2756 };
2757 let tentative = verdict::extract_json::<Position>(&body)
2758 .ok()
2759 .and_then(|p| p.tentative)
2760 .and_then(|s| s.trim().chars().next())
2761 .map(|c| c.to_ascii_uppercase());
2762 self.state.event(
2763 "deliberate",
2764 format!(
2765 "round {round}: judge {} now favours {}",
2766 j + 1,
2767 tentative.map_or("—".to_owned(), |c| c.to_string())
2768 ),
2769 );
2770 turns.push(DeliberationTurn {
2771 judge: j + 1,
2772 agent: agent_id,
2773 body: blind::sanitize_prose(&body, &self.state.config.blind),
2774 tentative,
2775 });
2776 }
2777 self.state
2778 .deliberation
2779 .push(DeliberationRound { round, turns });
2780 self.state.save()?;
2781 }
2782
2783 self.state.status = RunStatus::Voting;
2784 self.state.save()?;
2785 Ok(())
2786 }
2787
2788 async fn vote(&mut self) -> Result<()> {
2791 let run_id = self.state.id.clone();
2796 let prompts = self.state.config.prompts.clone();
2797 if !self.state.votes.is_empty() {
2798 return Ok(());
2799 }
2800 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2801 if viable.len() == 1 {
2802 return Ok(());
2803 }
2804 self.state.status = RunStatus::Voting;
2805
2806 let language = self.state.config.graph.language.clone();
2807 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2808 let sessions = self.state.config.graph.sessions;
2809 let artifacts = agent::artifacts_dir(&self.state.dir());
2810 let root = self.state.worktree_root();
2811 let base_short = short(&self.state.base_commit);
2812 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2813
2814 let mut jobs = Vec::new();
2815 let mut seats_at = Vec::new();
2816 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2817 if self
2818 .state
2819 .judgements
2820 .get(j)
2821 .is_some_and(|r| r.failed.is_some())
2822 {
2823 continue;
2824 }
2825 let seat_key = format!("judge-{}", j + 1);
2826 let spec = self.occupant(&seat_key, spec);
2827 let seat = self.seat(&seat_key, &spec.id);
2828 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2829 let text = if has_context(&spec, &seat, sessions) {
2830 prompt::final_vote(&viable, &language)
2831 } else {
2832 full.clone()
2833 };
2834 jobs.push(SeatJob {
2835 spec,
2836 seat,
2837 prompt: text,
2838 cwd: root.join(format!("judge-{}", j + 1)),
2839 timeout,
2840 allow_write: false,
2841 sessions,
2842 artifacts: artifacts.clone(),
2843 stem: format!("vote-judge-{}", j + 1),
2844 handover: Some(full),
2845 });
2846 seats_at.push(j);
2847 }
2848
2849 self.state.event(
2850 "vote",
2851 format!(
2852 "collecting {} final votes one by one, privately",
2853 jobs.len()
2854 ),
2855 );
2856 let allowed = viable.clone();
2857 let mut quota_losses = Vec::new();
2858 let cache = self.state.config.cache_dir();
2859 let ctx = WaveCtx {
2860 carry_seats: false,
2861 run: &run_id,
2862 node: "vote",
2863 prompts: &prompts,
2864 cache: cache.as_deref(),
2865 round: None,
2866 };
2867 let results = ask_json_wave::<FinalVote>(
2868 jobs,
2869 Arc::clone(&self.sem),
2870 self.state.config.graph.retries,
2871 &self.roles.judge_roster,
2872 &ctx,
2873 &mut quota_losses,
2874 &mut self.state,
2875 &move |v: &FinalVote| match v.label() {
2876 Some(c) if allowed.contains(&c) => Ok(()),
2877 other => bail!("vote {other:?} is not one of {allowed:?}"),
2878 },
2879 )
2880 .await;
2881 self.state.quota.extend(quota_losses);
2882
2883 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2884 let agent_id = seat.agent.clone();
2885 self.state.seats.insert(seat.key.clone(), seat);
2886 let initial = self
2887 .state
2888 .judgements
2889 .get(j)
2890 .and_then(|r| r.ranking.first().copied());
2891 let mut record = VoteRecord {
2892 judge: j + 1,
2893 agent: agent_id,
2894 vote: None,
2895 reason: String::new(),
2896 changed: false,
2897 };
2898 match res {
2899 Ok((v, _)) => {
2900 record.vote = v.label();
2901 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2902 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2903 self.state.event(
2904 "vote",
2905 format!(
2906 "judge {} voted {}{}",
2907 j + 1,
2908 record.vote.unwrap_or('?'),
2909 if record.changed { " (changed)" } else { "" }
2910 ),
2911 );
2912 }
2913 Err(e) => {
2914 self.state
2915 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
2916 }
2917 }
2918 self.state.votes.push(record);
2919 self.state.save()?;
2920 }
2921 Ok(())
2922 }
2923
2924 fn tally(&mut self) -> Result<()> {
2927 if self.state.tally.is_some() {
2928 return Ok(());
2929 }
2930 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2931 let tops: Vec<char> = self
2932 .state
2933 .judgements
2934 .iter()
2935 .filter_map(|j| j.ranking.first().copied())
2936 .collect();
2937 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
2938
2939 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2942 let mut cast: Vec<char> = Vec::new();
2943 for (i, j) in self.state.judgements.iter().enumerate() {
2944 let vote = self
2945 .state
2946 .votes
2947 .iter()
2948 .find(|v| v.judge == i + 1)
2949 .and_then(|v| v.vote)
2950 .or_else(|| j.ranking.first().copied());
2951 if let Some(v) = vote {
2952 *first_choice.entry(v).or_insert(0) += 1;
2953 cast.push(v);
2954 }
2955 }
2956
2957 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2958 for j in &self.state.judgements {
2959 let n = j.ranking.len();
2960 for (pos, label) in j.ranking.iter().enumerate() {
2961 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
2962 }
2963 }
2964
2965 let best = first_choice.values().copied().max().unwrap_or(0);
2966 let mut leaders: Vec<char> = first_choice
2967 .iter()
2968 .filter(|(_, v)| **v == best)
2969 .map(|(k, _)| *k)
2970 .collect();
2971 let mut tie_break = None;
2972 if leaders.len() > 1 {
2973 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
2974 let borda_leaders: Vec<char> = leaders
2975 .iter()
2976 .copied()
2977 .filter(|l| borda[l] == top_borda)
2978 .collect();
2979 tie_break = Some(if borda_leaders.len() == 1 {
2980 format!(
2981 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
2982 leaders.len()
2983 )
2984 } else {
2985 format!(
2986 "{} way tie on both first-choice votes and Borda points, broken by label order",
2987 leaders.len()
2988 )
2989 });
2990 leaders = borda_leaders;
2991 leaders.sort_unstable();
2992 }
2993 let winner = *leaders
2994 .first()
2995 .or(viable.first())
2996 .context("no candidate to declare a winner from")?;
2997
2998 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
2999 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3000 let deliberated = !self.state.deliberation.is_empty();
3001
3002 let quota_seats: std::collections::BTreeSet<&str> =
3006 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3007 let mut present = 0usize;
3008 for (i, j) in self.state.judgements.iter().enumerate() {
3009 if quota_seats.contains(j.seat.as_str()) {
3010 continue;
3011 }
3012 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3013 let voted = self
3014 .state
3015 .votes
3016 .iter()
3017 .any(|v| v.judge == i + 1 && v.vote.is_some());
3018 if ranked || voted {
3019 present += 1;
3020 }
3021 }
3022 let needs_quorum = viable.len() > 1;
3028 let judges_total = if needs_quorum {
3029 self.roles.judges.len()
3030 } else {
3031 0
3032 };
3033 let quorum = if needs_quorum {
3034 judges_total / 2 + 1
3035 } else {
3036 0
3037 };
3038 let met_quorum = !needs_quorum || present >= quorum;
3039 let uncontested = (!needs_quorum).then(|| {
3040 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3041 });
3042
3043 self.state.event(
3044 "tally",
3045 match &uncontested {
3046 Some(reason) => format!("winner {winner} — {reason}"),
3047 None => format!(
3048 "winner {winner} — votes {} | initial {} | {} changed | \
3049 {present}/{judges_total} judges{}",
3050 first_choice
3051 .iter()
3052 .map(|(k, v)| format!("{k}:{v}"))
3053 .collect::<Vec<_>>()
3054 .join(" "),
3055 if unanimous_initial {
3056 "unanimous"
3057 } else {
3058 "split"
3059 },
3060 changed_votes,
3061 if met_quorum {
3062 String::new()
3063 } else {
3064 format!(" — below quorum ({quorum} required)")
3065 },
3066 ),
3067 },
3068 );
3069 if !met_quorum {
3070 self.state.event(
3071 "stall",
3072 format!(
3073 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3074 the run stops here, resumable"
3075 ),
3076 );
3077 }
3078 self.state.tally = Some(Tally {
3079 first_choice,
3080 borda,
3081 winner,
3082 rankings: tops.len(),
3083 unanimous_initial,
3084 deliberated,
3085 changed_votes,
3086 unanimous_final,
3087 tie_break,
3088 judges: judges_total,
3089 present,
3090 quorum,
3091 met_quorum,
3092 uncontested,
3093 });
3094 self.state.status = if met_quorum {
3095 RunStatus::Reviewing
3096 } else {
3097 RunStatus::Stalled
3098 };
3099 self.state.save()?;
3100 Ok(())
3101 }
3102
3103 #[allow(clippy::too_many_lines)]
3124 async fn recover_stall(&mut self) -> Result<bool> {
3125 let run_id = self.state.id.clone();
3130 let prompts = self.state.config.prompts.clone();
3131 let quota_seats: BTreeSet<&str> =
3136 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3137 let absent: Vec<String> = self
3138 .state
3139 .judgements
3140 .iter()
3141 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3142 .map(|j| j.seat.clone())
3143 .collect();
3144 if absent.is_empty() {
3145 return Ok(false);
3146 }
3147 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3148 if viable.len() <= 1 {
3149 return Ok(false);
3150 }
3151 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3152 let language = self.state.config.graph.language.clone();
3153 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3154 let sessions = self.state.config.graph.sessions;
3155 let artifacts = agent::artifacts_dir(&self.state.dir());
3156 let root = self.state.worktree_root();
3157 let base_short = short(&self.state.base_commit);
3158 let candidates: Vec<Candidate> = viable.clone();
3159
3160 let mut positions: Vec<usize> = absent
3162 .iter()
3163 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3164 .collect();
3165 if positions.is_empty() {
3166 return Ok(false);
3167 }
3168 positions.sort_unstable();
3169 positions.dedup();
3170
3171 let mut judge_jobs = Vec::new();
3173 for &j in &positions {
3174 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3175 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3176 let seat_key = format!("judge-{}", j + 1);
3177 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3178 let seat = self.seat(&seat_key, &spec.id);
3179 judge_jobs.push(SeatJob {
3180 spec,
3181 seat,
3182 prompt: prompt::judge(
3183 &self.state.instruction,
3184 &views,
3185 self.roles.judges.len(),
3186 &base_short,
3187 &language,
3188 ),
3189 cwd: root.join(seat_key),
3190 timeout,
3191 allow_write: false,
3192 sessions,
3193 artifacts: artifacts.clone(),
3194 stem: format!("judge-{}-recover", j + 1),
3195 handover: None,
3196 });
3197 }
3198
3199 let labels_for_check = labels.clone();
3200 let mut judge_losses = Vec::new();
3201 let retries = self.state.config.graph.retries;
3202 let cache = self.state.config.cache_dir();
3203 let ctx = WaveCtx {
3204 carry_seats: false,
3205 run: &run_id,
3206 node: "judge",
3207 prompts: &prompts,
3208 cache: cache.as_deref(),
3209 round: None,
3210 };
3211 let results = ask_json_wave::<Ranking>(
3212 judge_jobs,
3213 Arc::clone(&self.sem),
3214 retries,
3215 &self.roles.judge_roster,
3216 &ctx,
3217 &mut judge_losses,
3218 &mut self.state,
3219 &move |r: &Ranking| r.validate(&labels_for_check),
3220 )
3221 .await;
3222
3223 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3225 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3226 let agent_id = seat.agent.clone();
3227 self.state.seats.insert(seat.key.clone(), seat);
3228 let record = &mut self.state.judgements[j];
3229 match res {
3230 Ok((ranking, out)) => {
3231 record.agent = agent_id;
3232 record.ranking = ranking.normalized();
3233 record.reasons = ranking.reasons;
3234 record.confidence = ranking.confidence;
3235 record.failed = None;
3236 record.duration_ms = out.duration_ms;
3237 recovered.insert(j);
3238 self.state.event(
3239 "recover",
3240 format!("judge {} ranked again after the limit", j + 1),
3241 );
3242 }
3243 Err(e) => {
3244 self.state
3245 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3246 }
3247 }
3248 }
3249
3250 let mut vote_jobs = Vec::new();
3252 let mut vote_pos: Vec<usize> = Vec::new();
3253 for &j in &recovered {
3254 let seat_key = format!("judge-{}", j + 1);
3255 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3256 let seat = self.seat(&seat_key, &spec.id);
3257 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3258 let text = if has_context(&spec, &seat, sessions) {
3259 prompt::final_vote(&labels, &language)
3260 } else {
3261 full.clone()
3262 };
3263 vote_jobs.push(SeatJob {
3264 spec,
3265 seat,
3266 prompt: text,
3267 cwd: root.join(seat_key),
3268 timeout,
3269 allow_write: false,
3270 sessions,
3271 artifacts: artifacts.clone(),
3272 stem: format!("vote-judge-{}-recover", j + 1),
3273 handover: Some(full),
3274 });
3275 vote_pos.push(j);
3276 }
3277 let allowed = labels.clone();
3278 let mut vote_losses = Vec::new();
3279 let vote_retries = self.state.config.graph.retries;
3280 let vote_cache = self.state.config.cache_dir();
3281 let ctx = WaveCtx {
3282 carry_seats: false,
3283 run: &run_id,
3284 node: "vote",
3285 prompts: &prompts,
3286 cache: vote_cache.as_deref(),
3287 round: None,
3288 };
3289 let votes = ask_json_wave::<FinalVote>(
3290 vote_jobs,
3291 Arc::clone(&self.sem),
3292 vote_retries,
3293 &self.roles.judge_roster,
3294 &ctx,
3295 &mut vote_losses,
3296 &mut self.state,
3297 &move |v: &FinalVote| match v.label() {
3298 Some(c) if allowed.contains(&c) => Ok(()),
3299 other => bail!("vote {other:?} is not one of {allowed:?}"),
3300 },
3301 )
3302 .await;
3303 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3304 let agent_id = seat.agent.clone();
3305 self.state.seats.insert(seat.key.clone(), seat);
3306 match res {
3307 Ok((v, _)) => {
3308 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3309 rec.vote = v.label();
3310 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3311 } else {
3312 self.state.votes.push(VoteRecord {
3313 judge: j + 1,
3314 agent: agent_id,
3315 vote: v.label(),
3316 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3317 changed: false,
3318 });
3319 }
3320 self.state.event(
3321 "recover",
3322 format!("judge {} voted again after the limit", j + 1),
3323 );
3324 }
3325 Err(e) => {
3326 self.state
3327 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3328 }
3329 }
3330 }
3331
3332 let recovered_keys: BTreeSet<String> = recovered
3336 .iter()
3337 .map(|&j| format!("judge-{}", j + 1))
3338 .collect();
3339 self.state
3340 .quota
3341 .retain(|q| !recovered_keys.contains(&q.seat));
3342 for loss in judge_losses.into_iter().chain(vote_losses) {
3346 if recovered_keys.contains(&loss.seat) {
3347 continue;
3348 }
3349 self.state.quota.retain(|q| q.seat != loss.seat);
3350 self.state.quota.push(loss);
3351 }
3352
3353 self.state.tally = None;
3355 self.tally()?;
3356 Ok(self
3357 .state
3358 .tally
3359 .as_ref()
3360 .map(|t| t.met_quorum)
3361 .unwrap_or(false))
3362 }
3363
3364 async fn fold_losers(&mut self) -> Result<()> {
3367 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3368 return Ok(());
3369 };
3370 let repo = self.state.repo.clone();
3371 let mut folded = Vec::new();
3372 for i in 0..self.state.candidates.len() {
3373 let c = &self.state.candidates[i];
3374 if c.label == winner || c.folded {
3375 continue;
3376 }
3377 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3378 git::worktree_remove(&repo, &wt).await.ok();
3379 git::branch_delete(&repo, &branch).await.ok();
3380 self.state.candidates[i].folded = true;
3381 folded.push(label.to_string());
3382 }
3383 let root = self.state.worktree_root();
3385 for j in 1..=self.roles.judges.len() {
3386 let wt = root.join(format!("judge-{j}"));
3387 if wt.exists() {
3388 git::worktree_remove(&repo, &wt).await.ok();
3389 }
3390 }
3391 if self.state.config.graph.advise {
3394 for k in 1..=self.state.config.graph.advisors {
3395 let wt = root.join(format!("advisor-{k}"));
3396 if wt.exists() {
3397 git::worktree_remove(&repo, &wt).await.ok();
3398 }
3399 }
3400 }
3401 if !folded.is_empty() {
3402 self.state
3403 .event("fold", format!("folded candidates {}", folded.join(", ")));
3404 self.state.save()?;
3405 }
3406 Ok(())
3407 }
3408
3409 async fn sync_to_base(&mut self) -> Result<()> {
3449 if self.state.status == RunStatus::AlreadyInBase {
3450 return Ok(());
3451 }
3452 let conflicted = self
3453 .state
3454 .base_sync
3455 .as_ref()
3456 .is_some_and(|s| s.conflict.is_some());
3457 let Some(winner) = self.state.winner().cloned() else {
3458 return Ok(());
3459 };
3460
3461 let repo = self.state.repo.clone();
3462 let remote = self.state.config.merge.remote.clone();
3463 let base_branch = self.state.base_branch.clone();
3464 let tracking = format!("{remote}/{base_branch}");
3465
3466 git::fetch(&repo, &remote, &base_branch).await.ok();
3467 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3472 return Ok(());
3473 };
3474
3475 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3476 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3477 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3478
3479 if (behind > 0 || conflicted || head == tip)
3488 && self
3489 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3490 .await?
3491 {
3492 return Ok(());
3493 }
3494 if conflicted {
3495 return Ok(());
3496 }
3497
3498 if behind == 0 {
3499 if let Some(from) = self
3508 .state
3509 .rebase_fixes
3510 .iter()
3511 .rev()
3512 .find_map(|r| r.from.clone())
3513 && from != head
3514 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3515 .await
3516 .is_ok_and(|o| o.ok())
3517 {
3518 git::sync_to_head(&winner.worktree).await?;
3519 }
3520 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3526 if let Some(why) = &conflict {
3527 self.state.status = RunStatus::Blocked;
3528 self.state.event("land", why.clone());
3529 }
3530 self.state.base_sync = Some(BaseSync {
3531 tip,
3532 behind: 0,
3533 attempts,
3534 conflict,
3535 already_in: None,
3536 });
3537 self.state.save()?;
3538 return Ok(());
3539 }
3540
3541 if attempts >= BASE_SYNC_ROUNDS {
3542 let why = format!(
3543 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3544 rebase(s); rebasing again would only race it",
3545 winner.branch
3546 );
3547 self.state.status = RunStatus::Blocked;
3548 self.state.base_sync = Some(BaseSync {
3549 tip,
3550 behind,
3551 attempts,
3552 conflict: Some(why.clone()),
3553 already_in: None,
3554 });
3555 self.state.event("land", why);
3556 self.state.save()?;
3557 return Ok(());
3558 }
3559
3560 self.state.event(
3561 "land",
3562 format!(
3563 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3564 winner.branch
3565 ),
3566 );
3567 self.state.save()?;
3568
3569 let branch_tracking = format!("{remote}/{}", winner.branch);
3575 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3576 let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3577 git::rev_parse(&repo, &branch_tracking).await.ok()
3578 } else {
3579 None
3580 };
3581 if let Some(theirs) = &remote_tip
3585 && !git::is_ancestor(&repo, theirs, &head).await
3586 && !crate::reconcile::origin_missing(&repo, &head, theirs)
3587 .await
3588 .is_ok_and(|missing| missing.is_empty())
3589 {
3590 let why = format!(
3591 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3592 them",
3593 short(theirs),
3594 winner.branch
3595 );
3596 self.state.status = RunStatus::Blocked;
3597 self.state.base_sync = Some(BaseSync {
3598 tip,
3599 behind,
3600 attempts,
3601 conflict: Some(why.clone()),
3602 already_in: None,
3603 });
3604 self.state.event("land", why);
3605 self.state.save()?;
3606 return Ok(());
3607 }
3608
3609 let scratch = self.state.dir().join("base-sync");
3610 let rebased = match crate::rebase::rebase_with_fixer(
3611 &mut self.state,
3612 &scratch,
3613 &winner.branch,
3614 &tracking,
3615 )
3616 .await
3617 {
3618 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3619 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3620 Err(e) => Err(e),
3621 };
3622 let attempts = attempts + 1;
3623 match rebased {
3624 Ok(None) => {
3625 git::sync_to_head(&winner.worktree).await?;
3629 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3630 let mut conflict = None;
3631 if let Some(pinned) = &remote_tip {
3632 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3633 match pushed {
3634 Ok(o) if o.ok() => self.state.event(
3635 "land",
3636 format!("pushed rebased {} to {remote}", winner.branch),
3637 ),
3638 Ok(o) => {
3639 conflict = Some(format!(
3640 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
3641 winner.branch,
3642 short(pinned),
3643 o.stderr.chars().take(600).collect::<String>()
3644 ));
3645 }
3646 Err(e) => {
3647 conflict = Some(format!(
3648 "rebased {} locally but could not push it: {e:#}",
3649 winner.branch
3650 ));
3651 }
3652 }
3653 }
3654 if let Some(why) = &conflict {
3655 self.state.status = RunStatus::Blocked;
3656 self.state.event("land", why.clone());
3657 }
3658 self.state.base_sync = Some(BaseSync {
3659 tip: tip.clone(),
3660 behind: 0,
3661 attempts,
3662 conflict,
3663 already_in: None,
3664 });
3665 self.state
3666 .event("land", format!("rebased {} onto {tracking}", winner.branch));
3667 }
3668 Ok(Some(conflict)) => {
3669 let why = format!(
3670 "{} conflicts with {tracking} and did not rebase: {}",
3671 winner.branch,
3672 conflict.chars().take(600).collect::<String>()
3673 );
3674 self.state.status = RunStatus::Blocked;
3675 self.state.base_sync = Some(BaseSync {
3676 tip,
3677 behind,
3678 attempts,
3679 conflict: Some(why.clone()),
3680 already_in: None,
3681 });
3682 self.state.event("land", why);
3683 }
3684 Err(e) => {
3685 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3686 self.state.status = RunStatus::Blocked;
3687 self.state.base_sync = Some(BaseSync {
3688 tip,
3689 behind,
3690 attempts,
3691 conflict: Some(why.clone()),
3692 already_in: None,
3693 });
3694 self.state.event("land", why);
3695 }
3696 }
3697 self.state.save()?;
3698 Ok(())
3699 }
3700
3701 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3707 let repo = self.state.repo.clone();
3708 let remote = self.state.config.merge.remote.clone();
3709 let fetched = git::fetch(&repo, &remote, branch).await;
3710 if !matches!(&fetched, Ok(o) if o.ok()) {
3711 return None;
3712 }
3713 let branch_tracking = format!("{remote}/{branch}");
3714 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3715 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3716 return None;
3717 }
3718 if !crate::reconcile::origin_missing(&repo, head, &theirs)
3719 .await
3720 .is_ok_and(|missing| missing.is_empty())
3721 {
3722 return Some(format!(
3723 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3724 them",
3725 short(&theirs)
3726 ));
3727 }
3728 match git::push_pinned(&repo, &remote, branch, &theirs).await {
3729 Ok(o) if o.ok() => {
3730 self.state
3731 .event("land", format!("pushed rebased {branch} to {remote}"));
3732 None
3733 }
3734 Ok(o) => Some(format!(
3735 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3736 someone may have pushed): {}",
3737 short(&theirs),
3738 o.stderr.chars().take(600).collect::<String>()
3739 )),
3740 Err(e) => Some(format!(
3741 "{branch} is rebased locally but could not be pushed: {e:#}"
3742 )),
3743 }
3744 }
3745
3746 async fn settle_already_in(
3763 &mut self,
3764 branch: &str,
3765 tip: &str,
3766 head: &str,
3767 attempts: usize,
3768 behind: usize,
3769 ) -> Result<bool> {
3770 let repo = self.state.repo.clone();
3771 let remote = self.state.config.merge.remote.clone();
3772 let start = self.state.base_commit.clone();
3773 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3774 Ok(Some(e)) => e,
3775 Ok(None) => return Ok(false),
3776 Err(e) => {
3777 tracing::warn!("already-in-base check for {branch}: {e:#}");
3778 return Ok(false);
3779 }
3780 };
3781 let mut verified = vec![head.to_owned()];
3782 let fetched = git::fetch(&repo, &remote, branch).await;
3783 if matches!(&fetched, Ok(o) if o.ok())
3784 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3785 && theirs != head
3786 {
3787 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3788 Ok(Some(_)) => verified.push(theirs),
3789 _ => return Ok(false),
3790 }
3791 }
3792 let base_branch = self.state.base_branch.clone();
3793 let message = format!(
3794 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3795 land",
3796 evidence.names(),
3797 evidence.proof.as_str()
3798 );
3799 let closed =
3800 crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3801 match closed {
3802 Ok(Ok(url)) => self
3803 .state
3804 .event("land", format!("closed {url}: superseded on {base_branch}")),
3805 Ok(Err(why)) => self
3806 .state
3807 .event("land", format!("did not close a pull request: {why}")),
3808 Err(e) => {
3809 let why = format!(
3810 "{branch} is already in {remote}/{base_branch}, but its pull request could \
3811 not be closed ({e:#}); resume to retry"
3812 );
3813 self.state.status = RunStatus::Blocked;
3814 self.state.base_sync = Some(BaseSync {
3815 tip: tip.to_owned(),
3816 behind,
3817 attempts,
3818 conflict: Some(why.clone()),
3819 already_in: None,
3820 });
3821 self.state.event("land", why);
3822 self.state.save()?;
3823 return Ok(true);
3824 }
3825 }
3826 self.state.status = RunStatus::AlreadyInBase;
3827 self.state.base_sync = Some(BaseSync {
3828 tip: tip.to_owned(),
3829 behind,
3830 attempts,
3831 conflict: None,
3832 already_in: Some(evidence),
3833 });
3834 self.state.event("land", message);
3835 self.state.save()?;
3836 self.settle_questions();
3837 Ok(true)
3838 }
3839
3840 fn landing_base(&self) -> String {
3850 self.state
3851 .base_sync
3852 .as_ref()
3853 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3854 }
3855
3856 pub async fn fix_selected(
3889 &mut self,
3890 ids: &[String],
3891 reason: &str,
3892 allow_stale: bool,
3893 ) -> Result<()> {
3894 let reason = reason.trim();
3895 if reason.is_empty() {
3896 bail!("a fix request needs a reason — that is the operator's own record of why");
3897 }
3898 if ids.is_empty() {
3899 bail!("no finding id given");
3900 }
3901 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3902 bail!(
3903 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3904 has already concluded — can be given a targeted fix. A run still \
3905 in progress should simply be resumed; a `merged` run's branch has \
3906 already landed, so its answer is a fresh `magi review <branch>`, \
3907 not reopening this run's own record",
3908 self.state.id,
3909 self.state.status.as_str()
3910 );
3911 }
3912 let Some(winner) = self.state.winner().cloned() else {
3913 bail!("run {} has no winning candidate to fix", self.state.id);
3914 };
3915 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
3916 bail!(
3917 "branch `{}` no longer exists; this run cannot be extended",
3918 winner.branch
3919 );
3920 }
3921 let home = crate::run::home();
3922 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
3923 bail!(
3924 "run {} is currently being worked on by another magi process",
3925 self.state.id
3926 );
3927 }
3928 let _claim = FixClaim::acquire(&self.state.dir())?;
3934
3935 let mut seen = BTreeSet::new();
3939 let mut findings = Vec::new();
3940 let mut missing = Vec::new();
3941 for id in ids {
3942 if !seen.insert(id.clone()) {
3943 continue;
3944 }
3945 match self.state.finding(id) {
3946 Some((round, rec, f)) => findings.push(OperatorFixFinding {
3947 id: f.id.clone(),
3948 severity: f.severity,
3949 reviewer_vote: rec.vote,
3950 round: round.round,
3951 round_head: round.head.clone(),
3952 reviewer: rec.reviewer,
3953 agent: rec.agent.clone(),
3954 file: f.file.clone(),
3955 line: f.line,
3956 title: f.title.clone(),
3957 detail: f.detail.clone(),
3958 outcome: OperatorFixOutcome::Pending,
3959 }),
3960 None => missing.push(id.clone()),
3961 }
3962 }
3963 if !missing.is_empty() {
3964 bail!(
3965 "unknown finding id(s): {}; nothing was changed",
3966 missing.join(", ")
3967 );
3968 }
3969
3970 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
3971 let stale_details: Vec<(String, String)> = findings
3972 .iter()
3973 .filter(|f| f.round_head != head_at_request)
3974 .map(|f| (f.id.clone(), f.round_head.clone()))
3975 .collect();
3976 let stale = !stale_details.is_empty();
3977 if stale && !allow_stale {
3978 bail!(
3979 "the branch has moved since some finding(s) were raised — {} — now \
3980 at {}; pass --allow-stale to fix anyway, or re-run review first",
3981 stale_details
3982 .iter()
3983 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
3984 .collect::<Vec<_>>()
3985 .join(", "),
3986 short(&head_at_request)
3987 );
3988 }
3989
3990 let request = OperatorFixRequest {
3991 requested_at: Timestamp::now(),
3992 reason: reason.to_owned(),
3993 findings,
3994 head_at_request: head_at_request.clone(),
3995 allow_stale,
3996 stale,
3997 fix: None,
3998 result_head: None,
3999 follow_up_review_run: None,
4000 };
4001 self.state.event(
4002 "fix",
4003 format!(
4004 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4005 request.findings.len(),
4006 request
4007 .findings
4008 .iter()
4009 .map(|f| f.id.as_str())
4010 .collect::<Vec<_>>()
4011 .join(", "),
4012 ),
4013 );
4014 self.state.operator_fixes.push(request);
4021 self.state.save()?;
4022 let request_index = self.state.operator_fixes.len() - 1;
4023
4024 if winner.worktree.exists() {
4033 let dirty = git::git(
4036 &winner.worktree,
4037 &["status", "--porcelain", "--untracked-files=all"],
4038 )
4039 .await?;
4040 let only_withheld = dirty.lines().all(|l| {
4041 l.strip_prefix("?? ")
4042 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4043 });
4044 if !only_withheld {
4045 bail!(
4046 "`{}` has uncommitted changes; refusing to touch it — commit or \
4047 discard them first",
4048 winner.worktree.display()
4049 );
4050 }
4051 git::worktree_remove(&self.state.repo, &winner.worktree)
4052 .await
4053 .ok();
4054 }
4055 let fix_worktree = self.state.worktree_root().join("operator-fix");
4056 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4057 git::git(
4058 &self.state.repo,
4059 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4060 )
4061 .await
4062 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4063 if !git::is_clean(&fix_worktree).await? {
4064 git::worktree_remove(&self.state.repo, &fix_worktree)
4065 .await
4066 .ok();
4067 bail!(
4068 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4069 winner.branch
4070 );
4071 }
4072
4073 let run_id = self.state.id.clone();
4074 let prompts = self.state.config.prompts.clone();
4075 let language = self.state.config.graph.language.clone();
4076 let sessions = self.state.config.graph.sessions;
4077 let artifacts = agent::artifacts_dir(&self.state.dir());
4078 let (fix_spec, fix_seat_key) = match &self.roles.fixer {
4079 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4080 _ => (
4081 self.state
4082 .config
4083 .agent(&winner.agent)
4084 .cloned()
4085 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4086 format!("impl-{}", winner.label),
4087 ),
4088 };
4089 let seat = self.seat(&fix_seat_key, &fix_spec.id);
4090 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4091 .findings
4092 .iter()
4093 .map(|f| Finding {
4094 id: f.id.clone(),
4095 severity: f.severity,
4096 file: f.file.clone(),
4097 line: f.line,
4098 title: f.title.clone(),
4099 detail: f.detail.clone(),
4100 })
4101 .collect();
4102 let job = SeatJob {
4103 prompt: prompt::operator_fix(
4104 &self.state.instruction,
4105 &finding_list,
4106 reason,
4107 &stale_details,
4108 &head_at_request,
4109 &language,
4110 ),
4111 spec: fix_spec.clone(),
4112 seat,
4113 cwd: fix_worktree.clone(),
4114 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
4115 allow_write: true,
4116 sessions,
4117 artifacts: artifacts.clone(),
4118 stem: "operator-fix".to_owned(),
4119 handover: None,
4120 };
4121 let cache = self.state.config.cache_dir();
4122 let ctx = WaveCtx {
4123 carry_seats: false,
4124 run: &run_id,
4125 node: "fix",
4126 prompts: &prompts,
4127 cache: cache.as_deref(),
4128 round: None,
4129 };
4130 let (seat, out) =
4131 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4132 let agent_id = seat.agent.clone();
4133
4134 let mut fix = FixRecord {
4135 agent: agent_id,
4136 addressed: Vec::new(),
4137 rejected: Vec::new(),
4138 notes: String::new(),
4139 committed: false,
4140 failed: None,
4141 duration_ms: 0,
4142 continuation: None,
4143 };
4144 let mut final_seat = seat.clone();
4145 match out {
4146 AgentOutcome::Ok(o) => {
4147 fix.duration_ms = o.duration_ms;
4148 let parsed = verdict::extract_json::<FixReport>(&o.text);
4149 let incomplete_reason = match &parsed {
4150 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4151 "the reply parsed, but it reported a command whose own CLI \
4152 never confirmed an exit status"
4153 .to_owned(),
4154 ),
4155 Ok(_) => None,
4156 Err(e) => Some(e.to_string()),
4157 };
4158 match incomplete_reason {
4159 None => {
4160 let report = parsed.expect("checked Ok above");
4161 fix.addressed = report.addressed;
4162 fix.rejected = report.rejected;
4163 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4164 }
4165 Some(reason) => {
4166 let (resumed_seat, resolved, failure, cont) = self
4167 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4168 .await;
4169 fix.duration_ms += cont.cumulative_wait_ms;
4170 fix.continuation = Some(cont);
4171 final_seat = resumed_seat;
4172 match resolved {
4173 Some(report) => {
4174 fix.addressed = report.addressed;
4175 fix.rejected = report.rejected;
4176 fix.notes =
4177 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4178 }
4179 None => fix.failed = failure,
4180 }
4181 }
4182 }
4183 }
4184 AgentOutcome::Dropped(o) => {
4185 fix.duration_ms = o.duration_ms;
4186 let why = o
4187 .dropped
4188 .as_ref()
4189 .map(|d| d.why.as_str())
4190 .unwrap_or("the CLI ended the stream without delivering its answer");
4191 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4192 }
4193 AgentOutcome::Quota(o) => {
4194 self.state.quota.push(QuotaLoss {
4195 seat: final_seat.key.clone(),
4196 node: "fix".to_owned(),
4197 at: Timestamp::now(),
4198 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4199 });
4200 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4201 }
4202 AgentOutcome::Failed(e) => fix.failed = Some(e),
4203 }
4204 if fix.continuation.is_none() {
4205 fix.continuation = Some(ContinuationRecord::not_needed());
4206 }
4207 self.state.seats.insert(final_seat.key.clone(), final_seat);
4208
4209 let rescue_message = format!(
4210 "magi: operator-selected fix ({}) (uncommitted work)",
4211 self.state.operator_fixes[request_index]
4212 .findings
4213 .iter()
4214 .map(|f| f.id.as_str())
4215 .collect::<Vec<_>>()
4216 .join(", ")
4217 );
4218 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4219 self.state.note_withheld("fix", &r.withheld);
4220 }
4221 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4222 fix.committed = after != head_at_request;
4223 git::worktree_remove(&self.state.repo, &fix_worktree)
4224 .await
4225 .ok();
4226
4227 self.state.event(
4228 "fix",
4229 match &fix.failed {
4230 Some(reason) => format!(
4231 "operator fix: adoption report was lost ({reason}); {}",
4232 if fix.committed {
4233 "committed"
4234 } else {
4235 "NO new commit"
4236 }
4237 ),
4238 None => format!(
4239 "operator fix: {} addressed, {} rejected, {}",
4240 fix.addressed.len(),
4241 fix.rejected.len(),
4242 if fix.committed {
4243 "committed"
4244 } else {
4245 "NO new commit"
4246 }
4247 ),
4248 },
4249 );
4250
4251 for f in &mut self.state.operator_fixes[request_index].findings {
4258 f.outcome = if fix.failed.is_some() {
4259 OperatorFixOutcome::Unreported
4260 } else if fix.addressed.contains(&f.id) {
4261 OperatorFixOutcome::Addressed
4262 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4263 OperatorFixOutcome::Rejected { why: r.why.clone() }
4264 } else {
4265 OperatorFixOutcome::Unreported
4266 };
4267 }
4268
4269 let committed = fix.committed;
4270 if committed {
4271 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4272 }
4273 self.state.operator_fixes[request_index].fix = Some(fix);
4274 self.state.save()?;
4277
4278 if committed {
4279 self.state.event(
4280 "fix",
4281 format!(
4282 "operator fix committed {}; opening a follow-up review-only run",
4283 short(&after)
4284 ),
4285 );
4286 let origin =
4289 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4290 match Self::review(
4291 &self.state.repo,
4292 &winner.branch,
4293 self.state.config.clone(),
4294 origin,
4295 )
4296 .await
4297 {
4298 Ok(mut follow_up) => {
4299 follow_up.state.event(
4300 "start",
4301 format!(
4302 "requested by an operator fix on run {} for finding(s) {}",
4303 self.state.id,
4304 self.state.operator_fixes[request_index]
4305 .findings
4306 .iter()
4307 .map(|f| f.id.as_str())
4308 .collect::<Vec<_>>()
4309 .join(", "),
4310 ),
4311 );
4312 follow_up.state.save()?;
4313 let follow_up_id = follow_up.state.id.clone();
4314 let adopted = match crate::direct::adopt(
4317 &follow_up.state,
4318 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4319 ) {
4320 Ok(a) => a,
4321 Err(e) => {
4322 self.state.event(
4325 "fix",
4326 format!(
4327 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4328 ),
4329 );
4330 self.state.save()?;
4331 return Ok(());
4332 }
4333 };
4334 let executed = follow_up.execute().await;
4335 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4336 if let Some(a) = adopted {
4337 a.finish(&follow_up.state, executed);
4338 }
4339 if let Some(e) = failure {
4340 self.state.event(
4341 "fix",
4342 format!(
4343 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4344 ),
4345 );
4346 }
4347 self.state.operator_fixes[request_index].follow_up_review_run =
4348 Some(follow_up_id);
4349 }
4350 Err(e) => {
4351 self.state.event(
4352 "fix",
4353 format!("committed the fix but could not open a follow-up review: {e:#}"),
4354 );
4355 }
4356 }
4357 self.state.save()?;
4358 }
4359
4360 Ok(())
4361 }
4362
4363 fn fixer_spec(&self, winner: &Candidate) -> (AgentSpec, String) {
4370 match &self.roles.fixer {
4371 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4372 _ => (
4373 self.state
4374 .config
4375 .agent(&winner.agent)
4376 .cloned()
4377 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4378 format!("impl-{}", winner.label),
4379 ),
4380 }
4381 }
4382
4383 async fn review_loop(&mut self) -> Result<()> {
4384 if self
4389 .state
4390 .base_sync
4391 .as_ref()
4392 .is_some_and(|s| s.conflict.is_some())
4393 {
4394 return Ok(());
4395 }
4396 let run_id = self.state.id.clone();
4401 let prompts = self.state.config.prompts.clone();
4402 let Some(winner) = self.state.winner().cloned() else {
4403 return Ok(());
4404 };
4405 let max_rounds = self.state.config.graph.review_rounds;
4406 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4416 if status == RunStatus::Gating {
4419 self.record_contested_handoff();
4420 }
4421 self.state.status = status;
4422 self.state.save()?;
4423 return Ok(());
4424 }
4425 self.state.status = RunStatus::Reviewing;
4426 if self
4436 .state
4437 .reviews
4438 .last()
4439 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4440 {
4441 let shell = self.state.config.shell();
4442 return self
4443 .stop_reviewing(
4444 "the last round's own verification never resolved",
4445 &shell,
4446 &winner.worktree,
4447 )
4448 .await;
4449 }
4450
4451 let repo = self.state.repo.clone();
4452 let root = self.state.worktree_root();
4453 let language = self.state.config.graph.language.clone();
4454 let sessions = self.state.config.graph.sessions;
4455 let artifacts = agent::artifacts_dir(&self.state.dir());
4456 let base = self.landing_base();
4457 let base_short = short(&base);
4458 let reviewers = self.roles.reviewers.clone();
4459 let shell = self.state.config.shell();
4460
4461 for round in (self.state.reviews.len() + 1)..=max_rounds {
4462 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4463 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4464 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4465 let prev_verification = self
4474 .state
4475 .reviews
4476 .last()
4477 .and_then(|r| r.verification_summary(&head));
4478
4479 let mut jobs = Vec::new();
4483 for (r, spec) in reviewers.iter().cloned().enumerate() {
4484 let wt = root.join(format!("review-{}", r + 1));
4485 if wt.exists() {
4486 git::reset_detached(&wt, &head).await?;
4487 } else {
4488 git::worktree_add_detached(&repo, &wt, &head).await?;
4489 }
4490 let seat_key = format!("review-{}", r + 1);
4491 let spec = pick_start_spec(
4494 &self.roles.reviewer_roster,
4495 spec,
4496 self.state.seat_history.get(&seat_key),
4497 );
4498 let seat = self.seat(&seat_key, &spec.id);
4499 jobs.push(SeatJob {
4500 prompt: prompt::review(&prompt::ReviewCtx {
4501 instruction: &self.state.instruction,
4502 branch: &winner.branch,
4503 base_short: &base_short,
4504 stat: &stat,
4505 patch: &patch,
4506 verification: prev_verification.as_ref(),
4507 reviewers: reviewers.len(),
4508 round,
4509 rounds: max_rounds,
4510 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4513 lens: Lens::for_seat(r),
4514 language: &language,
4515 }),
4516 spec,
4517 seat,
4518 cwd: wt,
4519 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4520 allow_write: false,
4521 sessions,
4522 artifacts: artifacts.clone(),
4523 stem: format!("review-{round}-{}", r + 1),
4524 handover: None,
4525 });
4526 }
4527
4528 self.state.event(
4529 "review",
4530 format!(
4531 "round {round}: {} reviewers on {}",
4532 jobs.len(),
4533 short(&head)
4534 ),
4535 );
4536 let mut quota_losses = Vec::new();
4537 let review_retries = self.state.config.graph.retries;
4538 let review_cache = self.state.config.cache_dir();
4539 let ctx = WaveCtx {
4540 carry_seats: true,
4541 run: &run_id,
4542 node: "review",
4543 prompts: &prompts,
4544 cache: review_cache.as_deref(),
4545 round: Some(round),
4546 };
4547 let results = ask_json_wave::<Review>(
4548 jobs,
4549 Arc::clone(&self.sem),
4550 review_retries,
4551 &self.roles.reviewer_roster,
4552 &ctx,
4553 &mut quota_losses,
4554 &mut self.state,
4555 &|_: &Review| Ok(()),
4556 )
4557 .await;
4558 let round_quota_missing = quota_losses.len();
4562 self.state.quota.extend(quota_losses);
4563
4564 let mut records = Vec::new();
4565 let mut all_findings = Vec::new();
4566 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4567 let agent_id = seat.agent.clone();
4568 self.state.seats.insert(seat.key.clone(), seat);
4569 let mut record = ReviewRecord {
4570 reviewer: r + 1,
4571 agent: agent_id,
4572 summary: String::new(),
4573 findings: Vec::new(),
4574 vote: None,
4575 failed: None,
4576 duration_ms: 0,
4577 attempts,
4583 };
4584 match res {
4585 Ok((review, out)) => {
4586 record.summary =
4594 blind::sanitize_prose(&review.summary, &self.state.config.blind);
4595 record.vote = Some(review.vote);
4596 record.duration_ms = out.duration_ms;
4597 for (n, mut f) in review.findings.into_iter().enumerate() {
4598 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4601 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4602 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4603 f.file = f
4609 .file
4610 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4611 all_findings.push(f.clone());
4612 record.findings.push(f);
4613 }
4614 self.state.event(
4615 "review",
4616 format!(
4617 "round {round}: reviewer {} voted {} with {} finding(s)",
4618 r + 1,
4619 review.vote.label(),
4620 record.findings.len()
4621 ),
4622 );
4623 }
4624 Err(e) => {
4625 record.failed = Some(e.to_string());
4626 self.state.event(
4627 "review",
4628 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4629 );
4630 }
4631 }
4632 records.push(record);
4633 }
4634
4635 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4642 let vote_split =
4643 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4644 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4645 if vote_split {
4646 self.state.event(
4647 "review",
4648 format!(
4649 "round {round}: votes split ({}) — one round of reconsideration",
4650 initial_votes
4651 .iter()
4652 .map(|v| v.label())
4653 .collect::<Vec<_>>()
4654 .join(", ")
4655 ),
4656 );
4657 let panel: Vec<ReviewSeatReport<'_>> = records
4660 .iter()
4661 .filter_map(|r| {
4662 r.vote.map(|vote| ReviewSeatReport {
4663 reviewer: r.reviewer,
4664 vote,
4665 summary: &r.summary,
4666 findings: &r.findings,
4667 })
4668 })
4669 .collect();
4670
4671 let mut jobs = Vec::new();
4672 let mut seats_at = Vec::new();
4673 for (r, spec) in reviewers.iter().cloned().enumerate() {
4674 if records[r].vote.is_none() {
4678 continue;
4679 }
4680 let wt = root.join(format!("review-{}", r + 1));
4681 let seat_key = format!("review-{}", r + 1);
4682 let spec = self.occupant(&seat_key, spec);
4683 let seat = self.seat(&seat_key, &spec.id);
4684 let build = |with_patch: bool| {
4692 prompt::review_reconsider(&ReviewReconsiderCtx {
4693 instruction: &self.state.instruction,
4694 reviewer: r + 1,
4695 lens: Lens::for_seat(r),
4696 panel: &panel,
4697 patch: with_patch.then_some(ReviewPatch {
4698 branch: &winner.branch,
4699 base_short: &base_short,
4700 stat: &stat,
4701 patch: &patch,
4702 }),
4703 round,
4704 rounds: max_rounds,
4705 language: &language,
4706 })
4707 };
4708 let full = build(true);
4709 let prompt = if has_context(&spec, &seat, sessions) {
4710 build(false)
4711 } else {
4712 full.clone()
4713 };
4714 jobs.push(SeatJob {
4715 prompt,
4716 spec,
4717 seat,
4718 cwd: wt,
4719 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4720 allow_write: false,
4721 sessions,
4722 artifacts: artifacts.clone(),
4723 stem: format!("review-{round}-reconsider-{}", r + 1),
4724 handover: Some(full),
4725 });
4726 seats_at.push(r);
4727 }
4728
4729 let mut recon_quota_losses = Vec::new();
4730 let recon_cache = self.state.config.cache_dir();
4731 let recon_ctx = WaveCtx {
4732 carry_seats: true,
4733 run: &run_id,
4734 node: "review",
4735 prompts: &prompts,
4736 cache: recon_cache.as_deref(),
4737 round: Some(round),
4738 };
4739 let recon_results = ask_json_wave::<ReviewRevote>(
4740 jobs,
4741 Arc::clone(&self.sem),
4742 review_retries,
4743 &self.roles.reviewer_roster,
4744 &recon_ctx,
4745 &mut recon_quota_losses,
4746 &mut self.state,
4747 &|_: &ReviewRevote| Ok(()),
4748 )
4749 .await;
4750 self.state.quota.extend(recon_quota_losses);
4751
4752 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4753 let agent_id = seat.agent.clone();
4754 self.state.seats.insert(seat.key.clone(), seat);
4755 let mut rec = ReviewRevoteRecord {
4756 reviewer: r + 1,
4757 agent: agent_id,
4758 vote: None,
4759 reason: String::new(),
4760 failed: None,
4761 };
4762 match res {
4763 Ok((rv, _)) => {
4764 rec.vote = Some(rv.vote);
4765 rec.reason =
4766 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4767 self.state.event(
4768 "review",
4769 format!(
4770 "round {round}: reviewer {} revoted {}",
4771 r + 1,
4772 rv.vote.label()
4773 ),
4774 );
4775 }
4776 Err(e) => {
4777 rec.failed = Some(e.to_string());
4778 self.state.event(
4779 "review",
4780 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4781 );
4782 }
4783 }
4784 reconsideration.push(rec);
4785 }
4786 } else if initial_votes.len() > 1 {
4787 self.state.event(
4788 "review",
4789 format!(
4790 "round {round}: votes agreed ({}) — no reconsideration",
4791 initial_votes[0].label()
4792 ),
4793 );
4794 }
4795
4796 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4797 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4798 let defer_e2e =
4809 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4810 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4811 let reason =
4812 format!("{blocking} blocking finding(s) already required a fix this round");
4813 self.state.event(
4814 "verify",
4815 format!(
4816 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4817 {}); it will run once a round has none left",
4818 short(&head)
4819 ),
4820 );
4821 (Vec::new(), false, true, Some(reason))
4822 } else {
4823 let e2e_commands = self.state.config.verify.e2e.clone();
4824 let cache_dir = self.state.config.cache_dir();
4825 let context = format!("round {round}");
4826 let (e2e, verify_retried) = with_cache_lease(
4827 &mut self.state,
4828 cache_dir.as_deref(),
4829 "e2e",
4830 "e2e",
4831 &winner.worktree,
4832 &head,
4833 verify_timeout,
4834 &context,
4835 |state, budget| {
4836 let shell = shell.clone();
4837 let e2e_commands = e2e_commands.clone();
4838 let worktree = winner.worktree.clone();
4839 let context = context.clone();
4840 async move {
4841 run_e2e_with_retry(
4842 state,
4843 &shell,
4844 &e2e_commands,
4845 &worktree,
4846 budget,
4847 &context,
4848 )
4849 .await
4850 }
4851 },
4852 )
4853 .await;
4854 (e2e, verify_retried, false, None)
4855 };
4856
4857 let expected = records.len();
4858 let answered = records.iter().filter(|r| r.failed.is_none()).count();
4859 let incomplete = answered < expected;
4860 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
4861 let policy = self.state.config.graph.incomplete_review;
4862 let clean = round_is_clean(
4863 blocking,
4864 e2e_ok,
4865 answered,
4866 expected,
4867 round_quota_missing,
4868 policy,
4869 );
4870
4871 let mut round_record = ReviewRound {
4872 round,
4873 head: head.clone(),
4874 verified_head: None,
4875 verified_at: None,
4876 reviews: records,
4877 e2e,
4878 verify_retried,
4879 e2e_deferred,
4880 e2e_defer_reason,
4881 fix: None,
4882 blocking,
4883 answered,
4884 expected,
4885 clean,
4886 progressed: false,
4887 vote_split,
4888 reconsideration,
4889 verdict: None,
4890 };
4891 round_record.verdict = ReviewVote::worst(
4895 round_record
4896 .final_votes()
4897 .into_iter()
4898 .map(|(_, _, vote)| vote),
4899 );
4900 if !matches!(
4911 round_record.e2e_status(),
4912 E2eStatus::Deferred | E2eStatus::NotConfigured
4913 ) {
4914 round_record.verified_head = Some(head.clone());
4915 round_record.verified_at = Some(Timestamp::now());
4916 }
4917 let this_round_verification = round_record.verification_summary(&head);
4918
4919 if incomplete {
4920 let missing: Vec<String> = round_record
4921 .reviews
4922 .iter()
4923 .filter(|r| r.failed.is_some())
4924 .map(|r| format!("review-{}", r.reviewer))
4925 .collect();
4926 self.state.event(
4927 "review",
4928 format!(
4929 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
4930 missing.join(", ")
4931 ),
4932 );
4933 }
4934
4935 if clean {
4936 self.state.event(
4937 "review",
4938 if incomplete && policy == IncompleteReviewPolicy::Warn {
4939 format!(
4940 "round {round}: clean (warn policy, incomplete panel) — no \
4941 blocking findings from the seats that answered, verification green"
4942 )
4943 } else if incomplete {
4944 format!(
4945 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
4946 quorum) — no blocking findings from the seats that answered, \
4947 verification green",
4948 expected - answered
4949 )
4950 } else {
4951 format!("round {round}: clean — no blocking findings, verification green")
4952 },
4953 );
4954 self.state.reviews.push(round_record);
4955 self.state.status = RunStatus::Gating;
4956 self.state.save()?;
4957 return Ok(());
4958 }
4959
4960 if incomplete && blocking == 0 && e2e_ok {
4968 self.state.reviews.push(round_record);
4969 self.state.save()?;
4970 if round == max_rounds {
4971 self.state.status = RunStatus::Blocked;
4972 self.state.event(
4973 "review",
4974 format!(
4975 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
4976 refusing to call it clean",
4977 expected - answered
4978 ),
4979 );
4980 return Ok(());
4981 }
4982 continue;
4983 }
4984
4985 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
4997 self.state.reviews.push(round_record);
4998 return self
4999 .stop_reviewing(
5000 "the round's own verification could not run",
5001 &shell,
5002 &winner.worktree,
5003 )
5004 .await;
5005 }
5006
5007 if round == max_rounds {
5008 self.state.reviews.push(round_record);
5009 return self
5010 .stop_reviewing(
5011 &format!(
5012 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5013 ),
5014 &shell,
5015 &winner.worktree,
5016 )
5017 .await;
5018 }
5019
5020 let (fix_spec, fix_seat_key) = self.fixer_spec(&winner);
5023 let seat = self.seat(&fix_seat_key, &fix_spec.id);
5024 let blocking_findings: Vec<_> = all_findings
5025 .iter()
5026 .filter(|f| f.severity.blocks())
5027 .cloned()
5028 .collect();
5029 let job = SeatJob {
5030 prompt: prompt::fix(
5031 &self.state.instruction,
5032 &blocking_findings,
5033 this_round_verification.as_ref(),
5034 round,
5035 max_rounds,
5036 &language,
5037 ),
5038 spec: fix_spec.clone(),
5039 seat,
5040 cwd: winner.worktree.clone(),
5041 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5042 allow_write: true,
5043 sessions,
5044 artifacts: artifacts.clone(),
5045 stem: format!("fix-{round}"),
5046 handover: None,
5047 };
5048 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5049 let cache = self.state.config.cache_dir();
5050 let ctx = WaveCtx {
5051 carry_seats: false,
5052 run: &run_id,
5053 node: "fix",
5054 prompts: &prompts,
5055 cache: cache.as_deref(),
5056 round: Some(round),
5057 };
5058 let (seat, out) =
5059 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5060 let agent_id = seat.agent.clone();
5061
5062 let mut fix = FixRecord {
5063 agent: agent_id,
5064 addressed: Vec::new(),
5065 rejected: Vec::new(),
5066 notes: String::new(),
5067 committed: false,
5068 failed: None,
5069 duration_ms: 0,
5070 continuation: None,
5071 };
5072 let mut continuation = ContinuationRecord::not_needed();
5073 let mut final_seat = seat.clone();
5074 match out {
5075 AgentOutcome::Ok(o) => {
5076 fix.duration_ms = o.duration_ms;
5077 let parsed = verdict::extract_json::<FixReport>(&o.text);
5078 let incomplete_reason = match &parsed {
5085 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5086 "the reply parsed, but it reported a command whose own CLI never \
5087 confirmed an exit status"
5088 .to_owned(),
5089 ),
5090 Ok(_) => None,
5091 Err(e) => Some(e.to_string()),
5092 };
5093 match incomplete_reason {
5094 None => {
5095 let report = parsed.expect("checked Ok above");
5096 fix.addressed = report.addressed;
5097 fix.rejected = report.rejected;
5098 fix.notes =
5099 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5100 }
5101 Some(reason) => {
5102 let (resumed_seat, resolved, failure, cont) = self
5103 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5104 .await;
5105 fix.duration_ms += cont.cumulative_wait_ms;
5106 continuation = cont;
5107 final_seat = resumed_seat;
5108 match resolved {
5109 Some(report) => {
5110 fix.addressed = report.addressed;
5111 fix.rejected = report.rejected;
5112 fix.notes = blind::sanitize_prose(
5113 &report.notes,
5114 &self.state.config.blind,
5115 );
5116 }
5117 None => fix.failed = failure,
5118 }
5119 }
5120 }
5121 }
5122 AgentOutcome::Dropped(o) => {
5124 fix.duration_ms = o.duration_ms;
5125 let why = o
5126 .dropped
5127 .as_ref()
5128 .map(|d| d.why.as_str())
5129 .unwrap_or("the CLI ended the stream without delivering its answer");
5130 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5131 }
5132 AgentOutcome::Quota(o) => {
5133 self.state.quota.push(QuotaLoss {
5134 seat: final_seat.key.clone(),
5135 node: "fix".to_owned(),
5136 at: Timestamp::now(),
5137 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5138 });
5139 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5140 }
5141 AgentOutcome::Failed(e) => fix.failed = Some(e),
5142 }
5143 fix.continuation = Some(continuation);
5144 self.state.seats.insert(final_seat.key.clone(), final_seat);
5145 if let Ok(r) = git::rescue_commit(
5146 &winner.worktree,
5147 &format!("magi: review round {round} fixes (uncommitted work)"),
5148 )
5149 .await
5150 {
5151 self.state.note_withheld("fix", &r.withheld);
5152 }
5153 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5154 fix.committed = after != before;
5155 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5163 let progressed = diff_after != patch;
5164 let commit_note = if fix.committed {
5165 "committed"
5166 } else {
5167 "NO new commit"
5168 };
5169 let tree_note = if progressed {
5170 "changed vs base"
5171 } else {
5172 "unchanged vs base"
5173 };
5174 self.state.event(
5175 "fix",
5176 match &fix.failed {
5177 Some(reason) => {
5183 format!(
5184 "round {round}: fixer's adoption report was lost ({reason}); \
5185 {commit_note}, tree {tree_note}"
5186 )
5187 }
5188 None => format!(
5189 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5190 {tree_note}{}",
5191 fix.addressed.len(),
5192 fix.rejected.len(),
5193 if continuation.outcome == ContinuationOutcome::Resumed {
5194 format!(
5195 " (adoption report recovered after {} continuation(s))",
5196 continuation.attempts
5197 )
5198 } else {
5199 String::new()
5200 },
5201 ),
5202 },
5203 );
5204 round_record.fix = Some(fix);
5205 round_record.progressed = progressed;
5206 self.state.reviews.push(round_record);
5207 self.state.save()?;
5208
5209 if matches!(
5222 continuation.outcome,
5223 ContinuationOutcome::Exhausted
5224 | ContinuationOutcome::QuotaLost
5225 | ContinuationOutcome::NoSession
5226 ) {
5227 return self
5228 .stop_reviewing(
5229 "the fixer's adoption report never came back, even after resuming its \
5230 own seat; refusing to start another round against the same worktree \
5231 while that is unresolved",
5232 &shell,
5233 &winner.worktree,
5234 )
5235 .await;
5236 }
5237
5238 let streak = self
5239 .state
5240 .reviews
5241 .iter()
5242 .rev()
5243 .take_while(|r| !r.progressed)
5244 .count();
5245 if streak >= STAGNANT_LIMIT {
5246 return self
5247 .stop_reviewing(
5248 &format!(
5249 "the tree has not moved against base for {streak} round(s) in a row"
5250 ),
5251 &shell,
5252 &winner.worktree,
5253 )
5254 .await;
5255 }
5256 }
5257 Ok(())
5258 }
5259
5260 fn record_contested_handoff(&mut self) {
5294 if self.state.contested_handoff.is_some() {
5295 return;
5296 }
5297 let Some(contested) = self
5298 .state
5299 .reviews
5300 .last()
5301 .and_then(ReviewRound::contested_handoff)
5302 else {
5303 return;
5304 };
5305 self.state.event(
5306 "review",
5307 format!(
5308 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5309 wait for the owner's approval",
5310 contested.findings.len(),
5311 contested.rejecters.len()
5312 ),
5313 );
5314 self.state.contested_handoff = Some(contested);
5315 }
5316
5317 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5318 let round_idx = self.state.reviews.len() - 1;
5319 let needs_catchup_run = matches!(
5327 self.state.reviews[round_idx].e2e_status(),
5328 E2eStatus::Deferred | E2eStatus::ResourceBlocked
5329 );
5330 if needs_catchup_run {
5331 let round = self.state.reviews[round_idx].round;
5332 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5333 let commands = self.state.config.verify.e2e.clone();
5334 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5335 let cache_dir = self.state.config.cache_dir();
5336 let context = format!(
5337 "round {round}: verification unresolved, catching up before the final decision"
5338 );
5339 let (outcomes, verify_retried) = with_cache_lease(
5340 &mut self.state,
5341 cache_dir.as_deref(),
5342 "e2e",
5343 "e2e",
5344 worktree,
5345 &attempted_head,
5346 timeout,
5347 &context,
5348 |state, budget| {
5349 let shell = shell.to_vec();
5350 let commands = commands.clone();
5351 let context = context.clone();
5352 async move {
5353 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5354 .await
5355 }
5356 },
5357 )
5358 .await;
5359 let last = &mut self.state.reviews[round_idx];
5360 last.e2e = outcomes;
5361 last.verify_retried = verify_retried;
5362 last.verified_head = Some(attempted_head);
5369 last.verified_at = Some(Timestamp::now());
5370 if verify_inconclusive(&last.e2e) {
5371 self.state.save()?;
5378 return Ok(());
5379 }
5380 last.e2e_deferred = false;
5381 }
5382 let last = &self.state.reviews[round_idx];
5383 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5384
5385 match last.e2e_status() {
5386 E2eStatus::Failed => {
5387 let red: Vec<String> = last
5388 .e2e
5389 .iter()
5390 .filter(|o| !o.ok())
5391 .map(|o| {
5392 format!(
5393 "`{}` -> {:?}\n{}",
5394 o.command,
5395 o.code,
5396 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5397 )
5398 })
5399 .collect();
5400 self.state
5401 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5402 self.state.status = RunStatus::Blocked;
5403 }
5404 E2eStatus::ResourceBlocked => {
5409 self.state.event(
5410 "review",
5411 format!(
5412 "{why}; e2e could not run (shared build cache unavailable); not \
5413 deciding yet"
5414 ),
5415 );
5416 }
5417 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5418 self.state.event(
5419 "review",
5420 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5421 );
5422 self.record_contested_handoff();
5423 self.state.status = RunStatus::Gating;
5424 }
5425 }
5426 self.state.save()?;
5427 Ok(())
5428 }
5429
5430 async fn gate(&mut self) -> Result<()> {
5433 if self.state.status == RunStatus::Failed
5445 || self
5446 .state
5447 .base_sync
5448 .as_ref()
5449 .is_some_and(|s| s.conflict.is_some())
5450 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5451 != Some(RunStatus::Gating)
5452 {
5453 return Ok(());
5454 }
5455 if self.state.gate_ran {
5456 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5467 self.state.status = RunStatus::Blocked;
5468 self.state.save()?;
5469 }
5470 return Ok(());
5471 }
5472 let Some(winner) = self.state.winner().cloned() else {
5473 return Ok(());
5474 };
5475 self.state.status = RunStatus::Gating;
5476 let mut outcomes = self.run_gate(&winner).await?;
5477 loop {
5478 if verify_inconclusive(&outcomes) {
5489 self.state.save()?;
5490 return Ok(());
5491 }
5492 if outcomes.iter().all(CommandOutcome::ok) {
5493 break;
5494 }
5495 match self.gate_fix_round(&winner, &outcomes).await? {
5496 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5497 GateFix::Stop => break,
5498 GateFix::Defer => {
5499 self.state.save()?;
5500 return Ok(());
5501 }
5502 }
5503 }
5504 let passed = outcomes.iter().all(CommandOutcome::ok);
5505 self.state.gate = outcomes;
5506 self.state.gate_ran = true;
5507 if !passed {
5508 self.state.status = RunStatus::Blocked;
5509 let spent = self.state.gate_fixes.len();
5510 self.state.event(
5511 "gate",
5512 if spent == 0 {
5513 "gate failed; not merging".to_owned()
5514 } else {
5515 format!("gate failed after {spent} gate-fix round(s); not merging")
5516 },
5517 );
5518 }
5519 self.state.save()?;
5520 Ok(())
5521 }
5522
5523 async fn run_pre_gate(&mut self, winner: &Candidate) {
5533 let commands = self.state.config.verify.pre_gate.clone();
5534 if commands.is_empty() {
5535 return;
5536 }
5537 let shell = self.state.config.shell();
5538 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5539 let (outcomes, _) = run_commands(
5540 &mut self.state,
5541 "pre_gate",
5542 "pre_gate",
5543 0,
5544 &shell,
5545 &commands,
5546 &winner.worktree,
5547 timeout,
5548 )
5549 .await;
5550 for o in &outcomes {
5551 if !o.ok() {
5552 tracing::warn!(
5553 "pre_gate `{}` failed ({:?}); the gate decides",
5554 o.command,
5555 o.code
5556 );
5557 }
5558 self.state.event(
5559 "pre_gate",
5560 format!(
5561 "`{}` -> {}",
5562 o.command,
5563 if o.ok() {
5564 "pass".to_owned()
5565 } else {
5566 format!(
5567 "FAIL ({:?})\n{}",
5568 o.code,
5569 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5570 )
5571 }
5572 ),
5573 );
5574 }
5575 self.state.pre_gate = outcomes;
5576 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5577 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5578 Ok(head) => {
5579 self.state
5580 .event("pre_gate", format!("committed mechanical fixes ({head})"));
5581 self.state.pre_gate_commit = Some(head);
5582 }
5583 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5584 },
5585 Ok(false) => {}
5586 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5587 }
5588 if let Err(e) = self.state.save() {
5589 tracing::warn!("could not persist the pre_gate record: {e:#}");
5590 }
5591 }
5592
5593 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5596 self.run_pre_gate(winner).await;
5597 let shell = self.state.config.shell();
5598 let gate_commands = self.state.config.verify.gate.clone();
5599 let outcomes = if gate_commands.is_empty() {
5608 Vec::new()
5609 } else {
5610 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5611 let cache_dir = self.state.config.cache_dir();
5612 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5613 let (outcomes, _) = with_cache_lease(
5614 &mut self.state,
5615 cache_dir.as_deref(),
5616 "gate",
5617 "gate",
5618 &winner.worktree,
5619 &head,
5620 timeout,
5621 "final gate",
5622 |state, budget| {
5623 let shell = shell.clone();
5624 let gate_commands = gate_commands.clone();
5625 let worktree = winner.worktree.clone();
5626 async move {
5627 let (outcomes, timed_out_pids) = run_commands(
5628 state,
5629 "gate",
5630 "gate",
5631 0,
5632 &shell,
5633 &gate_commands,
5634 &worktree,
5635 budget,
5636 )
5637 .await;
5638 (outcomes, false, timed_out_pids)
5639 }
5640 },
5641 )
5642 .await;
5643 outcomes
5644 };
5645 if outcomes.is_empty() {
5646 self.state.event(
5651 "gate",
5652 "no gate commands configured; nothing to check, passing",
5653 );
5654 }
5655 for o in &outcomes {
5656 self.state.event(
5657 "gate",
5658 format!(
5659 "`{}` -> {}",
5660 o.command,
5661 if o.ok() {
5662 "pass".to_owned()
5663 } else {
5664 format!(
5665 "FAIL ({:?})\n{}",
5666 o.code,
5667 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5668 )
5669 }
5670 ),
5671 );
5672 }
5673 Ok(outcomes)
5674 }
5675
5676 async fn gate_fix_round(
5689 &mut self,
5690 winner: &Candidate,
5691 outcomes: &[CommandOutcome],
5692 ) -> Result<GateFix> {
5693 let cap = self.state.config.graph.gate_fix_rounds;
5694 let spent = self.state.gate_fixes.len();
5695 if spent >= cap {
5696 if cap > 0 {
5697 self.state.event(
5698 "gate",
5699 format!("{spent} gate-fix round(s) spent and the gate still fails"),
5700 );
5701 }
5702 return Ok(GateFix::Stop);
5703 }
5704 if !gate_fixable(outcomes) {
5705 self.state.event(
5706 "gate",
5707 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5708 command or similar); not spending a fix round on it",
5709 );
5710 return Ok(GateFix::Stop);
5711 }
5712 let min_free = self.state.config.disk.min_free_bytes;
5713 if min_free > 0 {
5714 match crate::disk::free_bytes(&winner.worktree) {
5715 Ok(free) if crate::disk::enough_space(free, min_free) => {}
5716 Ok(free) => {
5717 self.state.event(
5718 "gate",
5719 format!(
5720 "only {free} bytes free ({min_free} required by `[disk] \
5721 min_free_bytes`); not spending a fix round on a failure the disk \
5722 may explain"
5723 ),
5724 );
5725 return Ok(GateFix::Stop);
5726 }
5727 Err(e) => {
5728 self.state.event(
5729 "gate",
5730 format!("free disk space could not be measured ({e:#}); no fix round"),
5731 );
5732 return Ok(GateFix::Stop);
5733 }
5734 }
5735 }
5736
5737 let attempt = spent + 1;
5738 let run_id = self.state.id.clone();
5739 let prompts = self.state.config.prompts.clone();
5740 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5741 let base = self.landing_base();
5742 let (fix_spec, fix_seat_key) = self.fixer_spec(winner);
5743 let seat = self.seat(&fix_seat_key, &fix_spec.id);
5744 let job = SeatJob {
5745 prompt: prompt::gate_fix(
5746 &self.state.instruction,
5747 &failed,
5748 attempt,
5749 cap,
5750 &self.state.config.graph.language,
5751 ),
5752 spec: fix_spec,
5753 seat,
5754 cwd: winner.worktree.clone(),
5755 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5756 allow_write: true,
5757 sessions: self.state.config.graph.sessions,
5758 artifacts: agent::artifacts_dir(&self.state.dir()),
5759 stem: format!("gate-fix-{attempt}"),
5760 handover: None,
5761 };
5762 self.state.event(
5763 "gate",
5764 format!("gate failed; gate-fix round {attempt} of {cap}"),
5765 );
5766 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5767 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5768 let cache = self.state.config.cache_dir();
5769 let ctx = WaveCtx {
5770 carry_seats: false,
5771 run: &run_id,
5772 node: "gate-fix",
5773 prompts: &prompts,
5774 cache: cache.as_deref(),
5775 round: None,
5776 };
5777 let (seat, out) = run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5778 let mut record = GateFixRecord {
5779 agent: seat.agent.clone(),
5780 failed,
5781 notes: String::new(),
5782 committed: false,
5783 error: None,
5784 };
5785 match out {
5786 AgentOutcome::Ok(o) => {
5787 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5790 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5791 }
5792 }
5793 AgentOutcome::Dropped(_) => {
5794 record.error = Some("the CLI dropped the stream".to_owned());
5795 }
5796 AgentOutcome::Quota(o) => {
5797 self.state.quota.push(QuotaLoss {
5798 seat: seat.key.clone(),
5799 node: "gate-fix".to_owned(),
5800 at: Timestamp::now(),
5801 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5802 });
5803 record.error = Some("rate limited (quota); fixer could not run".to_owned());
5804 }
5805 AgentOutcome::Failed(e) => record.error = Some(e),
5806 }
5807 self.state.seats.insert(seat.key.clone(), seat);
5808 if let Ok(r) = git::rescue_commit(
5809 &winner.worktree,
5810 &format!("magi: gate fix {attempt} (uncommitted work)"),
5811 )
5812 .await
5813 {
5814 self.state.note_withheld("gate-fix", &r.withheld);
5815 }
5816 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5817 record.committed = after != before;
5818 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5819 let note = record.error.clone();
5820 self.state.gate_fixes.push(record);
5821 self.state.save()?;
5822 if !changed {
5823 self.state.event(
5824 "gate",
5825 match note {
5826 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5827 None => format!("gate-fix round {attempt}: the tree did not change"),
5828 },
5829 );
5830 return Ok(GateFix::Stop);
5831 }
5832 self.state.event(
5833 "gate",
5834 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5835 );
5836
5837 let commands = self.state.config.verify.e2e.clone();
5838 if !commands.is_empty() {
5839 let shell = self.state.config.shell();
5840 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5841 let cache_dir = self.state.config.cache_dir();
5842 let context = format!("gate-fix round {attempt}");
5843 let (e2e, _) = with_cache_lease(
5844 &mut self.state,
5845 cache_dir.as_deref(),
5846 "e2e",
5847 "e2e",
5848 &winner.worktree,
5849 &after,
5850 timeout,
5851 &context,
5852 |state, budget| {
5853 let shell = shell.clone();
5854 let commands = commands.clone();
5855 let context = context.clone();
5856 let worktree = winner.worktree.clone();
5857 async move {
5858 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5859 .await
5860 }
5861 },
5862 )
5863 .await;
5864 if verify_inconclusive(&e2e) {
5865 return Ok(GateFix::Defer);
5866 }
5867 if e2e.iter().any(|o| !o.ok()) {
5868 self.state.event(
5869 "gate",
5870 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
5871 );
5872 return Ok(GateFix::Stop);
5873 }
5874 }
5875 Ok(GateFix::Retry)
5876 }
5877
5878 async fn merge(&mut self) -> Result<()> {
5881 if self
5896 .state
5897 .base_sync
5898 .as_ref()
5899 .is_some_and(|s| s.conflict.is_some())
5900 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5901 != Some(RunStatus::Gating)
5902 || !self.state.gate_status().ok()
5911 {
5912 return Ok(());
5913 }
5914 if self.state.merge.is_some() {
5923 return Ok(());
5924 }
5925 let Some(winner) = self.state.winner().cloned() else {
5926 return Ok(());
5927 };
5928 let repo = self.state.repo.clone();
5929 let base = self.state.base_branch.clone();
5930 let mode = self.state.config.merge.mode;
5931 let style = self.state.config.merge.style;
5932 let facts = if is_review_run(&self.state) {
5933 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
5934 let start = review_base(
5935 &repo,
5936 &self.state.config.merge.remote,
5937 &base,
5938 &self.state.base_commit,
5939 &winner.branch,
5940 )
5941 .await;
5942 branch_facts(&repo, &start, &winner.branch).await
5943 } else {
5944 None
5945 };
5946 let leaked = if is_review_run(&self.state) {
5949 leaked_subjects(&self.state, &winner.branch).await
5950 } else {
5951 Some(Vec::new())
5952 };
5953 let pr = pr_message_with(&self.state, winner.label, facts.as_ref());
5954 let message = pr.commit_message();
5955
5956 let outcome = match mode {
5957 MergeMode::None => MergeOutcome {
5958 mode,
5959 ok: true,
5960 detail: manual_merge_command(style, &repo, &winner.branch, &message),
5961 empty: false,
5962 },
5963 MergeMode::Pr | MergeMode::Local
5964 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
5965 {
5966 MergeOutcome {
5967 mode,
5968 ok: false,
5969 detail: empty_candidate_detail(&self.state, &base),
5970 empty: true,
5971 }
5972 }
5973 MergeMode::Local => {
5974 let on = git::current_branch(&repo).await?;
5975 if on.as_deref() != Some(base.as_str()) {
5976 MergeOutcome {
5977 mode,
5978 ok: false,
5979 detail: format!(
5980 "{} has {} checked out, not the base branch {base}",
5981 repo.display(),
5982 on.unwrap_or_else(|| "a detached HEAD".to_owned())
5983 ),
5984 empty: false,
5985 }
5986 } else if !git::is_clean(&repo).await? {
5987 MergeOutcome {
5988 mode,
5989 ok: false,
5990 detail: format!("{} is dirty; refusing to merge", repo.display()),
5991 empty: false,
5992 }
5993 } else {
5994 let out = match style {
5995 MergeStyle::Merge => {
5996 git::merge_no_ff(&repo, &winner.branch, &message).await?
5997 }
5998 MergeStyle::Squash => {
5999 git::merge_squash(&repo, &winner.branch, &message).await?
6000 }
6001 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6002 };
6003 MergeOutcome {
6004 mode,
6005 ok: out.ok(),
6006 detail: if out.ok() { out.stdout } else { out.stderr },
6007 empty: false,
6008 }
6009 }
6010 }
6011 MergeMode::Pr => {
6012 let remote = self.state.config.merge.remote.clone();
6013 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6014 if !pushed.ok() {
6015 MergeOutcome {
6016 mode,
6017 ok: false,
6018 detail: pushed.stderr,
6019 empty: false,
6020 }
6021 } else {
6022 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6028 let out = match pr_merge_plan(found) {
6029 PrPlan::Create => {
6030 gh_pr_create(
6031 &winner.worktree,
6032 &base,
6033 &winner.branch,
6034 &pr.title,
6035 &pr.body,
6036 )
6037 .await
6038 }
6039 PrPlan::Adopt { url, title } => {
6040 self.state
6041 .event("merge", format!("Pr: adopted open pull request {url}"));
6042 if title != pr.title
6043 && (!is_review_run(&self.state)
6044 || leaked
6045 .as_deref()
6046 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6047 && let Err(e) =
6048 land::set_pr_title(&winner.worktree, &url, &pr.title).await
6049 {
6050 tracing::warn!("could not refresh title of {url}: {e:#}");
6051 self.state
6052 .event("merge", format!("Pr: title refresh failed: {e:#}"));
6053 }
6054 Ok(url)
6055 }
6056 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6057 };
6058 match out {
6059 Ok(url) => MergeOutcome {
6060 mode,
6061 ok: true,
6062 detail: url,
6063 empty: false,
6064 },
6065 Err(e) => MergeOutcome {
6066 mode,
6067 ok: false,
6068 detail: e.to_string(),
6069 empty: false,
6070 },
6071 }
6072 }
6073 }
6074 };
6075
6076 self.state.status = match (mode, outcome.ok) {
6077 (MergeMode::None, _) => RunStatus::Ready,
6078 (_, true) => RunStatus::Merged,
6079 (_, false) => RunStatus::Blocked,
6080 };
6081 self.state.event(
6082 "merge",
6083 format!(
6084 "{:?}: {}",
6085 mode,
6086 outcome.detail.lines().next().unwrap_or("")
6087 ),
6088 );
6089 self.state.merge = Some(outcome);
6090 self.state.save()?;
6091
6092 if self.state.config.graph.land
6098 && mode == MergeMode::Pr
6099 && self.state.status == RunStatus::Merged
6100 {
6101 self.run_land().await?;
6102 }
6103 self.settle_questions();
6108 Ok(())
6109 }
6110
6111 async fn run_land(&mut self) -> Result<()> {
6122 let url = self
6123 .state
6124 .merge
6125 .as_ref()
6126 .map(|m| m.detail.clone())
6127 .unwrap_or_default();
6128 let url = url.lines().next().unwrap_or("").trim().to_owned();
6129 if !url.starts_with("http") {
6130 return Ok(());
6131 }
6132 match land::land(&mut self.state, &url).await {
6135 Ok(pr) if self.state.parked => {
6136 let _ = pr;
6140 }
6141 Ok(pr) => {
6142 self.state.status = match pr.state {
6143 land::PrLifecycle::Merged => RunStatus::Merged,
6144 _ => RunStatus::Blocked,
6145 };
6146 if bump::should_release_bump(self.state.status)
6153 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6154 {
6155 self.state
6161 .event("bump", format!("release bump skipped: {e:#}"));
6162 }
6163 if self.state.status == RunStatus::Merged {
6166 crate::followup::after_merge(&mut self.state, &pr.url).await;
6167 }
6168 self.state.save()?;
6169 }
6170 Err(e) => {
6171 self.state.status = RunStatus::Blocked;
6172 self.state.event("land", format!("gave up: {e}"));
6173 self.state.save()?;
6174 }
6175 }
6176 Ok(())
6177 }
6178
6179 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6183 if let Some(existing) = self.state.seats.get(key)
6184 && existing.agent == agent
6185 {
6186 return existing.clone();
6187 }
6188 let fresh = if self.state.seats.contains_key(key) {
6191 handover_seat(key, agent, self.state.next_seat_seed())
6192 } else {
6193 SeatState::new(key, agent, self.state.seed)
6194 };
6195 self.state.seats.insert(key.to_owned(), fresh.clone());
6196 fresh
6197 }
6198
6199 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6205 match self.state.seats.get(key) {
6206 Some(s) if s.agent != spec.id => {
6207 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6208 }
6209 _ => spec,
6210 }
6211 }
6212
6213 fn view(&self, c: &Candidate) -> CandidateView {
6215 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6216 .unwrap_or_default();
6217 let (patch, _) = blind::sanitize_patch(
6218 &format!("candidate {} patch", c.label),
6219 &raw,
6220 &self.state.config.blind,
6221 );
6222 CandidateView {
6223 label: c.label,
6224 branch: c.branch.clone(),
6225 summary: c.summary.clone(),
6226 stat: c.stat.clone(),
6227 patch,
6228 }
6229 }
6230
6231 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6233 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6234 prompt::judge(
6235 "(see above)",
6236 &views,
6237 self.roles.judges.len(),
6238 base_short,
6239 "en",
6240 )
6241 }
6242
6243 fn vote_prompt_full(
6249 &self,
6250 j: usize,
6251 labels: &[char],
6252 language: &str,
6253 candidates: &[Candidate],
6254 base_short: &str,
6255 ) -> String {
6256 let mut text = format!(
6257 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6258 prompt::final_vote(labels, language),
6259 self.state.instruction,
6260 self.candidate_block(candidates, base_short)
6261 );
6262 if let Some(own) = self
6263 .state
6264 .judgements
6265 .get(j)
6266 .filter(|r| !r.ranking.is_empty())
6267 {
6268 let reasons = own
6269 .reasons
6270 .iter()
6271 .map(|(k, v)| format!("- {k}: {v}"))
6272 .collect::<Vec<_>>()
6273 .join("\n");
6274 text.push_str(&format!(
6275 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6276 own.ranking.iter().collect::<String>(),
6277 if reasons.is_empty() {
6278 ""
6279 } else {
6280 ", because:\n"
6281 }
6282 ));
6283 }
6284 if !self.state.deliberation.is_empty() {
6285 text.push_str("\n# What was argued before this vote\n");
6286 for t in self.transcript(&[], j) {
6287 text.push_str(&format!(
6288 "\n## {}{}\n\n{}\n",
6289 t.who,
6290 if t.is_self { " (you)" } else { "" },
6291 t.body.trim()
6292 ));
6293 }
6294 }
6295 text
6296 }
6297
6298 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6305 let mut turns = Vec::new();
6306 for j in &self.state.judgements {
6307 if j.ranking.is_empty() {
6308 continue;
6309 }
6310 let reasons = j
6311 .reasons
6312 .iter()
6313 .map(|(k, v)| format!("- {k}: {v}"))
6314 .collect::<Vec<_>>()
6315 .join("\n");
6316 turns.push(Turn {
6317 who: format!("Judge {} (opening ranking)", j.judge),
6318 is_self: j.judge == self_idx + 1,
6319 body: format!(
6320 "Ranked {}{}{reasons}",
6321 j.ranking.iter().collect::<String>(),
6322 if reasons.is_empty() {
6323 ""
6324 } else {
6325 ", because:\n"
6326 }
6327 ),
6328 });
6329 }
6330 for t in self
6331 .state
6332 .deliberation
6333 .iter()
6334 .flat_map(|r| r.turns.iter())
6335 .chain(current)
6336 {
6337 turns.push(Turn {
6338 who: format!("Judge {}", t.judge),
6339 is_self: t.judge == self_idx + 1,
6340 body: t.body.clone(),
6341 });
6342 }
6343 turns
6344 }
6345}
6346
6347fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6349 agent::has_session(spec.kind, seat, sessions)
6350}
6351
6352fn next_untried_in_roster<'a>(
6373 roster: &'a [AgentSpec],
6374 start: usize,
6375 tried: &BTreeSet<String>,
6376) -> Option<&'a AgentSpec> {
6377 roster
6378 .get(start + 1..)?
6379 .iter()
6380 .find(|s| !tried.contains(&s.id))
6381}
6382
6383fn next_for_seat<'a>(
6393 roster: &'a [AgentSpec],
6394 start: usize,
6395 round_tried: &BTreeSet<String>,
6396 carried_failed: &BTreeSet<String>,
6397) -> Option<&'a AgentSpec> {
6398 roster
6399 .get(start + 1..)?
6400 .iter()
6401 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6402 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6403}
6404
6405fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6411 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6412 return spec;
6413 };
6414 let ok = |id: &str| !h.failed.contains(id);
6415 if let Some(last) = h.last_ok.as_deref()
6416 && ok(last)
6417 && let Some(s) = roster.iter().find(|s| s.id == last)
6418 {
6419 return s.clone();
6420 }
6421 if ok(&spec.id) {
6422 return spec;
6423 }
6424 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6425 roster
6426 .get(from + 1..)
6427 .into_iter()
6428 .flatten()
6429 .chain(roster.iter())
6430 .find(|s| ok(&s.id))
6431 .cloned()
6432 .unwrap_or(spec)
6433}
6434
6435fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6439 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6440}
6441
6442const TIMED_OUT: &str = "timed out";
6445
6446impl FailClass {
6447 fn of(out: &AgentOutcome) -> Option<Self> {
6449 match out {
6450 AgentOutcome::Ok(_) => None,
6451 AgentOutcome::Quota(_) => Some(Self::Quota),
6452 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6453 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6454 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6455 }
6456 }
6457
6458 fn stem_word(&self) -> &'static str {
6460 match self {
6461 Self::Quota => "quota",
6462 _ => "handover",
6463 }
6464 }
6465}
6466
6467fn failure_signature(msg: &str) -> String {
6471 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
6472 let mut out = Vec::new();
6473 for word in line.split_whitespace() {
6474 if word.contains('/') || word.contains('\\') {
6475 out.push("<path>".to_owned());
6476 continue;
6477 }
6478 let mut w = String::new();
6479 let mut in_digits = false;
6480 for c in word.chars() {
6481 if c.is_ascii_digit() {
6482 if !in_digits {
6483 w.push('#');
6484 }
6485 in_digits = true;
6486 } else {
6487 in_digits = false;
6488 w.push(c);
6489 }
6490 }
6491 out.push(w);
6492 }
6493 out.join(" ").chars().take(120).collect()
6494}
6495
6496fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
6503 match cur {
6504 FailClass::Quota | FailClass::Timeout => true,
6505 FailClass::Other(_) => prev != Some(cur),
6506 }
6507}
6508
6509fn fail_reason(out: &AgentOutcome) -> String {
6511 match out {
6512 AgentOutcome::Ok(_) => String::new(),
6513 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
6514 AgentOutcome::Dropped(o) => format!(
6515 "the CLI dropped the stream ({})",
6516 o.dropped
6517 .as_ref()
6518 .map(|d| d.why.as_str())
6519 .unwrap_or("it ended without delivering its answer")
6520 ),
6521 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
6522 }
6523}
6524
6525fn record_handover(
6528 state: &mut RunState,
6529 node: &str,
6530 seat: &str,
6531 from: &str,
6532 to: &str,
6533 class: &FailClass,
6534 reason: &str,
6535) {
6536 let message = if *class == FailClass::Quota {
6537 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
6538 } else {
6539 format!("{seat}: handed over {from} -> {to} ({reason})")
6540 };
6541 state.event(node, message);
6542 state.handovers.push(Handover {
6543 at: Timestamp::now(),
6544 node: node.to_owned(),
6545 seat: seat.to_owned(),
6546 from: from.to_owned(),
6547 to: to.to_owned(),
6548 reason: reason.to_owned(),
6549 });
6550}
6551
6552fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
6566 commands.iter().any(|c| c.exit_code.is_none())
6567}
6568
6569fn verified_noop_claim(
6582 usable: bool,
6583 commands: &[agent::CommandEvidence],
6584 text: &str,
6585) -> Option<String> {
6586 (usable && !has_unconfirmed_command(commands))
6587 .then(|| verdict::verified_noop(text))
6588 .flatten()
6589}
6590
6591fn short(commit: &str) -> String {
6592 commit.chars().take(7).collect()
6593}
6594
6595fn make_executable(path: &Path) -> Result<()> {
6596 #[cfg(unix)]
6597 {
6598 use std::os::unix::fs::PermissionsExt as _;
6599 let mut perms = std::fs::metadata(path)?.permissions();
6600 perms.set_mode(0o755);
6601 std::fs::set_permissions(path, perms)?;
6602 }
6603 #[cfg(not(unix))]
6604 {
6605 let _ = path;
6606 }
6607 Ok(())
6608}
6609
6610struct WaveCtx<'a> {
6617 run: &'a str,
6620 node: &'a str,
6622 prompts: &'a Prompts,
6623 cache: Option<&'a Path>,
6625 round: Option<usize>,
6628 carry_seats: bool,
6632}
6633
6634async fn run_one(
6636 job: SeatJob,
6637 sem: Arc<Semaphore>,
6638 ctx: &WaveCtx<'_>,
6639 state: &mut RunState,
6640 attempt: usize,
6641) -> (SeatState, AgentOutcome) {
6642 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
6643 .await
6644 .pop()
6645 .expect("one job in, one result out");
6646 (seat, out)
6647}
6648
6649async fn wave(
6655 jobs: Vec<SeatJob>,
6656 sem: Arc<Semaphore>,
6657 ctx: &WaveCtx<'_>,
6658 state: &mut RunState,
6659 attempt: usize,
6660) -> Vec<(usize, SeatState, AgentOutcome)> {
6661 let WaveCtx {
6662 run,
6663 node,
6664 prompts,
6665 cache,
6666 round,
6667 carry_seats: _,
6668 } = *ctx;
6669 for job in &jobs {
6670 state.seat_started(node, &job.seat.key, job.timeout, attempt);
6671 }
6672 if let Err(e) = state.save() {
6673 tracing::warn!("could not persist in-progress seats: {e:#}");
6678 }
6679 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
6695 let wait_started = Instant::now();
6696 let cache_guard = if let Some(cache_dir) = cache {
6697 if jobs_had_a_writer {
6698 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
6699 let budget = jobs
6700 .iter()
6701 .map(|j| j.timeout)
6702 .max()
6703 .unwrap_or(Duration::from_secs(60));
6704 acquire_cache_lease(state, cache_dir, &owner, budget, node)
6705 .await
6706 .ok()
6707 } else {
6708 None
6709 }
6710 } else {
6711 None
6712 };
6713 let waited_for_lease = wait_started.elapsed();
6720 let mut set = tokio::task::JoinSet::new();
6721 let overlay = prompts.overlay(node);
6722 for (i, mut job) in jobs.into_iter().enumerate() {
6723 job.timeout = job.timeout.saturating_sub(waited_for_lease);
6724 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
6725 if cache.is_some() {
6726 job.prompt.push('\n');
6727 job.prompt
6728 .push_str(&prompt::build_cache_note(node, job.allow_write));
6729 }
6730 let sem = Arc::clone(&sem);
6731 let run = run.to_owned();
6732 let node = node.to_owned();
6733 let attachments = if node == "implement" {
6736 state.attachments.clone()
6737 } else {
6738 Vec::new()
6739 };
6740 let cache = cache
6751 .filter(|_| job.allow_write && cache_guard.is_some())
6752 .map(Path::to_path_buf);
6753 set.spawn(async move {
6754 let _permit = sem.acquire().await;
6755 let mut seat = job.seat;
6756 let out = agent::invoke(
6757 &job.spec,
6758 &mut seat,
6759 &Invocation {
6760 cwd: &job.cwd,
6761 prompt: &job.prompt,
6762 timeout: job.timeout,
6763 allow_write: job.allow_write,
6764 sessions: job.sessions,
6765 artifacts: &job.artifacts,
6766 stem: &job.stem,
6767 run: &run,
6768 node: &node,
6769 cache_dir: cache.as_deref(),
6770 attachments: &attachments,
6771 writable: &[],
6772 },
6773 )
6774 .await;
6775 let out = match out {
6776 Ok(o) if o.usable() => AgentOutcome::Ok(o),
6777 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
6778 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
6786 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
6787 Ok(o) => AgentOutcome::Failed(format!(
6788 "exited with {:?} and no usable output",
6789 o.exit_code
6790 )),
6791 Err(e) => AgentOutcome::Failed(e.to_string()),
6792 };
6793 (i, seat, out)
6794 });
6795 }
6796 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
6797 while let Some(joined) = set.join_next().await {
6798 let (i, seat, out) = match joined {
6799 Ok(v) => v,
6800 Err(e) => {
6804 tracing::error!("agent task panicked: {e}");
6805 continue;
6806 }
6807 };
6808 state.seat_finished(&seat.key);
6809 record_jobs(state, node, round, &seat.key, &out);
6810 if let Err(e) = state.save() {
6811 tracing::warn!("could not persist a seat's completion: {e:#}");
6812 }
6813 if collected.len() <= i {
6814 collected.resize_with(i + 1, || None);
6815 }
6816 collected[i] = Some((i, seat, out));
6817 }
6818 if state
6824 .active
6825 .values()
6826 .any(|a| a.node == node && a.attempt == attempt)
6827 {
6828 state
6829 .active
6830 .retain(|_, a| !(a.node == node && a.attempt == attempt));
6831 if let Err(e) = state.save() {
6832 tracing::warn!("could not persist the end of a wave: {e:#}");
6833 }
6834 }
6835 if let Some(cache_dir) = cache
6842 && jobs_had_a_writer
6843 {
6844 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
6845 }
6846 if let Some(guard) = cache_guard {
6847 guard.release();
6848 }
6849 collected.into_iter().flatten().collect()
6850}
6851
6852fn record_jobs(
6863 state: &mut RunState,
6864 node: &str,
6865 round: Option<usize>,
6866 seat: &str,
6867 out: &AgentOutcome,
6868) {
6869 let commands: &[agent::CommandEvidence] = match out {
6870 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
6871 AgentOutcome::Failed(_) => &[],
6872 };
6873 let checked_at = Timestamp::now();
6874 for c in commands {
6875 state.jobs.push(JobRecord {
6876 node: node.to_owned(),
6877 round,
6878 seat: seat.to_owned(),
6879 id: c.id.clone(),
6880 description: c.description.clone(),
6881 checked_at,
6882 status: match c.exit_code {
6883 Some(0) => JobStatus::Completed,
6884 Some(_) => JobStatus::Failed,
6885 None => JobStatus::Unknown,
6886 },
6887 exit_code: c.exit_code,
6888 result_summary: c.result_summary.clone(),
6889 source: c.source.clone(),
6890 });
6891 }
6892}
6893
6894fn round_is_clean(
6915 blocking: usize,
6916 e2e_ok: bool,
6917 answered: usize,
6918 expected: usize,
6919 quota_missing: usize,
6920 policy: IncompleteReviewPolicy,
6921) -> bool {
6922 if blocking != 0 || !e2e_ok {
6923 return false;
6924 }
6925 if answered == expected || policy == IncompleteReviewPolicy::Warn {
6926 return true;
6927 }
6928 answered > 0 && expected - answered <= quota_missing
6929}
6930
6931fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
6955 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
6956 return Some(RunStatus::Gating);
6957 }
6958 let last = reviews.last()?;
6959 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
6960 if reviews.len() < max_rounds && !stagnant {
6961 return None;
6962 }
6963 if last.incomplete() && last.blocking == 0 {
6964 return Some(RunStatus::Blocked);
6965 }
6966 if last.e2e_status() == E2eStatus::ResourceBlocked {
6967 return None;
6968 }
6969 Some(if last.e2e.iter().all(CommandOutcome::ok) {
6970 RunStatus::Gating
6971 } else {
6972 RunStatus::Blocked
6973 })
6974}
6975
6976fn retry_budget(full: Duration, nudged: bool) -> Duration {
6991 if nudged {
6992 (full / 4).max(Duration::from_secs(120)).min(full)
6993 } else {
6994 full
6995 }
6996}
6997
6998#[allow(clippy::too_many_arguments)]
7027async fn ask_json_wave<T>(
7028 jobs: Vec<SeatJob>,
7029 sem: Arc<Semaphore>,
7030 retries: usize,
7031 roster: &[AgentSpec],
7032 ctx: &WaveCtx<'_>,
7033 losses: &mut Vec<QuotaLoss>,
7034 state: &mut RunState,
7035 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7036) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7037where
7038 T: serde::de::DeserializeOwned + Send + 'static,
7039{
7040 ask_wave_with(
7041 jobs,
7042 sem,
7043 retries,
7044 roster,
7045 ctx,
7046 losses,
7047 state,
7048 &|text: &str| {
7049 let v = verdict::extract_json::<T>(text)?;
7050 validate(&v)?;
7051 Ok(v)
7052 },
7053 )
7054 .await
7055}
7056
7057#[allow(clippy::too_many_arguments)]
7066async fn ask_wave_with<T>(
7067 jobs: Vec<SeatJob>,
7068 sem: Arc<Semaphore>,
7069 retries: usize,
7070 roster: &[AgentSpec],
7071 ctx: &WaveCtx<'_>,
7072 losses: &mut Vec<QuotaLoss>,
7073 state: &mut RunState,
7074 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7075) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7076where
7077 T: Send + 'static,
7078{
7079 let n = jobs.len();
7080 let originals: Vec<SeatJob> = jobs;
7081 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7082 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7083 let mut nudges: Vec<usize> = vec![0; n];
7090 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7094 let mut tried: Vec<BTreeSet<String>> = specs
7095 .iter()
7096 .map(|s| BTreeSet::from([s.id.clone()]))
7097 .collect();
7098 let starts: Vec<usize> = specs
7099 .iter()
7100 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7101 .collect();
7102 let carry = ctx.carry_seats && !roster.is_empty();
7103 let carried: Vec<BTreeSet<String>> = originals
7106 .iter()
7107 .map(|j| {
7108 state
7109 .seat_history
7110 .get(&j.seat.key)
7111 .filter(|_| carry)
7112 .map(|h| h.failed.clone())
7113 .unwrap_or_default()
7114 })
7115 .collect();
7116 let mut prev: Vec<Option<FailClass>> = originals
7117 .iter()
7118 .map(|j| {
7119 state
7120 .seat_history
7121 .get(&j.seat.key)
7122 .filter(|_| carry)
7123 .and_then(|h| h.last_fail.clone())
7124 })
7125 .collect();
7126 let next_agent = |i: usize, tried: &BTreeSet<String>| -> Option<AgentSpec> {
7127 if carry {
7128 next_for_seat(roster, starts[i], tried, &carried[i]).cloned()
7129 } else {
7130 next_untried_in_roster(roster, starts[i], tried).cloned()
7131 }
7132 };
7133 let mut fresh: Vec<Option<String>> = vec![None; n];
7134 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7135 let mut pending: Vec<usize> = (0..n).collect();
7136
7137 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7142 for round in 0..max_rounds {
7143 if pending.is_empty() {
7144 break;
7145 }
7146 let mut batch = Vec::with_capacity(pending.len());
7147 let mut renudged: Vec<&str> = Vec::new();
7148 for &i in &pending {
7149 let src = &originals[i];
7150 let full: &str = match &src.handover {
7155 Some(h) if specs[i].id != src.spec.id => h,
7156 _ => &src.prompt,
7157 };
7158 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7161 (
7162 full.to_owned(),
7163 src.timeout,
7164 format!("{}-{word}-{}", src.stem, specs[i].id),
7165 )
7166 } else if nudges[i] == 0 {
7167 (full.to_owned(), src.timeout, src.stem.clone())
7168 } else {
7169 renudged.push(src.seat.key.as_str());
7170 let why = done[i]
7171 .as_ref()
7172 .and_then(|r| r.as_ref().err().map(ToString::to_string))
7173 .unwrap_or_else(|| "no parsable answer".to_owned());
7174 let nudge = prompt::nudge(&why);
7175 let nudged = has_context(&specs[i], &seats[i], src.sessions);
7176 let prompt = if nudged {
7177 nudge
7178 } else {
7179 format!("{full}\n\n---\n\n{nudge}")
7180 };
7181 (
7182 prompt,
7183 retry_budget(src.timeout, nudged),
7184 format!("{}-retry{}", src.stem, nudges[i]),
7185 )
7186 };
7187 batch.push(SeatJob {
7188 spec: specs[i].clone(),
7189 seat: seats[i].clone(),
7190 cwd: src.cwd.clone(),
7191 prompt,
7192 timeout,
7193 allow_write: src.allow_write,
7194 sessions: src.sessions,
7195 artifacts: src.artifacts.clone(),
7196 stem,
7197 handover: None,
7198 });
7199 }
7200
7201 if !renudged.is_empty() {
7202 state.event(
7203 ctx.node,
7204 format!("retry {round}: re-asking {}", renudged.join(", ")),
7205 );
7206 }
7207 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7208 let mut still = Vec::new();
7209 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7210 seats[i] = seat;
7211 let class = FailClass::of(&out);
7212 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7215 && nudges[i] < retries
7216 && !roster.is_empty();
7217 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7218 let next = should_hand_over(prev[i].as_ref(), &cur)
7219 .then(|| next_agent(i, &tried[i]))
7220 .flatten();
7221 if carry {
7222 let h = state
7223 .seat_history
7224 .entry(originals[i].seat.key.clone())
7225 .or_default();
7226 h.failed.insert(specs[i].id.clone());
7227 h.last_fail = Some(cur.clone());
7228 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7229 h.last_ok = None;
7230 }
7231 if let Err(e) = state.save() {
7234 tracing::warn!("could not persist a seat's failure history: {e:#}");
7235 }
7236 }
7237 if let Some(next) = next {
7238 record_handover(
7239 state,
7240 ctx.node,
7241 &originals[i].seat.key,
7242 &specs[i].id,
7243 &next.id,
7244 &cur,
7245 &fail_reason(&out),
7246 );
7247 tried[i].insert(next.id.clone());
7248 prev[i] = Some(cur.clone());
7249 seats[i] =
7250 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7251 specs[i] = next;
7252 fresh[i] = Some(cur.stem_word().to_owned());
7253 nudges[i] = 0;
7254 done[i] = Some(Err(anyhow::anyhow!(
7255 "handed over after: {}",
7256 fail_reason(&out)
7257 )));
7258 still.push(i);
7259 continue;
7260 }
7261 }
7262 if carry
7263 && !nudge_first
7264 && let Some(cur) = class.clone()
7265 {
7266 let h = state
7268 .seat_history
7269 .entry(originals[i].seat.key.clone())
7270 .or_default();
7271 h.failed.insert(specs[i].id.clone());
7272 h.last_fail = Some(cur);
7273 }
7274 let parsed = match out {
7275 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7276 AgentOutcome::Quota(o) => {
7277 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7278 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7279 }
7280 AgentOutcome::Dropped(o) => {
7284 let why = o
7285 .dropped
7286 .as_ref()
7287 .map(|d| d.why.as_str())
7288 .unwrap_or("the CLI ended the stream without delivering its answer");
7289 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7290 }
7291 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7292 };
7293 let quota = class == Some(FailClass::Quota);
7294 let failed = parsed.is_err();
7295 done[i] = Some(parsed);
7296 if carry && !failed {
7297 let h = state
7298 .seat_history
7299 .entry(originals[i].seat.key.clone())
7300 .or_default();
7301 h.failed.remove(&specs[i].id);
7302 h.last_ok = Some(specs[i].id.clone());
7303 h.last_fail = None;
7304 }
7305 let agent_failure = class.is_some()
7313 && !nudge_first
7314 && !roster.is_empty()
7315 && next_agent(i, &tried[i]).is_some();
7316 if failed && !quota && !agent_failure && nudges[i] < retries {
7317 nudges[i] += 1;
7318 still.push(i);
7319 }
7320 }
7321 pending = still;
7322 }
7323
7324 for (i, q) in last_quota.into_iter().enumerate() {
7327 if let Some(reset) = q {
7328 losses.push(QuotaLoss {
7329 seat: originals[i].seat.key.clone(),
7330 node: ctx.node.to_owned(),
7331 at: Timestamp::now(),
7332 reset,
7333 });
7334 }
7335 }
7336
7337 seats
7338 .into_iter()
7339 .zip(done)
7340 .zip(nudges)
7341 .map(|((seat, res), attempts)| {
7342 (
7343 seat,
7344 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7345 attempts,
7346 )
7347 })
7348 .collect()
7349}
7350
7351async fn acquire_cache_lease(
7364 state: &mut RunState,
7365 cache_dir: &Path,
7366 owner: &crate::cache::Owner,
7367 budget: Duration,
7368 context: &str,
7369) -> Result<crate::cache::Guard> {
7370 let home = crate::run::home();
7371 let started = Instant::now();
7372 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7373 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7374 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7375 Err(e) => {
7376 state.event(
7377 "verify",
7378 format!("{context}: could not check the shared build cache: {e:#}"),
7379 );
7380 if let Err(e2) = state.save() {
7381 tracing::warn!("could not persist a cache-check failure: {e2:#}");
7382 }
7383 return Err(e);
7384 }
7385 };
7386 state.event(
7387 "verify",
7388 format!(
7389 "{context}: waiting for the shared build cache at {} ({})",
7390 cache_dir.display(),
7391 busy.describe()
7392 ),
7393 );
7394 if let Err(e) = state.save() {
7395 tracing::warn!("could not persist a cache wait: {e:#}");
7396 }
7397 let remaining = budget.saturating_sub(started.elapsed());
7398 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7399 Ok(g) => Ok(g),
7400 Err(e) => {
7401 state.event("verify", format!("{context}: {e:#}"));
7402 if let Err(e2) = state.save() {
7403 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7404 }
7405 Err(e)
7406 }
7407 }
7408}
7409
7410#[allow(clippy::too_many_arguments)]
7431async fn with_cache_lease<'s, F, Fut>(
7432 state: &'s mut RunState,
7433 cache_dir: Option<&Path>,
7434 node: &str,
7435 seat: &str,
7436 worktree: &Path,
7437 head: &str,
7438 budget: Duration,
7439 context: &str,
7440 body: F,
7441) -> (Vec<CommandOutcome>, bool)
7442where
7443 F: FnOnce(&'s mut RunState, Duration) -> Fut,
7444 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7445{
7446 let Some(cache_dir) = cache_dir else {
7447 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7448 return (outcomes, retried);
7449 };
7450 let home = crate::run::home();
7451 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7452 let started = Instant::now();
7453 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
7454 Ok(g) => g,
7455 Err(e) => {
7456 return (
7457 vec![CommandOutcome {
7458 command: "(waiting for the shared build cache)".to_owned(),
7459 code: None,
7460 output_tail: e.to_string(),
7461 duration_ms: started.elapsed().as_millis() as u64,
7462 resource_blocked: true,
7463 }],
7464 false,
7465 );
7466 }
7467 };
7468 let identity = crate::cache::Identity::new(worktree, head);
7469 if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
7470 state.event(
7478 "verify",
7479 format!(
7480 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
7481 worktree.display(),
7482 short(head)
7483 ),
7484 );
7485 guard.release();
7486 return (
7487 vec![CommandOutcome {
7488 command: "(confirming the shared build cache is fresh)".to_owned(),
7489 code: None,
7490 output_tail: e.to_string(),
7491 duration_ms: started.elapsed().as_millis() as u64,
7492 resource_blocked: true,
7493 }],
7494 false,
7495 );
7496 }
7497 let remaining = budget.saturating_sub(started.elapsed());
7498 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
7499 if !timed_out_pids.is_empty() {
7504 wait_for_timed_out_children_to_die(&timed_out_pids).await;
7505 }
7506 guard.release();
7507 (outcomes, retried)
7508}
7509
7510async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
7522 wait_for_pids_with(
7523 pids,
7524 crate::proc::pid_alive,
7525 LEASE_RELEASE_POLL,
7526 LEASE_RELEASE_MAX_WAIT,
7527 )
7528 .await;
7529}
7530
7531async fn wait_for_pids_with<F: Fn(u32) -> bool>(
7537 pids: &[u32],
7538 alive: F,
7539 poll: Duration,
7540 max_wait: Duration,
7541) {
7542 let deadline = Instant::now() + max_wait;
7543 loop {
7544 if pids.iter().all(|&pid| !alive(pid)) {
7545 return;
7546 }
7547 if Instant::now() >= deadline {
7548 return;
7549 }
7550 tokio::time::sleep(poll).await;
7551 }
7552}
7553
7554fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
7561 outcomes.iter().any(|o| o.resource_blocked)
7562}
7563
7564enum GateFix {
7566 Retry,
7568 Stop,
7571 Defer,
7574}
7575
7576fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
7584 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
7585 red.peek().is_some()
7586 && red.all(|o| {
7587 !o.resource_blocked
7588 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
7589 && !o.output_tail.trim().is_empty()
7590 })
7591}
7592
7593fn e2e_outcome_label(o: &CommandOutcome) -> String {
7597 if o.ok() {
7598 return "pass".to_owned();
7599 }
7600 let reason = if o.build_failed() {
7601 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
7602 } else {
7603 format!("FAIL ({:?})", o.code)
7604 };
7605 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
7606}
7607
7608async fn run_e2e_with_retry(
7616 state: &mut RunState,
7617 shell: &[String],
7618 commands: &[String],
7619 worktree: &Path,
7620 timeout: Duration,
7621 context: &str,
7622) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
7623 let (mut e2e, mut timed_out_pids) = run_commands(
7624 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
7625 )
7626 .await;
7627 for o in &e2e {
7628 state.event(
7629 "verify",
7630 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
7631 );
7632 }
7633 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
7637 if verify_retried {
7638 state.event(
7639 "verify",
7640 format!(
7641 "{context}: verify could not build/link, not a test result — retrying once \
7642 before concluding"
7643 ),
7644 );
7645 let retried = run_commands(
7646 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
7647 )
7648 .await;
7649 e2e = retried.0;
7650 timed_out_pids.extend(retried.1);
7653 for o in &e2e {
7654 state.event(
7655 "verify",
7656 format!(
7657 "{context}: retry `{}` -> {}",
7658 o.command,
7659 e2e_outcome_label(o)
7660 ),
7661 );
7662 }
7663 }
7664 (e2e, verify_retried, timed_out_pids)
7665}
7666
7667#[allow(clippy::too_many_arguments)]
7683async fn run_commands(
7684 state: &mut RunState,
7685 node: &str,
7686 task: &str,
7687 attempt: usize,
7688 shell: &[String],
7689 commands: &[String],
7690 cwd: &Path,
7691 timeout: Duration,
7692) -> (Vec<CommandOutcome>, Vec<u32>) {
7693 if commands.is_empty() {
7694 return (Vec::new(), Vec::new());
7699 }
7700 let mut out = Vec::new();
7701 let mut timed_out_pids = Vec::new();
7702 let total = commands.len();
7703 for (idx, command) in commands.iter().enumerate() {
7704 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
7705 if let Err(e) = state.save() {
7706 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
7707 }
7708 let started = Instant::now();
7709 let mut cmd = tokio::process::Command::new(&shell[0]);
7710 cmd.quiet();
7711 cmd.args(&shell[1..])
7712 .arg(command)
7713 .current_dir(cwd)
7714 .stdin(std::process::Stdio::null())
7715 .stdout(std::process::Stdio::piped())
7716 .stderr(std::process::Stdio::piped())
7717 .kill_on_drop(true);
7718 let spawned = cmd.spawn();
7719 let (code, body) = match spawned {
7720 Ok(child) => {
7721 let pid = child.id();
7726 match tokio::time::timeout(timeout, child.wait_with_output()).await {
7727 Ok(Ok(o)) => {
7728 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
7729 body.push_str(&String::from_utf8_lossy(&o.stderr));
7730 (o.status.code(), body)
7731 }
7732 Ok(Err(e)) => (None, format!("failed to run: {e}")),
7733 Err(_) => {
7734 if let Some(pid) = pid {
7735 timed_out_pids.push(pid);
7736 }
7737 (None, format!("timed out after {}s", timeout.as_secs()))
7738 }
7739 }
7740 }
7741 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
7742 };
7743 out.push(CommandOutcome {
7744 command: command.clone(),
7745 code,
7746 output_tail: tail(&body, OUTPUT_TAIL),
7747 duration_ms: started.elapsed().as_millis() as u64,
7748 resource_blocked: false,
7749 });
7750 }
7751 state.task_finished(task);
7752 if let Err(e) = state.save() {
7753 tracing::warn!("could not persist the end of {task}: {e:#}");
7754 }
7755 (out, timed_out_pids)
7756}
7757
7758fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
7770 let repo = repo.display();
7771 match style {
7772 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
7773 MergeStyle::Squash => {
7774 let subject = message
7777 .lines()
7778 .next()
7779 .unwrap_or(branch)
7780 .replace(['\\', '"', '$', '`'], "");
7781 format!(
7782 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
7783 )
7784 }
7785 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
7786 }
7787}
7788
7789const PR_TITLE_MAX: usize = 240;
7800
7801fn english_title(text: &str, max: usize) -> Option<String> {
7812 let line = queue::first_line(text)?;
7813 let chars: Vec<char> = line.chars().collect();
7814 let (kept, mark) = if chars.len() <= max {
7815 (line.to_owned(), "")
7816 } else if let Some(end) = sentence_end(&chars, max) {
7817 (chars[..end].iter().collect::<String>(), "")
7818 } else {
7819 let room = max.saturating_sub(3);
7820 let cut = if chars[room].is_whitespace() {
7823 room
7824 } else {
7825 chars[..room]
7826 .iter()
7827 .rposition(|c| c.is_whitespace())
7828 .unwrap_or(room)
7829 };
7830 let head: String = chars[..cut].iter().collect();
7831 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
7832 (head.to_owned(), "...")
7833 };
7834 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
7835 return None;
7836 }
7837 Some(format!("{kept}{mark}"))
7838}
7839
7840fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
7844 const MIN: usize = 20;
7845 for i in MIN..max.min(chars.len()) {
7846 if !matches!(chars[i], '.' | '!' | '?') {
7847 continue;
7848 }
7849 let Some(&next) = chars.get(i + 1) else {
7850 continue;
7851 };
7852 if !next.is_whitespace() {
7853 continue;
7854 }
7855 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
7856 if after.is_some_and(|c| c.is_ascii_lowercase()) {
7857 continue;
7858 }
7859 let word: String = chars[..i]
7860 .iter()
7861 .rev()
7862 .take_while(|c| !c.is_whitespace())
7863 .collect::<Vec<_>>()
7864 .into_iter()
7865 .rev()
7866 .collect();
7867 let word = word.to_ascii_lowercase();
7868 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
7869 continue;
7870 }
7871 let end = chars[..i]
7872 .iter()
7873 .rposition(|c| !c.is_whitespace())
7874 .map_or(i, |p| p + 1);
7875 return Some(end);
7876 }
7877 None
7878}
7879
7880struct PrMessage {
7885 title: String,
7886 body: String,
7887}
7888
7889impl PrMessage {
7890 fn commit_message(&self) -> String {
7894 format!("{}\n\n{}", self.title, self.body)
7895 }
7896}
7897
7898fn title_marker(line: &str) -> Option<&str> {
7900 let line = line.trim();
7901 let head = line.get(..6)?;
7902 head.eq_ignore_ascii_case("title:")
7903 .then(|| line[6..].trim())
7904}
7905
7906fn summary_title(summary: &str) -> Option<String> {
7911 let first = summary.lines().find(|l| !l.trim().is_empty())?;
7912 let raw = title_marker(first)?;
7913 if raw.is_empty() {
7914 return None;
7915 }
7916 let title = queue::title_from(raw, PR_TITLE_MAX);
7917 let lower = title.to_ascii_lowercase();
7918 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7919 return None;
7920 }
7921 Some(title)
7922}
7923
7924const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
7926
7927const EXISTING_BRANCH: &str = "(existing branch)";
7929
7930fn is_review_run(state: &RunState) -> bool {
7934 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
7935}
7936
7937fn review_title(state: &RunState) -> Option<String> {
7942 english_subject(state.reviewed_commits.as_ref()?.first()?)
7943}
7944
7945fn english_subject(raw: &str) -> Option<String> {
7948 let raw = raw.trim();
7949 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
7950 return None;
7951 }
7952 let title = queue::title_from(raw, PR_TITLE_MAX);
7953 let lower = title.to_ascii_lowercase();
7954 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7955 return None;
7956 }
7957 Some(title)
7958}
7959
7960#[derive(Debug, Clone, PartialEq, Eq)]
7963struct BranchFacts {
7964 commits: Vec<(String, String)>,
7966 stat: String,
7968}
7969
7970const STAT_FILE_LINES: usize = 25;
7972const COMMITS_MAX_CHARS: usize = 20_000;
7974
7975async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
7978 let commits = git::commit_log(repo, base, branch).await.ok()?;
7979 if commits.is_empty() {
7980 return None;
7981 }
7982 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
7983 let lines: Vec<&str> = stat.lines().collect();
7984 let stat = if lines.len() > STAT_FILE_LINES + 1 {
7985 let omitted = lines.len() - 1 - STAT_FILE_LINES;
7986 let more = format!(" ... {omitted} more file(s)");
7987 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
7988 kept.push(&more);
7989 kept.push(lines[lines.len() - 1]);
7990 kept.join("\n")
7991 } else {
7992 lines.join("\n")
7993 };
7994 Some(BranchFacts { commits, stat })
7995}
7996
7997fn markdown_safe(text: &str) -> String {
8000 text.replace("</details>", "</details>")
8001 .replace("\x60\x60\x60", "~~~")
8002}
8003
8004fn neutral_title(state: &RunState, winner: char) -> String {
8005 format!(
8006 "chore: land candidate {} of run {}",
8007 winner.to_ascii_uppercase(),
8008 state.id
8009 )
8010}
8011
8012pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8018 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8019 ""
8020 } else {
8021 pr_title
8022 }
8023}
8024
8025pub fn landing_subject_source(state: &RunState) -> String {
8029 if is_review_run(state) {
8030 let winner = state.candidates.first().map_or('A', |c| c.label);
8031 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8032 }
8033 state.instruction.clone()
8034}
8035
8036fn summary_without_title(summary: &str) -> String {
8039 let mut lines = summary.trim().lines().peekable();
8040 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8041 lines.next();
8042 }
8043 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8044}
8045
8046#[cfg(test)]
8060fn pr_message(state: &RunState, winner: char) -> PrMessage {
8061 pr_message_with(state, winner, None)
8062}
8063
8064fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8067 let summary = state
8068 .candidates
8069 .iter()
8070 .find(|c| c.label == winner)
8071 .map(|c| c.summary.as_str())
8072 .unwrap_or_default();
8073 let review = is_review_run(state);
8076 let title = if review {
8077 facts
8078 .and_then(|f| english_subject(&f.commits.first()?.0))
8079 .or_else(|| review_title(state))
8080 .or_else(|| {
8081 state
8082 .candidates
8083 .iter()
8084 .find(|c| c.label == winner)
8085 .filter(|c| !c.branch.starts_with("magi/"))
8086 .and_then(|c| english_subject(&c.branch))
8087 })
8088 .unwrap_or_else(|| neutral_title(state, winner))
8089 } else {
8090 summary_title(summary).unwrap_or_else(|| {
8091 english_title(&state.instruction, PR_TITLE_MAX)
8092 .unwrap_or_else(|| neutral_title(state, winner))
8093 })
8094 };
8095
8096 let mut body = String::new();
8097 let what = summary_without_title(summary);
8098 if !what.is_empty() {
8099 body.push_str("## Summary\n\n");
8100 body.push_str(&what);
8101 body.push_str("\n\n");
8102 }
8103
8104 let notes: Vec<(usize, &str)> = state
8107 .reviews
8108 .iter()
8109 .filter_map(|r| {
8110 let n = r.fix.as_ref()?.notes.trim();
8111 (!n.is_empty()).then_some((r.round, n))
8112 })
8113 .collect();
8114 if !notes.is_empty() {
8115 body.push_str("## Review fixes\n\n");
8116 if let [(_, only)] = notes.as_slice() {
8117 body.push_str(only);
8118 body.push_str("\n\n");
8119 } else {
8120 for (round, n) in ¬es {
8121 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8122 }
8123 }
8124 }
8125 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8126
8127 let open = state.open_findings();
8128 if !open.is_empty() {
8129 body.push_str("## Open review findings\n\n");
8130 for f in &open {
8131 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8132 }
8133 body.push('\n');
8134 }
8135
8136 if let Some(fix) = fix
8137 && !fix.rejected.is_empty()
8138 {
8139 body.push_str("## Declined by the fixer\n\n");
8140 for r in &fix.rejected {
8141 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8142 }
8143 body.push('\n');
8144 }
8145
8146 if review {
8147 body.push_str("## Commits under review\n\n");
8149 if let Some(facts) = facts {
8150 let mut left = COMMITS_MAX_CHARS;
8151 for (i, (subject, text)) in facts.commits.iter().enumerate() {
8152 let mut entry = format!("- {}\n", markdown_safe(subject));
8153 for l in markdown_safe(text).lines() {
8154 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
8155 }
8156 if left == 0 {
8157 body.push_str(&format!(
8158 "- ... {} more commit(s)\n",
8159 facts.commits.len() - i
8160 ));
8161 break;
8162 }
8163 if entry.len() > left {
8164 let mut end = left;
8167 while !entry.is_char_boundary(end) {
8168 end -= 1;
8169 }
8170 entry.truncate(end);
8171 entry.push_str("\n ... (truncated)\n");
8172 left = 0;
8173 } else {
8174 left -= entry.len();
8175 }
8176 body.push_str(&entry);
8177 }
8178 if !facts.stat.trim().is_empty() {
8179 body.push_str(&format!(
8180 "\n## Diff stat\n\n```\n{}\n```\n",
8181 markdown_safe(facts.stat.trim())
8182 ));
8183 }
8184 } else {
8185 match &state.reviewed_commits {
8186 Some(subjects) => {
8187 for s in subjects {
8188 body.push_str(&format!("- {}\n", s.trim()));
8189 }
8190 }
8191 None => {
8192 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8195 body.push_str(rest.trim());
8196 body.push('\n');
8197 }
8198 }
8199 }
8200 } else {
8201 let task = state.instruction.trim();
8202 let task = if task.is_empty() {
8203 "(empty task)"
8204 } else {
8205 task
8206 };
8207 body.push_str(&format!(
8208 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8209 task.replace("</details>", "</details>")
8210 ));
8211 }
8212
8213 body.push_str(&format!(
8214 "\n---\nmagi:run/{} magi:candidate-{}\n",
8215 state.id,
8216 winner.to_ascii_lowercase()
8217 ));
8218
8219 let id = crate::scrub::Identity::current();
8222 PrMessage {
8223 title: crate::scrub::scrub(&title, &id),
8224 body: crate::scrub::scrub(&body, &id),
8225 }
8226}
8227
8228fn seeded_instruction(state: &RunState) -> String {
8232 match refs::describe(&state.seeds) {
8233 Some(facts) => format!(
8234 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8235 Candidates start from the unmerged branch named above, when there \
8236 is one, and carry any unmerged commit named by sha as a \
8237 cherry-pick. Check that this is what the task meant before \
8238 building on it.",
8239 state.instruction
8240 ),
8241 None => state.instruction.clone(),
8242 }
8243}
8244
8245async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8249 let base = &state.base_branch;
8250 let mut against = base.clone();
8251 if mode == MergeMode::Pr {
8252 let remote = &state.config.merge.remote;
8256 let tracking = format!("{remote}/{base}");
8257 let fetched = git::fetch(repo, remote, base).await;
8258 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8259 against = tracking;
8260 } else {
8261 return false;
8262 }
8263 }
8264 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8265}
8266
8267fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8270 let mut detail = format!(
8271 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8272 nothing to open a pull request for"
8273 );
8274 match refs::describe(&state.seeds) {
8275 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8276 None => detail.push_str(
8277 "\nThe task names no existing branch or commit; if it means to land work \
8278 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8279 ),
8280 }
8281 detail
8282}
8283
8284#[derive(Debug, PartialEq, Eq)]
8287enum PrPlan {
8288 Create,
8289 Adopt { url: String, title: String },
8290 Stop(String),
8291}
8292
8293fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8296 match found {
8297 Ok(land::OpenPr::None) => PrPlan::Create,
8298 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8299 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8300 "several open pull requests exist for this branch, not picking one: {}",
8301 urls.join(" ")
8302 )),
8303 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8304 }
8305}
8306
8307async fn gh_pr_create(
8309 cwd: &Path,
8310 base: &str,
8311 head: &str,
8312 title: &str,
8313 body: &str,
8314) -> Result<String> {
8315 let out = tokio::process::Command::new("gh")
8316 .args([
8317 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8318 ])
8319 .current_dir(cwd)
8320 .quiet()
8321 .stdin(std::process::Stdio::null())
8322 .output()
8323 .await
8324 .context("spawn gh")?;
8325 if out.status.success() {
8326 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8327 } else {
8328 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8329 }
8330}
8331
8332pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8341 let repo = state.repo.clone();
8342 let root = state.worktree_root();
8343 let winner = state.tally.as_ref().map(|t| t.winner);
8344 let mut removed = Vec::new();
8345
8346 for i in 0..state.candidates.len() {
8347 let c = state.candidates[i].clone();
8348 let is_winner = Some(c.label) == winner;
8349 if is_winner && !drop_winner {
8350 continue;
8351 }
8352 if c.worktree.exists() {
8353 git::worktree_remove(&repo, &c.worktree).await.ok();
8354 removed.push(c.worktree.to_string_lossy().into_owned());
8355 }
8356 let handed_over = state.released_branches.contains(&c.branch);
8359 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8360 git::branch_delete(&repo, &c.branch).await.ok();
8361 removed.push(c.branch.clone());
8362 }
8363 state.candidates[i].folded = true;
8364 }
8365
8366 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8367 let path = name.path();
8368 let keep = !drop_winner
8369 && winner.is_some_and(|w| {
8370 path.file_name()
8371 .is_some_and(|n| n == format!("cand-{w}").as_str())
8372 });
8373 if keep {
8374 continue;
8375 }
8376 git::worktree_remove(&repo, &path).await.ok();
8377 removed.push(path.to_string_lossy().into_owned());
8378 }
8379
8380 remove_if_empty(&root);
8389
8390 if state.enabled_worktree_config && drop_winner {
8391 git::release_worktree_config(&repo).await.ok();
8395 state.enabled_worktree_config = false;
8396 }
8397 state.save_under(home)?;
8398 Ok(removed)
8399}
8400
8401fn remove_if_empty(dir: &Path) {
8412 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
8413 std::fs::remove_dir(dir).ok();
8414 }
8415}
8416
8417pub fn worst_open(state: &RunState) -> Option<Severity> {
8419 state
8420 .reviews
8421 .last()?
8422 .reviews
8423 .iter()
8424 .flat_map(|r| r.findings.iter())
8425 .map(|f| f.severity)
8426 .max()
8427}
8428
8429#[cfg(test)]
8430mod tests {
8431 #[test]
8432 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
8433 let leaked = vec!["chore(deps): update a crate".to_owned()];
8434 let own = "fix(daemon): apply a chosen action";
8435 assert!(should_retitle("", own, &leaked));
8436 assert!(should_retitle(
8437 &format!("{REVIEW_PROMPT_OPENING} `x`"),
8438 own,
8439 &leaked
8440 ));
8441 assert!(should_retitle(
8442 "chore: land candidate A of run 1",
8443 own,
8444 &leaked
8445 ));
8446 assert!(should_retitle(
8447 "magi: candidate A (uncommitted work)",
8448 own,
8449 &leaked
8450 ));
8451 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
8452 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
8453 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
8454 }
8455
8456 #[test]
8457 fn pr_merge_plan_creates_adopts_or_stops() {
8458 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
8459 assert_eq!(
8460 pr_merge_plan(Ok(land::OpenPr::One {
8461 url: "u".into(),
8462 title: "t".into()
8463 })),
8464 PrPlan::Adopt {
8465 url: "u".into(),
8466 title: "t".into()
8467 }
8468 );
8469 let PrPlan::Stop(many) =
8470 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
8471 else {
8472 panic!("many must stop");
8473 };
8474 assert!(many.contains('a') && many.contains('b'));
8475 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
8476 panic!("a failed lookup must stop");
8477 };
8478 assert!(err.contains("bad token"));
8479 }
8480
8481 use super::*;
8482 use crate::run::GateStatus;
8483 use std::collections::BTreeMap;
8484 use std::time::Duration;
8485
8486 fn conductor() -> AgentSpec {
8487 AgentSpec {
8488 id: "conductor".to_owned(),
8489 kind: crate::config::AgentKind::Command,
8490 model: None,
8491 command: vec!["true".to_owned()],
8492 extra_args: Vec::new(),
8493 env: BTreeMap::new(),
8494 prompt_delivery: None,
8495 }
8496 }
8497
8498 fn spec(id: &str) -> AgentSpec {
8499 AgentSpec {
8500 id: id.to_owned(),
8501 kind: crate::config::AgentKind::Command,
8502 model: None,
8503 command: vec!["true".to_owned()],
8504 extra_args: Vec::new(),
8505 env: BTreeMap::new(),
8506 prompt_delivery: None,
8507 }
8508 }
8509
8510 fn ids(xs: &[&str]) -> BTreeSet<String> {
8511 xs.iter().map(|s| (*s).to_owned()).collect()
8512 }
8513
8514 #[test]
8515 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
8516 let roster = [spec("a"), spec("b"), spec("c")];
8517 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
8518 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8519 }
8520
8521 #[test]
8522 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
8523 let roster = [spec("a"), spec("b"), spec("c")];
8524 let failed = ids(&["a", "b", "c"]);
8525 let mut tried = ids(&["b"]);
8527 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8528 assert_eq!(first.id, "a");
8529 tried.insert(first.id.clone());
8530 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8531 assert_eq!(second.id, "c");
8532 tried.insert(second.id.clone());
8533 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
8534 }
8535
8536 #[test]
8537 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
8538 let roster = [spec("a"), spec("b")];
8539 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
8540 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8541 }
8542
8543 #[test]
8544 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
8545 let roster = [spec("a"), spec("b"), spec("c")];
8546 let h = SeatHistory {
8547 failed: ids(&["a"]),
8548 last_ok: Some("b".to_owned()),
8549 last_fail: None,
8550 };
8551 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
8552 let gone = SeatHistory {
8554 last_ok: Some("zzz".to_owned()),
8555 ..h.clone()
8556 };
8557 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
8558 let failed = SeatHistory {
8559 failed: ids(&["a", "b"]),
8560 last_ok: Some("b".to_owned()),
8561 last_fail: None,
8562 };
8563 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
8564 }
8565
8566 #[test]
8567 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
8568 let first = SeatState::new("review-1", "alpha", 7);
8569 let next = handover_seat("review-1", "gamma", 7);
8570 assert_ne!(first.claude_session, next.claude_session);
8571 }
8572
8573 #[test]
8574 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
8575 let mut state = state_with_summary("x", "y");
8576 let a = handover_seat("review-1", "beta", state.next_seat_seed());
8577 let b = handover_seat("review-1", "beta", state.next_seat_seed());
8578 assert_ne!(a.claude_session, b.claude_session);
8579 }
8580
8581 #[test]
8582 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
8583 let roster = [spec("a"), spec("b")];
8584 let h = SeatHistory {
8585 failed: ids(&["a", "b"]),
8586 ..SeatHistory::default()
8587 };
8588 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
8589 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
8590 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
8591 }
8592
8593 #[test]
8600 fn failure_signature_ignores_numbers_and_paths() {
8601 assert_eq!(
8602 failure_signature("exited with Some(2) and no usable output"),
8603 failure_signature("exited with Some(137) and no usable output")
8604 );
8605 assert_eq!(
8606 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
8607 failure_signature("cannot open /var/x.txt: denied")
8608 );
8609 assert_ne!(failure_signature("boom"), failure_signature("bang"));
8610 }
8611
8612 #[test]
8613 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
8614 let other = FailClass::Other("x".into());
8615 assert!(should_hand_over(None, &FailClass::Quota));
8616 assert!(should_hand_over(Some(&other), &FailClass::Quota));
8617 assert!(should_hand_over(
8618 Some(&FailClass::Timeout),
8619 &FailClass::Timeout
8620 ));
8621 assert!(should_hand_over(None, &other));
8622 assert!(!should_hand_over(Some(&other), &other));
8623 assert!(should_hand_over(
8624 Some(&other),
8625 &FailClass::Other("y".into())
8626 ));
8627 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
8629 assert!(should_hand_over(Some(&FailClass::Quota), &other));
8630 }
8631
8632 #[test]
8633 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
8634 let a = SeatState::new("judge-1", "alpha", 7);
8635 let b = handover_seat("judge-1", "beta", 7);
8636 assert_ne!(a.claude_session, b.claude_session);
8637 assert_eq!(b.turns, 0);
8638 }
8639
8640 #[test]
8641 fn a_timeout_is_classified_apart_from_other_failures() {
8642 assert_eq!(
8643 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
8644 Some(FailClass::Timeout)
8645 );
8646 assert!(matches!(
8647 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
8648 Some(FailClass::Other(_))
8649 ));
8650 }
8651
8652 #[test]
8653 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
8654 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8655 let tried = BTreeSet::from(["beta".to_owned()]);
8656 let next = next_untried_in_roster(&roster, 1, &tried);
8659 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
8660 }
8661
8662 #[test]
8663 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
8664 let roster = vec![spec("alpha"), spec("beta")];
8665 let tried = BTreeSet::from(["beta".to_owned()]);
8666 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8670 }
8671
8672 #[test]
8673 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
8674 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8675 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
8676 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8680 }
8681
8682 #[test]
8683 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
8684 let roster = vec![spec("a"), spec("a"), spec("b")];
8685 let tried = BTreeSet::from(["a".to_owned()]);
8686 let next = next_untried_in_roster(&roster, 0, &tried);
8687 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8688 }
8689
8690 #[test]
8691 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
8692 let roster = vec![spec("a"), spec("b")];
8693 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
8694 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
8695 }
8696
8697 #[test]
8698 fn remove_if_empty_only_ever_takes_a_bare_directory() {
8699 let dir = tempfile::tempdir().unwrap();
8700 let bay = dir.path().join("ffff");
8701
8702 remove_if_empty(&bay);
8704 assert!(!bay.exists());
8705
8706 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
8709 remove_if_empty(&bay);
8710 assert!(bay.exists(), "non-empty directory must survive");
8711
8712 std::fs::remove_dir(bay.join("cand-A")).unwrap();
8714 remove_if_empty(&bay);
8715 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
8716 }
8717
8718 #[test]
8727 fn a_full_panel_that_found_nothing_is_clean() {
8728 assert!(round_is_clean(
8729 0,
8730 true,
8731 2,
8732 2,
8733 0,
8734 IncompleteReviewPolicy::Block
8735 ));
8736 }
8737
8738 #[test]
8739 fn a_missing_seat_is_never_clean_under_the_default_policy() {
8740 assert!(!round_is_clean(
8741 0,
8742 true,
8743 1,
8744 2,
8745 0,
8746 IncompleteReviewPolicy::Block
8747 ));
8748 }
8749
8750 #[test]
8751 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
8752 assert!(!round_is_clean(
8753 1,
8754 true,
8755 1,
8756 2,
8757 0,
8758 IncompleteReviewPolicy::Warn
8759 ));
8760 }
8761
8762 #[test]
8763 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
8764 assert!(round_is_clean(
8765 0,
8766 true,
8767 1,
8768 2,
8769 0,
8770 IncompleteReviewPolicy::Warn
8771 ));
8772 }
8773
8774 #[test]
8775 fn a_full_panel_with_an_open_finding_is_not_clean() {
8776 assert!(!round_is_clean(
8777 1,
8778 true,
8779 2,
8780 2,
8781 0,
8782 IncompleteReviewPolicy::Block
8783 ));
8784 }
8785
8786 #[test]
8787 fn a_full_panel_with_a_red_e2e_is_not_clean() {
8788 assert!(!round_is_clean(
8789 0,
8790 false,
8791 2,
8792 2,
8793 0,
8794 IncompleteReviewPolicy::Block
8795 ));
8796 }
8797
8798 #[test]
8805 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
8806 assert!(round_is_clean(
8809 0,
8810 true,
8811 1,
8812 2,
8813 1,
8814 IncompleteReviewPolicy::Block
8815 ));
8816 }
8817
8818 #[test]
8819 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
8820 assert!(!round_is_clean(
8823 0,
8824 true,
8825 1,
8826 2,
8827 0,
8828 IncompleteReviewPolicy::Block
8829 ));
8830 }
8831
8832 #[test]
8833 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
8834 assert!(!round_is_clean(
8835 1,
8836 true,
8837 1,
8838 2,
8839 1,
8840 IncompleteReviewPolicy::Block
8841 ));
8842 assert!(!round_is_clean(
8843 0,
8844 false,
8845 1,
8846 2,
8847 1,
8848 IncompleteReviewPolicy::Block
8849 ));
8850 }
8851
8852 #[test]
8853 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
8854 assert!(!round_is_clean(
8858 0,
8859 true,
8860 0,
8861 2,
8862 2,
8863 IncompleteReviewPolicy::Block
8864 ));
8865 }
8866
8867 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
8868 CommandOutcome {
8869 command: "test".to_owned(),
8870 code,
8871 output_tail: String::new(),
8872 duration_ms: 0,
8873 resource_blocked,
8874 }
8875 }
8876
8877 #[test]
8878 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
8879 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
8880 assert!(
8881 !verify_inconclusive(&[outcome(Some(1), false)]),
8882 "an ordinary failure is still evidence about the patch"
8883 );
8884 assert!(verify_inconclusive(&[outcome(None, true)]));
8885 assert!(
8886 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
8887 "one inconclusive outcome taints the whole batch"
8888 );
8889 assert!(!verify_inconclusive(&[]));
8890 }
8891
8892 #[tokio::test]
8893 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
8894 let calls = std::sync::atomic::AtomicUsize::new(0);
8898 let started = Instant::now();
8899 wait_for_pids_with(
8900 &[123],
8901 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
8902 Duration::from_millis(5),
8903 Duration::from_secs(5),
8904 )
8905 .await;
8906 assert!(
8907 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
8908 "must keep checking rather than deciding on the first answer"
8909 );
8910 assert!(
8911 started.elapsed() < Duration::from_secs(1),
8912 "must return the moment it is confirmed dead, not wait out the ceiling"
8913 );
8914 }
8915
8916 #[tokio::test]
8917 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
8918 let started = Instant::now();
8919 wait_for_pids_with(
8920 &[123],
8921 |_| true, Duration::from_millis(5),
8923 Duration::from_millis(30),
8924 )
8925 .await;
8926 let elapsed = started.elapsed();
8927 assert!(
8928 elapsed >= Duration::from_millis(30),
8929 "must not give up before its own ceiling: {elapsed:?}"
8930 );
8931 assert!(
8932 elapsed < Duration::from_secs(1),
8933 "must not wait past its own ceiling either: {elapsed:?}"
8934 );
8935 }
8936
8937 #[tokio::test]
8938 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
8939 let started = Instant::now();
8940 wait_for_pids_with(
8941 &[],
8942 |_| true,
8943 Duration::from_secs(5),
8944 Duration::from_secs(5),
8945 )
8946 .await;
8947 assert!(
8948 started.elapsed() < Duration::from_millis(200),
8949 "an empty pid list has nothing to confirm"
8950 );
8951 }
8952
8953 fn review_round(
8959 clean: bool,
8960 blocking: usize,
8961 answered: usize,
8962 expected: usize,
8963 progressed: bool,
8964 e2e_ok: bool,
8965 ) -> ReviewRound {
8966 ReviewRound {
8967 round: 1,
8968 head: "h".to_owned(),
8969 verified_head: None,
8970 verified_at: None,
8971 reviews: Vec::new(),
8972 e2e: vec![CommandOutcome {
8973 command: "test".to_owned(),
8974 code: Some(if e2e_ok { 0 } else { 1 }),
8975 output_tail: String::new(),
8976 duration_ms: 0,
8977 resource_blocked: false,
8978 }],
8979 verify_retried: false,
8980 e2e_deferred: false,
8981 e2e_defer_reason: None,
8982 fix: None,
8983 blocking,
8984 answered,
8985 expected,
8986 clean,
8987 progressed,
8988 vote_split: false,
8989 reconsideration: Vec::new(),
8990 verdict: None,
8991 }
8992 }
8993
8994 #[test]
8995 fn review_conclusion_is_none_when_nothing_has_run() {
8996 assert_eq!(review_conclusion(&[], 3), None);
8997 }
8998
8999 #[test]
9000 fn review_conclusion_is_none_while_rounds_remain() {
9001 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9002 assert_eq!(review_conclusion(&rounds, 3), None);
9003 }
9004
9005 #[test]
9006 fn review_conclusion_is_gating_once_a_round_is_clean() {
9007 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9008 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9009 }
9010
9011 #[test]
9012 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9013 let rounds = vec![
9014 review_round(false, 1, 2, 2, true, true),
9015 review_round(false, 1, 2, 2, true, true),
9016 ];
9017 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9018 }
9019
9020 #[test]
9021 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9022 let rounds = vec![
9023 review_round(false, 1, 2, 2, true, true),
9024 review_round(false, 1, 2, 2, true, false),
9025 ];
9026 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9027 }
9028
9029 #[test]
9030 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9031 let mut blocked = review_round(false, 1, 2, 2, true, false);
9038 blocked.e2e[0].resource_blocked = true;
9039 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9040 assert_eq!(review_conclusion(&rounds, 2), None);
9041 }
9042
9043 #[test]
9044 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9045 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9047 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9048 }
9049
9050 #[test]
9051 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9052 let rounds = vec![
9053 review_round(false, 1, 2, 2, false, true),
9054 review_round(false, 1, 2, 2, false, true),
9055 ];
9056 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9057 }
9058
9059 fn secs(n: u64) -> Duration {
9060 Duration::from_secs(n)
9061 }
9062
9063 fn init_repo(dir: &Path) {
9066 let run = |args: &[&str]| {
9067 let out = std::process::Command::new("git")
9068 .args(args)
9069 .current_dir(dir)
9070 .quiet()
9071 .output()
9072 .expect("spawn git");
9073 assert!(
9074 out.status.success(),
9075 "git {args:?} failed: {}",
9076 String::from_utf8_lossy(&out.stderr)
9077 );
9078 };
9079 run(&["init", "-b", "main"]);
9080 run(&["config", "user.name", "magi test"]);
9081 run(&["config", "user.email", "magi@example.com"]);
9082 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9083 run(&["add", "-A"]);
9084 run(&["commit", "-m", "init"]);
9085 }
9086
9087 fn ask_test_home() {
9095 crate::run::pin_test_home();
9096 }
9097
9098 fn runner_at(status: RunStatus) -> Runner {
9101 let mut state = RunState::new(
9102 PathBuf::from("/nonexistent/repo"),
9103 "main".to_owned(),
9104 "deadbeef".to_owned(),
9105 "task".to_owned(),
9106 Config::default(),
9107 );
9108 state.status = status;
9109 Runner {
9110 state,
9111 roles: ResolvedRoles {
9112 implementers: Vec::new(),
9113 judges: Vec::new(),
9114 reviewers: Vec::new(),
9115 fixer: None,
9116 conductor: conductor(),
9117 implementer_roster: Vec::new(),
9118 judge_roster: Vec::new(),
9119 reviewer_roster: Vec::new(),
9120 },
9121 sem: Arc::new(Semaphore::new(1)),
9122 pause: Pause::new(),
9123 interrupt: Pause::new(),
9124 }
9125 }
9126
9127 #[test]
9131 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9132 crate::run::pin_test_home();
9133 let mut runner = runner_at(RunStatus::Implementing);
9134 let interrupt = Pause::new();
9135 runner.watch_interrupt(interrupt.clone());
9136
9137 interrupt.park_because("task a1b2 asked to run first");
9138
9139 assert!(runner.park_here().expect("park_here"));
9140 assert!(runner.state.parked);
9141 let last = runner.state.events.last().expect("a park event");
9142 assert_eq!(last.node, "park");
9143 assert!(
9144 last.message.contains("task a1b2 asked to run first"),
9145 "expected the interrupt reason in {:?}",
9146 last.message
9147 );
9148 }
9149
9150 #[test]
9158 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9159 crate::run::pin_test_home();
9160 let mut runner = runner_at(RunStatus::Implementing);
9161 let shutdown = Pause::new();
9162 runner.on_pause(shutdown.clone());
9163 let interrupt = Pause::new();
9164 runner.watch_interrupt(interrupt.clone());
9165
9166 assert!(!runner.park_here().expect("park_here"));
9168 assert!(!runner.state.parked);
9169
9170 interrupt.park_because("test");
9172 assert!(!shutdown.parked());
9173 assert!(runner.park_here().expect("park_here"));
9174 }
9175
9176 #[tokio::test]
9190 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9191 crate::run::pin_test_home();
9192 let mut runner = runner_at(RunStatus::Implementing);
9193 let interrupt = Pause::new();
9194 runner.watch_interrupt(interrupt.clone());
9195
9196 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9197 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9198
9199 let node = async move {
9203 started_tx.send(()).expect("send started");
9204 finish_rx.await.expect("recv finish");
9205 "node finished"
9206 };
9207
9208 let interrupter = async move {
9209 started_rx.await.expect("recv started");
9210 interrupt.park_because("higher-priority task waiting");
9212 tokio::task::yield_now().await;
9216 finish_tx.send(()).expect("send finish");
9217 };
9218
9219 let (node_result, ()) = tokio::join!(node, interrupter);
9220 assert_eq!(
9221 node_result, "node finished",
9222 "the in-flight call ran to completion"
9223 );
9224
9225 assert!(runner.park_here().expect("park_here"));
9228 assert!(runner.state.parked);
9229 }
9230
9231 #[test]
9237 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9238 crate::run::pin_test_home();
9239 let mut runner = runner_at(RunStatus::Judging);
9240 runner.state.config.agents = vec![conductor()];
9244 runner.state.candidates = vec![Candidate {
9245 index: 0,
9246 label: 'A',
9247 agent: "alpha".to_owned(),
9248 branch: "magi/x/A".to_owned(),
9249 worktree: PathBuf::from("/nonexistent/worktree"),
9250 summary: "did the thing".to_owned(),
9251 stat: "1 file changed".to_owned(),
9252 files: 1,
9253 commits: 1,
9254 empty: false,
9255 failed: None,
9256 verified_noop: None,
9257 duration_ms: 1234,
9258 folded: false,
9259 }];
9260 let run_id = runner.state.id.clone();
9261
9262 let interrupt = Pause::new();
9263 runner.watch_interrupt(interrupt.clone());
9264 interrupt.park_because("task c3d4 asked to run first");
9265 assert!(runner.park_here().expect("park_here"));
9266
9267 let resumed = Runner::resume(&run_id).expect("resume");
9268 assert_eq!(resumed.state.candidates.len(), 1);
9269 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9270 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9271 assert_eq!(resumed.state.status, runner.state.status);
9272 assert!(
9273 resumed.state.parked,
9274 "still parked until `execute` actually walks the graph again"
9275 );
9276 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9277 }
9278
9279 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9281 let mut q = ask::Question::new(
9282 run.to_owned(),
9283 "implement".to_owned(),
9284 "impl-A".to_owned(),
9285 "Which storage backend should the cache use?".to_owned(),
9286 String::new(),
9287 vec!["SQLite".to_owned(), "Redis".to_owned()],
9288 );
9289 store.put(&mut q).unwrap();
9290 q
9291 }
9292
9293 #[test]
9294 fn a_failed_runs_open_question_is_abandoned() {
9295 ask_test_home();
9296 let store = ask::Questions::open();
9297 let mut runner = runner_at(RunStatus::Failed);
9298 let run = runner.state.id.clone();
9299 let q = ask_open_question(&store, &run);
9300
9301 runner.settle_questions();
9302
9303 let back = store.get(&q.id).unwrap();
9304 assert!(
9305 !back.status.open(),
9306 "the seat that asked died with the run; nobody is left to read an answer"
9307 );
9308 assert!(
9309 back.detail.contains(&run) && back.detail.contains("failed"),
9310 "the reason names what the run became, not just that it is gone: {}",
9311 back.detail
9312 );
9313 }
9314
9315 #[test]
9316 fn a_merged_runs_open_question_is_abandoned_too() {
9317 ask_test_home();
9318 let store = ask::Questions::open();
9319 for status in [RunStatus::Merged, RunStatus::Ready] {
9322 let mut runner = runner_at(status);
9323 let run = runner.state.id.clone();
9324 let q = ask_open_question(&store, &run);
9325
9326 runner.settle_questions();
9327
9328 let back = store.get(&q.id).unwrap();
9329 assert!(
9330 !back.status.open(),
9331 "{status:?} run's question must not outlive the run"
9332 );
9333 }
9334 }
9335
9336 #[test]
9337 fn a_still_resumable_runs_open_question_is_left_alone() {
9338 ask_test_home();
9339 let store = ask::Questions::open();
9340 for status in [RunStatus::Blocked, RunStatus::Stalled] {
9346 let mut runner = runner_at(status);
9347 let run = runner.state.id.clone();
9348 let q = ask_open_question(&store, &run);
9349
9350 runner.settle_questions();
9351
9352 let back = store.get(&q.id).unwrap();
9353 assert!(
9354 back.status.open(),
9355 "{status:?} is still alive; the question must still be waiting"
9356 );
9357 }
9358 }
9359
9360 #[test]
9361 fn settle_questions_never_touches_an_already_answered_question() {
9362 ask_test_home();
9363 let store = ask::Questions::open();
9364 let mut runner = runner_at(RunStatus::Failed);
9365 let run = runner.state.id.clone();
9366 let mut q = ask_open_question(&store, &run);
9367 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
9368 .unwrap();
9369 store.put(&mut q).unwrap();
9370
9371 runner.settle_questions();
9376 runner.settle_questions();
9377
9378 let back = store.get(&q.id).unwrap();
9379 assert_eq!(
9380 back.status,
9381 ask::QuestionStatus::Answered,
9382 "a real answer is a decision on record, never overwritten by a sweep"
9383 );
9384 }
9385
9386 #[tokio::test]
9397 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
9398 crate::run::pin_test_home();
9399 let tmp = tempfile::tempdir().expect("tempdir");
9400 let repo = tmp.path().join("repo");
9401 std::fs::create_dir_all(&repo).unwrap();
9402 init_repo(&repo);
9403
9404 let mut config = Config::default();
9405 config.graph.worktree_root = Some(tmp.path().join("wt"));
9406
9407 let mut state = RunState::new(
9408 repo.clone(),
9409 "main".to_owned(),
9410 "deadbeef".to_owned(),
9411 "task".to_owned(),
9412 config,
9413 );
9414 let root = state.worktree_root();
9415 let wt_a = root.join("cand-A");
9416 let wt_b = root.join("cand-B");
9417 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
9418 .await
9419 .expect("worktree A");
9420 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
9421 .await
9422 .expect("worktree B");
9423
9424 state.candidates = vec![
9425 Candidate {
9426 index: 0,
9427 label: 'A',
9428 agent: "alpha".to_owned(),
9429 branch: "magi/x/A".to_owned(),
9430 worktree: wt_a.clone(),
9431 summary: String::new(),
9432 stat: String::new(),
9433 files: 0,
9434 commits: 0,
9435 empty: false,
9436 failed: None,
9437 verified_noop: None,
9438 duration_ms: 0,
9439 folded: false,
9440 },
9441 Candidate {
9442 index: 1,
9443 label: 'B',
9444 agent: "beta".to_owned(),
9445 branch: "magi/x/B".to_owned(),
9446 worktree: wt_b.clone(),
9447 summary: String::new(),
9448 stat: String::new(),
9449 files: 0,
9450 commits: 0,
9451 empty: false,
9452 failed: None,
9453 verified_noop: None,
9454 duration_ms: 0,
9455 folded: false,
9456 },
9457 ];
9458 state.tally = Some(Tally {
9459 first_choice: BTreeMap::from([('A', 1)]),
9460 borda: BTreeMap::new(),
9461 winner: 'A',
9462 rankings: 1,
9463 unanimous_initial: true,
9464 deliberated: false,
9465 changed_votes: 0,
9466 unanimous_final: true,
9467 tie_break: None,
9468 judges: 1,
9469 present: 1,
9470 quorum: 1,
9471 met_quorum: true,
9472 uncontested: None,
9473 });
9474 state.status = RunStatus::Ready;
9475
9476 fold_run(&mut state, false, &crate::run::home())
9477 .await
9478 .expect("fold_run");
9479
9480 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
9481 assert!(
9482 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9483 "the unmerged winner's branch survives"
9484 );
9485 assert!(
9486 !state.candidates[0].folded,
9487 "the winner is not marked folded"
9488 );
9489
9490 assert!(!wt_b.exists(), "the loser's worktree is removed");
9491 assert!(
9492 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
9493 "the loser's branch is removed"
9494 );
9495 assert!(state.candidates[1].folded, "the loser is marked folded");
9496 }
9497
9498 #[tokio::test]
9501 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
9502 let tmp = tempfile::tempdir().expect("tempdir");
9503 let repo = tmp.path().join("repo");
9504 std::fs::create_dir_all(&repo).unwrap();
9505 init_repo(&repo);
9506 let home = tmp.path().join("home");
9507
9508 let mut config = Config::default();
9509 config.graph.worktree_root = Some(tmp.path().join("wt"));
9510 let mut state = RunState::new(
9511 repo.clone(),
9512 "main".to_owned(),
9513 "deadbeef".to_owned(),
9514 "task".to_owned(),
9515 config,
9516 );
9517 git::git(&repo, &["branch", "magi/x/A", "main"])
9519 .await
9520 .expect("branch");
9521 state.candidates = vec![Candidate {
9522 index: 0,
9523 label: 'A',
9524 agent: "alpha".to_owned(),
9525 branch: "magi/x/A".to_owned(),
9526 worktree: state.worktree_root().join("cand-A"),
9527 summary: String::new(),
9528 stat: String::new(),
9529 files: 0,
9530 commits: 0,
9531 empty: false,
9532 failed: None,
9533 verified_noop: None,
9534 duration_ms: 0,
9535 folded: true,
9536 }];
9537 state.released_to = Some("20260901-000000-new1".to_owned());
9538 state.released_branches = vec!["magi/x/A".to_owned()];
9539
9540 fold_run(&mut state, true, &home).await.expect("fold_run");
9541
9542 assert!(
9543 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9544 "the handed-over branch survives a fold"
9545 );
9546 }
9547
9548 #[tokio::test]
9552 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
9553 let tmp = tempfile::tempdir().expect("tempdir");
9554 let repo = tmp.path().join("repo");
9555 std::fs::create_dir_all(&repo).unwrap();
9556 init_repo(&repo);
9557 let run = |args: &[&str]| {
9558 let out = std::process::Command::new("git")
9559 .quiet()
9560 .args(args)
9561 .current_dir(&repo)
9562 .output()
9563 .expect("spawn git");
9564 assert!(out.status.success(), "git {args:?}");
9565 };
9566 run(&["branch", "magi/x/A"]);
9567 run(&["checkout", "-q", "-b", "magi/x/B"]);
9568 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
9569 run(&["add", "-A"]);
9570 run(&["commit", "-q", "-m", "work"]);
9571 run(&["checkout", "-q", "main"]);
9572
9573 let probe = RunState::new(
9577 repo.clone(),
9578 "main".to_owned(),
9579 "deadbeef".to_owned(),
9580 "task".to_owned(),
9581 Config::default(),
9582 );
9583 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
9584 let bare = tmp.path().join("origin.git");
9585 let out = std::process::Command::new("git")
9586 .quiet()
9587 .args(["init", "-q", "--bare"])
9588 .arg(&bare)
9589 .output()
9590 .expect("spawn git");
9591 assert!(out.status.success(), "git init --bare");
9592 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
9593 run(&["push", "-q", "origin", "main"]);
9594
9595 let mut state = RunState::new(
9596 repo.clone(),
9597 "main".to_owned(),
9598 "deadbeef".to_owned(),
9599 "task".to_owned(),
9600 Config::default(),
9601 );
9602 state.seeds = vec![refs::Seed {
9603 token: "magi/27b2/A".to_owned(),
9604 kind: refs::SeedKind::Unresolved,
9605 sha: String::new(),
9606 branch: true,
9607 detail: "no branch or commit named magi/27b2/A".to_owned(),
9608 }];
9609
9610 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
9611 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
9612 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
9613 let detail = empty_candidate_detail(&state, "main");
9614 assert!(detail.starts_with("empty candidate"), "{detail}");
9615 assert!(detail.contains("magi/27b2/A"), "{detail}");
9616 }
9617
9618 #[tokio::test]
9627 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
9628 let tmp = tempfile::tempdir().expect("tempdir");
9629 let repo = tmp.path().join("repo");
9630 std::fs::create_dir_all(&repo).unwrap();
9631 init_repo(&repo);
9632
9633 let mut config = Config::default();
9634 config.merge.mode = MergeMode::Local;
9635
9636 let mut state = RunState::new(
9637 repo.clone(),
9638 "main".to_owned(),
9639 "deadbeef".to_owned(),
9640 "task".to_owned(),
9641 config,
9642 );
9643 state.candidates = vec![Candidate {
9644 index: 0,
9645 label: 'A',
9646 agent: "alpha".to_owned(),
9647 branch: "does-not-exist".to_owned(),
9648 worktree: repo.clone(),
9649 summary: String::new(),
9650 stat: String::new(),
9651 files: 0,
9652 commits: 0,
9653 empty: false,
9654 failed: None,
9655 verified_noop: None,
9656 duration_ms: 0,
9657 folded: false,
9658 }];
9659 state.tally = Some(Tally {
9660 first_choice: BTreeMap::from([('A', 1)]),
9661 borda: BTreeMap::new(),
9662 winner: 'A',
9663 rankings: 1,
9664 unanimous_initial: true,
9665 deliberated: false,
9666 changed_votes: 0,
9667 unanimous_final: true,
9668 tie_break: None,
9669 judges: 0,
9670 present: 0,
9671 quorum: 0,
9672 met_quorum: true,
9673 uncontested: Some("only candidate A produced a change".to_owned()),
9674 });
9675 state.reviews = vec![ReviewRound {
9676 round: 1,
9677 head: "deadbeef".to_owned(),
9678 verified_head: None,
9679 verified_at: None,
9680 reviews: Vec::new(),
9681 e2e: Vec::new(),
9682 fix: None,
9683 blocking: 0,
9684 answered: 0,
9685 expected: 0,
9686 clean: true,
9687 verify_retried: false,
9688 e2e_deferred: false,
9689 e2e_defer_reason: None,
9690 progressed: false,
9691 vote_split: false,
9692 reconsideration: Vec::new(),
9693 verdict: None,
9694 }];
9695 state.gate = vec![CommandOutcome {
9696 command: "test".to_owned(),
9697 code: Some(0),
9698 output_tail: String::new(),
9699 duration_ms: 0,
9700 resource_blocked: false,
9701 }];
9702 state.gate_ran = true;
9703 state.status = RunStatus::Ready;
9708 state.merge = Some(MergeOutcome {
9709 mode: MergeMode::Local,
9710 ok: false,
9711 detail: "already concluded".to_owned(),
9712 empty: false,
9713 });
9714
9715 let mut runner = Runner {
9716 state,
9717 roles: ResolvedRoles {
9718 implementers: Vec::new(),
9719 judges: Vec::new(),
9720 reviewers: Vec::new(),
9721 fixer: None,
9722 conductor: conductor(),
9723 implementer_roster: Vec::new(),
9724 judge_roster: Vec::new(),
9725 reviewer_roster: Vec::new(),
9726 },
9727 sem: Arc::new(Semaphore::new(1)),
9728 pause: Pause::new(),
9729 interrupt: Pause::new(),
9730 };
9731
9732 runner.merge().await.expect("merge");
9733
9734 assert_eq!(
9735 runner.state.status,
9736 RunStatus::Ready,
9737 "a concluded run's status must not change on reentry"
9738 );
9739 assert_eq!(
9740 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
9741 Some("already concluded"),
9742 "merge must not run again once the node already recorded an outcome"
9743 );
9744 }
9745
9746 #[tokio::test]
9755 async fn merge_refuses_a_gate_that_has_not_actually_run() {
9756 let tmp = tempfile::tempdir().expect("tempdir");
9757 let repo = tmp.path().join("repo");
9758 std::fs::create_dir_all(&repo).unwrap();
9759 init_repo(&repo);
9760
9761 let mut config = Config::default();
9762 config.merge.mode = MergeMode::Local;
9763
9764 let mut state = RunState::new(
9765 repo.clone(),
9766 "main".to_owned(),
9767 "deadbeef".to_owned(),
9768 "task".to_owned(),
9769 config,
9770 );
9771 state.candidates = vec![Candidate {
9772 index: 0,
9773 label: 'A',
9774 agent: "alpha".to_owned(),
9775 branch: "does-not-exist".to_owned(),
9776 worktree: repo.clone(),
9777 summary: String::new(),
9778 stat: String::new(),
9779 files: 0,
9780 commits: 0,
9781 empty: false,
9782 failed: None,
9783 verified_noop: None,
9784 duration_ms: 0,
9785 folded: false,
9786 }];
9787 state.tally = Some(Tally {
9788 first_choice: BTreeMap::from([('A', 1)]),
9789 borda: BTreeMap::new(),
9790 winner: 'A',
9791 rankings: 1,
9792 unanimous_initial: true,
9793 deliberated: false,
9794 changed_votes: 0,
9795 unanimous_final: true,
9796 tie_break: None,
9797 judges: 0,
9798 present: 0,
9799 quorum: 0,
9800 met_quorum: true,
9801 uncontested: Some("only candidate A produced a change".to_owned()),
9802 });
9803 state.reviews = vec![ReviewRound {
9804 round: 1,
9805 head: "deadbeef".to_owned(),
9806 verified_head: None,
9807 verified_at: None,
9808 reviews: Vec::new(),
9809 e2e: Vec::new(),
9810 fix: None,
9811 blocking: 0,
9812 answered: 0,
9813 expected: 0,
9814 clean: true,
9815 verify_retried: false,
9816 e2e_deferred: false,
9817 e2e_defer_reason: None,
9818 progressed: false,
9819 vote_split: false,
9820 reconsideration: Vec::new(),
9821 verdict: None,
9822 }];
9823 state.gate = Vec::new();
9825 state.gate_ran = false;
9826 state.status = RunStatus::Gating;
9827
9828 let mut runner = Runner {
9829 state,
9830 roles: ResolvedRoles {
9831 implementers: Vec::new(),
9832 judges: Vec::new(),
9833 reviewers: Vec::new(),
9834 fixer: None,
9835 conductor: conductor(),
9836 implementer_roster: Vec::new(),
9837 judge_roster: Vec::new(),
9838 reviewer_roster: Vec::new(),
9839 },
9840 sem: Arc::new(Semaphore::new(1)),
9841 pause: Pause::new(),
9842 interrupt: Pause::new(),
9843 };
9844
9845 runner.merge().await.expect("merge");
9846
9847 assert!(
9848 runner.state.merge.is_none(),
9849 "an empty gate must never be read as a passing one: {:?}",
9850 runner.state.merge
9851 );
9852 }
9853
9854 #[tokio::test]
9861 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
9862 let tmp = tempfile::tempdir().expect("tempdir");
9863 let repo = tmp.path().join("repo");
9864 std::fs::create_dir_all(&repo).unwrap();
9865 init_repo(&repo);
9866
9867 let config = Config::default();
9869
9870 let mut state = RunState::new(
9871 repo.clone(),
9872 "main".to_owned(),
9873 "deadbeef".to_owned(),
9874 "task".to_owned(),
9875 config,
9876 );
9877 state.candidates = vec![Candidate {
9878 index: 0,
9879 label: 'A',
9880 agent: "alpha".to_owned(),
9881 branch: "does-not-exist".to_owned(),
9882 worktree: repo.clone(),
9883 summary: String::new(),
9884 stat: String::new(),
9885 files: 0,
9886 commits: 0,
9887 empty: false,
9888 failed: None,
9889 verified_noop: None,
9890 duration_ms: 0,
9891 folded: false,
9892 }];
9893 state.tally = Some(Tally {
9894 first_choice: BTreeMap::from([('A', 1)]),
9895 borda: BTreeMap::new(),
9896 winner: 'A',
9897 rankings: 1,
9898 unanimous_initial: true,
9899 deliberated: false,
9900 changed_votes: 0,
9901 unanimous_final: true,
9902 tie_break: None,
9903 judges: 0,
9904 present: 0,
9905 quorum: 0,
9906 met_quorum: true,
9907 uncontested: Some("only candidate A produced a change".to_owned()),
9908 });
9909 state.reviews = vec![ReviewRound {
9910 round: 1,
9911 head: "deadbeef".to_owned(),
9912 verified_head: None,
9913 verified_at: None,
9914 reviews: Vec::new(),
9915 e2e: Vec::new(),
9916 fix: None,
9917 blocking: 0,
9918 answered: 0,
9919 expected: 0,
9920 clean: true,
9921 verify_retried: false,
9922 e2e_deferred: false,
9923 e2e_defer_reason: None,
9924 progressed: false,
9925 vote_split: false,
9926 reconsideration: Vec::new(),
9927 verdict: None,
9928 }];
9929
9930 let mut runner = Runner {
9931 state,
9932 roles: ResolvedRoles {
9933 implementers: Vec::new(),
9934 judges: Vec::new(),
9935 reviewers: Vec::new(),
9936 fixer: None,
9937 conductor: conductor(),
9938 implementer_roster: Vec::new(),
9939 judge_roster: Vec::new(),
9940 reviewer_roster: Vec::new(),
9941 },
9942 sem: Arc::new(Semaphore::new(1)),
9943 pause: Pause::new(),
9944 interrupt: Pause::new(),
9945 };
9946
9947 runner.gate().await.expect("gate");
9948 assert!(
9949 runner.state.gate_ran,
9950 "zero configured commands is still a real attempt, not an unrun gate"
9951 );
9952 assert!(runner.state.gate.is_empty());
9953 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
9954 assert_ne!(
9955 runner.state.status,
9956 RunStatus::Blocked,
9957 "a gate with nothing to check must not read as failed"
9958 );
9959
9960 runner.merge().await.expect("merge");
9961 assert_eq!(
9962 runner.state.status,
9963 RunStatus::Ready,
9964 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
9965 );
9966 }
9967
9968 #[tokio::test]
9979 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
9980 crate::run::pin_test_home();
9981 let home = crate::run::home();
9982
9983 let tmp = tempfile::tempdir().expect("tempdir");
9984 let repo = tmp.path().join("repo");
9985 std::fs::create_dir_all(&repo).unwrap();
9986 init_repo(&repo);
9987 let cache_dir = tmp.path().join("target");
9990
9991 let mut config = Config::default();
9992 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
9993 config.graph.timeout_verify = Some(2);
9996
9997 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
9998 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
9999 .expect("no io error acquiring directly")
10000 {
10001 crate::cache::AcquireOutcome::Acquired(g) => g,
10002 crate::cache::AcquireOutcome::Busy(b) => {
10003 panic!("expected the direct acquire to win the lease first: {b:?}")
10004 }
10005 };
10006
10007 let mut state = RunState::new(
10008 repo.clone(),
10009 "main".to_owned(),
10010 "deadbeef".to_owned(),
10011 "task".to_owned(),
10012 config,
10013 );
10014 state.candidates = vec![Candidate {
10015 index: 0,
10016 label: 'A',
10017 agent: "alpha".to_owned(),
10018 branch: "does-not-exist".to_owned(),
10019 worktree: repo.clone(),
10020 summary: String::new(),
10021 stat: String::new(),
10022 files: 0,
10023 commits: 0,
10024 empty: false,
10025 failed: None,
10026 verified_noop: None,
10027 duration_ms: 0,
10028 folded: false,
10029 }];
10030 state.tally = Some(Tally {
10031 first_choice: BTreeMap::from([('A', 1)]),
10032 borda: BTreeMap::new(),
10033 winner: 'A',
10034 rankings: 1,
10035 unanimous_initial: true,
10036 deliberated: false,
10037 changed_votes: 0,
10038 unanimous_final: true,
10039 tie_break: None,
10040 judges: 0,
10041 present: 0,
10042 quorum: 0,
10043 met_quorum: true,
10044 uncontested: Some("only candidate A produced a change".to_owned()),
10045 });
10046 state.reviews = vec![ReviewRound {
10047 round: 1,
10048 head: "deadbeef".to_owned(),
10049 verified_head: None,
10050 verified_at: None,
10051 reviews: Vec::new(),
10052 e2e: Vec::new(),
10053 fix: None,
10054 blocking: 0,
10055 answered: 0,
10056 expected: 0,
10057 clean: true,
10058 verify_retried: false,
10059 e2e_deferred: false,
10060 e2e_defer_reason: None,
10061 progressed: false,
10062 vote_split: false,
10063 reconsideration: Vec::new(),
10064 verdict: None,
10065 }];
10066
10067 let mut runner = Runner {
10068 state,
10069 roles: ResolvedRoles {
10070 implementers: Vec::new(),
10071 judges: Vec::new(),
10072 reviewers: Vec::new(),
10073 fixer: None,
10074 conductor: conductor(),
10075 implementer_roster: Vec::new(),
10076 judge_roster: Vec::new(),
10077 reviewer_roster: Vec::new(),
10078 },
10079 sem: Arc::new(Semaphore::new(1)),
10080 pause: Pause::new(),
10081 interrupt: Pause::new(),
10082 };
10083
10084 let started = std::time::Instant::now();
10085 runner.gate().await.expect("gate");
10086 assert!(
10087 started.elapsed() < Duration::from_secs(1),
10088 "a gate with nothing to run must never wait on a lease it never needed"
10089 );
10090 assert!(
10091 runner.state.gate_ran,
10092 "zero commands is still a real, immediate attempt"
10093 );
10094 assert!(runner.state.gate.is_empty());
10095 assert_ne!(
10096 runner.state.status,
10097 RunStatus::Blocked,
10098 "must not read as resource-blocked on a lease it never asked for"
10099 );
10100 }
10101
10102 #[tokio::test]
10112 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10113 crate::run::pin_test_home();
10114
10115 let tmp = tempfile::tempdir().expect("tempdir");
10116 let repo = tmp.path().join("repo");
10117 std::fs::create_dir_all(&repo).unwrap();
10118 init_repo(&repo);
10119
10120 let mut config = Config::default();
10121 config.verify.gate = vec![
10122 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10123 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10124 .to_owned(),
10125 ];
10126
10127 let mut state = RunState::new(
10128 repo.clone(),
10129 "main".to_owned(),
10130 "deadbeef".to_owned(),
10131 "task".to_owned(),
10132 config,
10133 );
10134 let run_id = state.id.clone();
10135 state.candidates = vec![Candidate {
10136 index: 0,
10137 label: 'A',
10138 agent: "alpha".to_owned(),
10139 branch: "does-not-exist".to_owned(),
10140 worktree: repo.clone(),
10141 summary: String::new(),
10142 stat: String::new(),
10143 files: 0,
10144 commits: 0,
10145 empty: false,
10146 failed: None,
10147 verified_noop: None,
10148 duration_ms: 0,
10149 folded: false,
10150 }];
10151 state.tally = Some(Tally {
10152 first_choice: BTreeMap::from([('A', 1)]),
10153 borda: BTreeMap::new(),
10154 winner: 'A',
10155 rankings: 1,
10156 unanimous_initial: true,
10157 deliberated: false,
10158 changed_votes: 0,
10159 unanimous_final: true,
10160 tie_break: None,
10161 judges: 0,
10162 present: 0,
10163 quorum: 0,
10164 met_quorum: true,
10165 uncontested: Some("only candidate A produced a change".to_owned()),
10166 });
10167 state.reviews = vec![ReviewRound {
10168 round: 1,
10169 head: "deadbeef".to_owned(),
10170 verified_head: None,
10171 verified_at: None,
10172 reviews: Vec::new(),
10173 e2e: Vec::new(),
10174 fix: None,
10175 blocking: 0,
10176 answered: 0,
10177 expected: 0,
10178 clean: true,
10179 verify_retried: false,
10180 e2e_deferred: false,
10181 e2e_defer_reason: None,
10182 progressed: false,
10183 vote_split: false,
10184 reconsideration: Vec::new(),
10185 verdict: None,
10186 }];
10187
10188 let mut runner = Runner {
10189 state,
10190 roles: ResolvedRoles {
10191 implementers: Vec::new(),
10192 judges: Vec::new(),
10193 reviewers: Vec::new(),
10194 fixer: None,
10195 conductor: conductor(),
10196 implementer_roster: Vec::new(),
10197 judge_roster: Vec::new(),
10198 reviewer_roster: Vec::new(),
10199 },
10200 sem: Arc::new(Semaphore::new(1)),
10201 pause: Pause::new(),
10202 interrupt: Pause::new(),
10203 };
10204
10205 let started_marker = repo.join("started.marker");
10206 let release_marker = repo.join("release.marker");
10207 let poller = tokio::spawn(async move {
10208 for _ in 0..100 {
10213 if started_marker.exists()
10214 && let Ok(s) = crate::run::RunState::load(&run_id)
10215 && let Some(a) = s.active.get("gate")
10216 {
10217 std::fs::write(&release_marker, b"go").expect("release marker");
10218 return Some(a.clone());
10219 }
10220 tokio::time::sleep(Duration::from_millis(50)).await;
10221 }
10222 None
10223 });
10224
10225 runner.gate().await.expect("gate");
10226 let captured = poller.await.expect("poller task");
10227 let captured = captured.expect(
10228 "the poller never saw a `gate` task entry in run.json while the command was \
10229 still blocked on its own release marker",
10230 );
10231
10232 assert_eq!(captured.task.as_deref(), Some("gate"));
10233 assert_eq!(captured.node, "gate");
10234 assert_eq!(captured.index, Some(1));
10235 assert_eq!(captured.total, Some(1));
10236 assert!(
10237 captured
10238 .command
10239 .as_deref()
10240 .is_some_and(|c| c.contains("started.marker")),
10241 "{captured:?}"
10242 );
10243
10244 assert!(
10245 runner.state.active.is_empty(),
10246 "the entry must be cleared once the command actually finished: {:?}",
10247 runner.state.active
10248 );
10249 assert!(runner.state.gate_ran);
10250 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10251 }
10252
10253 #[tokio::test]
10256 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10257 use crate::verdict::{Finding, ReviewVote, Severity};
10258 crate::run::pin_test_home();
10259 let tmp = tempfile::tempdir().expect("tempdir");
10260 let repo = tmp.path().join("repo");
10261 std::fs::create_dir_all(&repo).unwrap();
10262 init_repo(&repo);
10263
10264 for (severity, vote, expect) in [
10265 (Severity::Major, ReviewVote::Reject, true),
10266 (Severity::Minor, ReviewVote::Reject, false),
10267 (Severity::Major, ReviewVote::Approve, false),
10268 ] {
10269 let mut round = review_round(false, 1, 1, 1, false, true);
10270 round.reviews = vec![ReviewRecord {
10271 reviewer: 1,
10272 agent: "alpha".to_owned(),
10273 summary: String::new(),
10274 findings: vec![Finding {
10275 id: "R1-1-1".to_owned(),
10276 severity,
10277 file: None,
10278 line: None,
10279 title: "t".to_owned(),
10280 detail: String::new(),
10281 }],
10282 vote: Some(vote),
10283 failed: None,
10284 duration_ms: 0,
10285 attempts: 0,
10286 }];
10287 let mut state = RunState::new(
10288 repo.clone(),
10289 "main".to_owned(),
10290 "deadbeef".to_owned(),
10291 "task".to_owned(),
10292 Config::default(),
10293 );
10294 state.reviews = vec![round];
10295 let mut runner = Runner {
10296 state,
10297 roles: ResolvedRoles {
10298 implementers: Vec::new(),
10299 judges: Vec::new(),
10300 reviewers: Vec::new(),
10301 fixer: None,
10302 conductor: conductor(),
10303 implementer_roster: Vec::new(),
10304 judge_roster: Vec::new(),
10305 reviewer_roster: Vec::new(),
10306 },
10307 sem: Arc::new(Semaphore::new(1)),
10308 pause: Pause::new(),
10309 interrupt: Pause::new(),
10310 };
10311 let shell = runner.state.config.shell();
10312 runner
10313 .stop_reviewing("round budget spent", &shell, &repo)
10314 .await
10315 .expect("stop_reviewing");
10316 assert_eq!(runner.state.status, RunStatus::Gating);
10317 assert_eq!(
10318 runner.state.contested_handoff.is_some(),
10319 expect,
10320 "{severity:?} + {vote:?}"
10321 );
10322 }
10323 }
10324
10325 #[tokio::test]
10338 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10339 crate::run::pin_test_home();
10340 let home = crate::run::home();
10341
10342 let tmp = tempfile::tempdir().expect("tempdir");
10343 let repo = tmp.path().join("repo");
10344 std::fs::create_dir_all(&repo).unwrap();
10345 init_repo(&repo);
10346 let head = crate::git::rev_parse(&repo, "HEAD")
10347 .await
10348 .expect("rev-parse");
10349 let cache_dir = tmp.path().join("target");
10352
10353 let mut config = Config::default();
10354 config.verify.e2e = vec![format!(
10355 "CARGO_TARGET_DIR='{}' test -f README.md",
10356 cache_dir.display()
10357 )];
10358 config.graph.review_rounds = 1;
10359 config.graph.timeout_verify = Some(2);
10362
10363 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10364 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10365 .expect("no io error acquiring directly")
10366 {
10367 crate::cache::AcquireOutcome::Acquired(g) => g,
10368 crate::cache::AcquireOutcome::Busy(b) => {
10369 panic!("expected the direct acquire to win the lease first: {b:?}")
10370 }
10371 };
10372
10373 let mut state = RunState::new(
10374 repo.clone(),
10375 "main".to_owned(),
10376 head.clone(),
10377 "task".to_owned(),
10378 config,
10379 );
10380 state.candidates = vec![Candidate {
10381 index: 0,
10382 label: 'A',
10383 agent: "alpha".to_owned(),
10384 branch: "does-not-exist".to_owned(),
10385 worktree: repo.clone(),
10386 summary: String::new(),
10387 stat: String::new(),
10388 files: 0,
10389 commits: 0,
10390 empty: false,
10391 failed: None,
10392 verified_noop: None,
10393 duration_ms: 0,
10394 folded: false,
10395 }];
10396 state.tally = Some(Tally {
10397 first_choice: BTreeMap::from([('A', 1)]),
10398 borda: BTreeMap::new(),
10399 winner: 'A',
10400 rankings: 1,
10401 unanimous_initial: true,
10402 deliberated: false,
10403 changed_votes: 0,
10404 unanimous_final: true,
10405 tie_break: None,
10406 judges: 0,
10407 present: 0,
10408 quorum: 0,
10409 met_quorum: true,
10410 uncontested: Some("only candidate A produced a change".to_owned()),
10411 });
10412 state.reviews = vec![ReviewRound {
10416 round: 1,
10417 head: head.clone(),
10418 verified_head: None,
10419 verified_at: None,
10420 reviews: Vec::new(),
10421 e2e: Vec::new(),
10422 fix: None,
10423 blocking: 1,
10424 answered: 1,
10425 expected: 1,
10426 clean: false,
10427 verify_retried: false,
10428 e2e_deferred: true,
10429 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
10430 progressed: false,
10431 vote_split: false,
10432 reconsideration: Vec::new(),
10433 verdict: None,
10434 }];
10435
10436 let mut runner = Runner {
10437 state,
10438 roles: ResolvedRoles {
10439 implementers: Vec::new(),
10440 judges: Vec::new(),
10441 reviewers: Vec::new(),
10442 fixer: None,
10443 conductor: conductor(),
10444 implementer_roster: Vec::new(),
10445 judge_roster: Vec::new(),
10446 reviewer_roster: Vec::new(),
10447 },
10448 sem: Arc::new(Semaphore::new(1)),
10449 pause: Pause::new(),
10450 interrupt: Pause::new(),
10451 };
10452
10453 let shell = runner.state.config.shell();
10454 runner
10455 .stop_reviewing("round budget spent", &shell, &repo)
10456 .await
10457 .expect("stop_reviewing");
10458
10459 let last = runner.state.reviews.last().expect("round record");
10460 assert_eq!(
10461 last.e2e_status(),
10462 E2eStatus::ResourceBlocked,
10463 "the shared cache is still held; the attempt must read as blocked, not deferred or \
10464 failed: {last:?}"
10465 );
10466 assert_eq!(
10467 last.verified_head.as_deref(),
10468 Some(head.as_str()),
10469 "which commit this attempt targeted is known even though nothing finished checking \
10470 it"
10471 );
10472 let first_attempt_at = last
10473 .verified_at
10474 .expect("when this attempt ran is known too");
10475 assert_ne!(
10476 runner.state.status,
10477 RunStatus::Blocked,
10478 "contention is evidence about the machine, not the patch — it must not settle the \
10479 run as blocked: {:?}",
10480 runner.state.status
10481 );
10482 assert!(
10483 !runner
10484 .state
10485 .events
10486 .iter()
10487 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
10488 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
10489 runner.state.events
10490 );
10491
10492 runner
10497 .stop_reviewing("round budget spent", &shell, &repo)
10498 .await
10499 .expect("stop_reviewing retry");
10500 assert_eq!(
10501 runner.state.reviews.len(),
10502 1,
10503 "no new round was started: {:?}",
10504 runner.state.reviews
10505 );
10506 let last = runner.state.reviews.last().expect("round record");
10507 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
10508 assert!(
10509 last.verified_at.expect("still known") > first_attempt_at,
10510 "a second reentry must be a fresh attempt, not a stale copy of the first"
10511 );
10512 assert_ne!(runner.state.status, RunStatus::Blocked);
10513
10514 held.release();
10515 }
10516
10517 #[tokio::test]
10529 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
10530 crate::run::pin_test_home();
10531 let home = crate::run::home();
10532
10533 let tmp = tempfile::tempdir().expect("tempdir");
10534 let repo = tmp.path().join("repo");
10535 std::fs::create_dir_all(&repo).unwrap();
10536 init_repo(&repo);
10537 let head = crate::git::rev_parse(&repo, "HEAD")
10538 .await
10539 .expect("rev-parse");
10540 let cache_dir = tmp.path().join("target");
10541
10542 let mut config = Config::default();
10543 config.verify.e2e = vec![format!(
10544 "CARGO_TARGET_DIR='{}' test -f README.md",
10545 cache_dir.display()
10546 )];
10547 config.graph.review_rounds = 1;
10548 config.graph.timeout_verify = Some(2);
10549
10550 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10551 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10552 .expect("no io error acquiring directly")
10553 {
10554 crate::cache::AcquireOutcome::Acquired(g) => g,
10555 crate::cache::AcquireOutcome::Busy(b) => {
10556 panic!("expected the direct acquire to win the lease first: {b:?}")
10557 }
10558 };
10559
10560 let mut state = RunState::new(
10561 repo.clone(),
10562 "main".to_owned(),
10563 head.clone(),
10564 "task".to_owned(),
10565 config,
10566 );
10567 state.candidates = vec![Candidate {
10568 index: 0,
10569 label: 'A',
10570 agent: "alpha".to_owned(),
10571 branch: "does-not-exist".to_owned(),
10572 worktree: repo.clone(),
10573 summary: String::new(),
10574 stat: String::new(),
10575 files: 0,
10576 commits: 0,
10577 empty: false,
10578 failed: None,
10579 verified_noop: None,
10580 duration_ms: 0,
10581 folded: false,
10582 }];
10583 state.tally = Some(Tally {
10584 first_choice: BTreeMap::from([('A', 1)]),
10585 borda: BTreeMap::new(),
10586 winner: 'A',
10587 rankings: 1,
10588 unanimous_initial: true,
10589 deliberated: false,
10590 changed_votes: 0,
10591 unanimous_final: true,
10592 tie_break: None,
10593 judges: 0,
10594 present: 0,
10595 quorum: 0,
10596 met_quorum: true,
10597 uncontested: Some("only candidate A produced a change".to_owned()),
10598 });
10599 state.reviews = vec![ReviewRound {
10603 round: 1,
10604 head: head.clone(),
10605 verified_head: Some(head.clone()),
10606 verified_at: Some(jiff::Timestamp::now()),
10607 reviews: Vec::new(),
10608 e2e: vec![CommandOutcome {
10609 command: format!(
10610 "CARGO_TARGET_DIR='{}' test -f README.md",
10611 cache_dir.display()
10612 ),
10613 code: None,
10614 output_tail: "waiting for the shared build cache".to_owned(),
10615 duration_ms: 0,
10616 resource_blocked: true,
10617 }],
10618 fix: None,
10619 blocking: 1,
10620 answered: 1,
10621 expected: 1,
10622 clean: false,
10623 verify_retried: false,
10624 e2e_deferred: false,
10625 e2e_defer_reason: None,
10626 progressed: false,
10627 vote_split: false,
10628 reconsideration: Vec::new(),
10629 verdict: None,
10630 }];
10631
10632 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
10633 let mut runner = Runner {
10634 state,
10635 roles: ResolvedRoles {
10636 implementers: Vec::new(),
10637 judges: Vec::new(),
10638 reviewers: Vec::new(),
10639 fixer: None,
10640 conductor: conductor(),
10641 implementer_roster: Vec::new(),
10642 judge_roster: Vec::new(),
10643 reviewer_roster: Vec::new(),
10644 },
10645 sem: Arc::new(Semaphore::new(1)),
10646 pause: Pause::new(),
10647 interrupt: Pause::new(),
10648 };
10649
10650 runner.review_loop().await.expect("review_loop");
10655
10656 assert_eq!(
10657 runner.state.reviews.len(),
10658 1,
10659 "no new round was started on top of the unresolved one: {:?}",
10660 runner.state.reviews
10661 );
10662 let last = &runner.state.reviews[0];
10663 assert_eq!(
10664 last.e2e_status(),
10665 E2eStatus::ResourceBlocked,
10666 "still contended: {last:?}"
10667 );
10668 assert!(
10669 last.verified_at.expect("still known") > first_attempt_at,
10670 "review_loop must have actually retried the check, not left it exactly as found"
10671 );
10672 assert_ne!(
10673 runner.state.status,
10674 RunStatus::Blocked,
10675 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
10676 runner.state.status
10677 );
10678
10679 held.release();
10680 }
10681
10682 #[tokio::test]
10683 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
10684 crate::run::pin_test_home();
10685 let tmp = tempfile::tempdir().expect("tempdir");
10686 let repo = tmp.path().join("repo");
10687 std::fs::create_dir_all(&repo).unwrap();
10688 init_repo(&repo);
10689
10690 let mut config = Config::default();
10691 config.merge.mode = MergeMode::Pr;
10692 config.graph.land = true;
10693 config.graph.land_approval = false;
10694
10695 let mut state = RunState::new(
10696 repo.clone(),
10697 "main".to_owned(),
10698 "deadbeef".to_owned(),
10699 "task".to_owned(),
10700 config,
10701 );
10702 state.candidates = vec![Candidate {
10703 index: 0,
10704 label: 'A',
10705 agent: "alpha".to_owned(),
10706 branch: "does-not-exist".to_owned(),
10707 worktree: repo.clone(),
10708 summary: String::new(),
10709 stat: String::new(),
10710 files: 0,
10711 commits: 0,
10712 empty: false,
10713 failed: None,
10714 verified_noop: None,
10715 duration_ms: 0,
10716 folded: false,
10717 }];
10718 state.tally = Some(Tally {
10719 first_choice: BTreeMap::from([('A', 1)]),
10720 borda: BTreeMap::new(),
10721 winner: 'A',
10722 rankings: 1,
10723 unanimous_initial: true,
10724 deliberated: false,
10725 changed_votes: 0,
10726 unanimous_final: true,
10727 tie_break: None,
10728 judges: 0,
10729 present: 0,
10730 quorum: 0,
10731 met_quorum: true,
10732 uncontested: Some("only candidate A produced a change".to_owned()),
10733 });
10734 state.reviews = vec![ReviewRound {
10735 round: 1,
10736 head: "deadbeef".to_owned(),
10737 verified_head: None,
10738 verified_at: None,
10739 reviews: Vec::new(),
10740 e2e: Vec::new(),
10741 fix: None,
10742 blocking: 0,
10743 answered: 0,
10744 expected: 0,
10745 clean: true,
10746 verify_retried: false,
10747 e2e_deferred: false,
10748 e2e_defer_reason: None,
10749 progressed: false,
10750 vote_split: false,
10751 reconsideration: Vec::new(),
10752 verdict: None,
10753 }];
10754 state.gate = vec![CommandOutcome {
10755 command: "test".to_owned(),
10756 code: Some(0),
10757 output_tail: String::new(),
10758 duration_ms: 0,
10759 resource_blocked: false,
10760 }];
10761 state.gate_ran = true;
10762 state.status = RunStatus::Landing;
10766 state.merge = Some(MergeOutcome {
10767 mode: MergeMode::Pr,
10768 ok: true,
10769 detail: "https://example.invalid/x/y/pull/1".to_owned(),
10770 empty: false,
10771 });
10772
10773 ask_test_home();
10777 let store = ask::Questions::open();
10778 let q = ask_open_question(&store, &state.id);
10779
10780 let mut runner = Runner {
10781 state,
10782 roles: ResolvedRoles {
10783 implementers: Vec::new(),
10784 judges: Vec::new(),
10785 reviewers: Vec::new(),
10786 fixer: None,
10787 conductor: conductor(),
10788 implementer_roster: Vec::new(),
10789 judge_roster: Vec::new(),
10790 reviewer_roster: Vec::new(),
10791 },
10792 sem: Arc::new(Semaphore::new(1)),
10793 pause: Pause::new(),
10794 interrupt: Pause::new(),
10795 };
10796
10797 runner.execute().await.expect("execute");
10802
10803 assert_eq!(
10804 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10805 Some("https://example.invalid/x/y/pull/1"),
10806 "reentry must not push again or open a second pull request over the \
10807 one `land` is already watching"
10808 );
10809 assert_ne!(
10810 runner.state.status,
10811 RunStatus::Landing,
10812 "land could not actually reach the fake pull request, so it must \
10813 have given up rather than left the run silently parked forever"
10814 );
10815 assert_eq!(runner.state.status, RunStatus::Blocked);
10819 assert!(
10820 store.get(&q.id).unwrap().status.open(),
10821 "Blocked is still alive; settle_questions must have been a no-op here"
10822 );
10823 }
10824
10825 fn state_with_round(round: ReviewRound) -> RunState {
10826 let mut s = RunState::new(
10827 PathBuf::from("/repo"),
10828 "main".to_owned(),
10829 "abc1234".to_owned(),
10830 "add retries".to_owned(),
10831 Config::default(),
10832 );
10833 s.reviews = vec![round];
10834 s
10835 }
10836
10837 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
10838 crate::verdict::Finding {
10839 id: id.to_owned(),
10840 severity,
10841 file: None,
10842 line: None,
10843 title: title.to_owned(),
10844 detail: String::new(),
10845 }
10846 }
10847
10848 #[test]
10849 fn pr_body_names_open_findings_and_declined_ones() {
10850 let round = ReviewRound {
10851 round: 2,
10852 head: "deadbee".to_owned(),
10853 verified_head: None,
10854 verified_at: None,
10855 reviews: vec![ReviewRecord {
10856 attempts: 0,
10857 reviewer: 1,
10858 agent: "alpha".to_owned(),
10859 summary: String::new(),
10860 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
10861 vote: None,
10862 failed: None,
10863 duration_ms: 0,
10864 }],
10865 e2e: vec![CommandOutcome {
10866 command: "cargo test".to_owned(),
10867 code: Some(0),
10868 output_tail: String::new(),
10869 duration_ms: 0,
10870 resource_blocked: false,
10871 }],
10872 verify_retried: false,
10873 e2e_deferred: false,
10874 e2e_defer_reason: None,
10875 fix: Some(FixRecord {
10876 agent: "alpha".to_owned(),
10877 addressed: Vec::new(),
10878 rejected: vec![crate::verdict::Rejection {
10879 id: "R1-1-1".to_owned(),
10880 why: "not reachable from any caller".to_owned(),
10881 }],
10882 notes: String::new(),
10883 committed: true,
10884 failed: None,
10885 duration_ms: 0,
10886 continuation: None,
10887 }),
10888 blocking: 0,
10889 answered: 1,
10890 expected: 1,
10891 clean: false,
10892 progressed: true,
10893 vote_split: false,
10894 reconsideration: Vec::new(),
10895 verdict: None,
10896 };
10897 let state = state_with_round(round);
10898 let body = pr_message(&state, 'A').body;
10899
10900 assert!(body.contains("add retries"), "the task must still be there");
10901 assert!(body.contains("R2-1-1"), "{body}");
10902 assert!(body.contains("unused import"), "{body}");
10903 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
10904 assert!(
10905 body.contains("not reachable from any caller"),
10906 "the reason it was declined: {body}"
10907 );
10908 }
10909
10910 #[test]
10911 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
10912 let round = ReviewRound {
10913 round: 1,
10914 head: "deadbee".to_owned(),
10915 verified_head: None,
10916 verified_at: None,
10917 reviews: vec![ReviewRecord {
10918 attempts: 0,
10919 reviewer: 1,
10920 agent: "alpha".to_owned(),
10921 summary: String::new(),
10922 findings: Vec::new(),
10923 vote: None,
10924 failed: None,
10925 duration_ms: 0,
10926 }],
10927 e2e: Vec::new(),
10928 verify_retried: false,
10929 e2e_deferred: false,
10930 e2e_defer_reason: None,
10931 fix: None,
10932 blocking: 0,
10933 answered: 1,
10934 expected: 1,
10935 clean: true,
10936 progressed: false,
10937 vote_split: false,
10938 reconsideration: Vec::new(),
10939 verdict: None,
10940 };
10941 let state = state_with_round(round);
10942 let body = pr_message(&state, 'A').body;
10943 assert!(!body.contains("Open review findings"), "{body}");
10944 assert!(!body.contains("Declined"), "{body}");
10945 }
10946
10947 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
10948 let mut state = RunState::new(
10949 PathBuf::from("/repo"),
10950 "main".to_owned(),
10951 "abc1234".to_owned(),
10952 instruction.to_owned(),
10953 Config::default(),
10954 );
10955 state.candidates.push(Candidate {
10956 index: 0,
10957 label: 'A',
10958 agent: "alpha".to_owned(),
10959 branch: "magi/x/A".to_owned(),
10960 worktree: PathBuf::from("/wt"),
10961 summary: summary.to_owned(),
10962 stat: String::new(),
10963 files: 1,
10964 commits: 1,
10965 empty: false,
10966 failed: None,
10967 verified_noop: None,
10968 folded: false,
10969 duration_ms: 0,
10970 });
10971 state
10972 }
10973
10974 fn review_state(subjects: &[&str]) -> RunState {
10975 let mut state = state_with_summary(
10976 "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",
10977 "",
10978 );
10979 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
10980 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
10981 state
10982 }
10983
10984 #[tokio::test]
10987 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
10988 ask_test_home();
10989 let tmp = tempfile::tempdir().unwrap();
10990 let repo = tmp.path().join("repo");
10991 std::fs::create_dir_all(&repo).unwrap();
10992 init_repo(&repo);
10993 let origin = tmp.path().join("origin.git");
10994 let g = |dir: &Path, args: &[&str]| {
10995 let out = std::process::Command::new("git")
10996 .args(args)
10997 .current_dir(dir)
10998 .quiet()
10999 .output()
11000 .expect("spawn git");
11001 assert!(
11002 out.status.success(),
11003 "git {args:?}: {}",
11004 String::from_utf8_lossy(&out.stderr)
11005 );
11006 };
11007 g(
11008 tmp.path(),
11009 &[
11010 "clone",
11011 "--bare",
11012 "-q",
11013 repo.to_str().unwrap(),
11014 origin.to_str().unwrap(),
11015 ],
11016 );
11017 g(
11018 &repo,
11019 &["remote", "add", "origin", origin.to_str().unwrap()],
11020 );
11021 let c1 = git::rev_parse(&repo, "main").await.unwrap();
11022
11023 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11025 g(&repo, &["add", "-A"]);
11026 g(
11027 &repo,
11028 &["commit", "-q", "-m", "chore(deps): update a crate"],
11029 );
11030 g(&repo, &["push", "-q", "origin", "main"]);
11031 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11032 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11033 g(&repo, &["add", "-A"]);
11034 g(
11035 &repo,
11036 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11037 );
11038 g(&repo, &["checkout", "-q", "main"]);
11039
11040 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11041 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11042 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11044 assert_eq!(fallback, c1);
11045
11046 let mut state = review_state(&[
11047 "chore(deps): update a crate",
11048 "fix(daemon): apply a chosen action",
11049 ]);
11050 state.repo = repo.clone();
11051 state.base_branch = "main".to_owned();
11052 state.base_commit = c1;
11053 refresh_reviewed_commits(&mut state, "feat/own").await;
11054 assert_eq!(
11055 state.reviewed_commits,
11056 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11057 );
11058 assert_eq!(
11059 review_title(&state).as_deref(),
11060 Some("fix(daemon): apply a chosen action")
11061 );
11062 assert_eq!(
11063 leaked_subjects(&state, "feat/own").await,
11064 Some(vec!["chore(deps): update a crate".to_owned()])
11065 );
11066 state.config.merge.remote = "nowhere".to_owned();
11069 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11070 }
11071
11072 #[test]
11073 fn pr_message_review_single_commit_uses_its_subject() {
11074 let state = review_state(&["feat(nats): per-role user"]);
11075 let m = pr_message(&state, 'A');
11076 assert_eq!(m.title, "feat(nats): per-role user");
11077 assert!(!m.body.contains("Review the work already"), "{}", m.body);
11078 assert!(m.body.contains("## Commits under review"), "{}", m.body);
11079 }
11080
11081 #[test]
11082 fn pr_message_review_multi_commit_takes_the_oldest() {
11083 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11084 let m = pr_message(&state, 'A');
11085 assert_eq!(m.title, "feat: the change");
11086 for s in ["feat: the change", "fix: typo", "fix: again"] {
11087 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11088 }
11089 }
11090
11091 #[test]
11092 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11093 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11094 let mut state = review_state(&[first, "fix: later fixup"]);
11095 state.candidates[0].branch = "機能/ログイン".to_owned();
11096 let m = pr_message(&state, 'A');
11097 assert!(
11098 m.title.starts_with("chore: land candidate A of run"),
11099 "{}",
11100 m.title
11101 );
11102 }
11103 }
11104
11105 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11106 BranchFacts {
11107 commits: commits
11108 .iter()
11109 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11110 .collect(),
11111 stat: stat.to_owned(),
11112 }
11113 }
11114
11115 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11116 let mut r = review_round(true, 0, 1, 1, true, true);
11117 r.round = round;
11118 r.fix = notes.map(|n| FixRecord {
11119 agent: "fixer".to_owned(),
11120 addressed: Vec::new(),
11121 rejected: Vec::new(),
11122 notes: n.to_owned(),
11123 committed: true,
11124 failed: None,
11125 duration_ms: 0,
11126 continuation: None,
11127 });
11128 r
11129 }
11130
11131 #[test]
11132 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11133 let state = review_state(&["ignored"]);
11134 let f = facts(
11135 &[
11136 (
11137 "fix(login): resolve PATH on macOS",
11138 "Login shells skip rc files.",
11139 ),
11140 ("fix: address review", ""),
11141 ],
11142 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11143 );
11144 let m = pr_message_with(&state, 'A', Some(&f));
11145 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11146 assert!(
11147 m.body
11148 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
11149 "{}",
11150 m.body
11151 );
11152 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11153 assert!(m.body.contains("## Diff stat"), "{}", m.body);
11154 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11155 for banned in [
11156 "Review the work already",
11157 "no task statement",
11158 "Original task",
11159 ] {
11160 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11161 }
11162 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11163 }
11164
11165 #[test]
11166 fn pr_message_review_truncates_a_huge_first_commit_body() {
11167 let state = review_state(&["ignored"]);
11168 let f = facts(
11169 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11170 "s",
11171 );
11172 let m = pr_message_with(&state, 'A', Some(&f));
11173 assert!(m.body.len() < 30_000, "{}", m.body.len());
11174 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11175 assert!(
11176 m.body.contains("- ... 1 more commit(s)"),
11177 "{}",
11178 m.body.len()
11179 );
11180 assert!(m.body.ends_with("magi:candidate-a\n"));
11181 }
11182
11183 #[test]
11184 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11185 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11186 assert_eq!(m.title, "feat: x");
11187 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11188 assert!(!m.body.contains("Diff stat"), "{}", m.body);
11189 assert!(!m.body.contains("no task statement"), "{}", m.body);
11190 }
11191
11192 #[test]
11193 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11194 let mut state = review_state(&["日本語の件名"]);
11195 state.candidates[0].branch = "fix/macos-login-path".to_owned();
11196 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11197 state.candidates[0].branch = "機能/ログイン".to_owned();
11198 assert!(
11199 pr_message(&state, 'A')
11200 .title
11201 .starts_with("chore: land candidate A")
11202 );
11203 }
11204
11205 #[test]
11206 fn pr_message_review_fixes_survive_a_clean_final_round() {
11207 let mut state = review_state(&["feat: x"]);
11208 state.reviews = vec![
11209 round_with_notes(1, Some("handled the PATH case")),
11210 round_with_notes(2, None),
11211 ];
11212 let body = pr_message(&state, 'A').body;
11213 assert!(
11214 body.contains("## Review fixes\n\nhandled the PATH case\n"),
11215 "{body}"
11216 );
11217 assert!(!body.contains("### Round"), "{body}");
11218
11219 state.reviews = vec![
11220 round_with_notes(1, Some("first fix")),
11221 round_with_notes(2, Some("")),
11222 round_with_notes(3, Some("second fix")),
11223 round_with_notes(4, None),
11224 ];
11225 let body = pr_message(&state, 'A').body;
11226 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11227 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11228 assert!(!body.contains("### Round 2"), "{body}");
11229 }
11230
11231 #[test]
11232 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11233 let state = state_with_summary(
11234 "add retries to the client",
11235 "TITLE: feat: retries\n\nDid it.",
11236 );
11237 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11238 assert_eq!(m.title, "feat: retries");
11239 assert!(
11240 m.body.contains("<summary>Original task</summary>"),
11241 "{}",
11242 m.body
11243 );
11244 assert!(!m.body.contains("Commits under review"), "{}", m.body);
11245 assert!(
11246 m.body
11247 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11248 "{}",
11249 m.body
11250 );
11251 }
11252
11253 #[test]
11254 fn pr_message_review_bounds_a_long_english_subject() {
11255 let long = format!("feat: {}", "word ".repeat(100));
11256 let m = pr_message(&review_state(&[&long]), 'A');
11257 assert!(m.title.starts_with("feat: word"), "{}", m.title);
11258 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11259 }
11260
11261 #[test]
11262 fn pr_message_implementation_run_is_unchanged_by_review_support() {
11263 let state = state_with_summary("add retries\n\ndetails", "- did some things");
11264 let m = pr_message(&state, 'A');
11265 assert_eq!(m.title, "add retries");
11266 assert!(m.body.contains("<summary>Original task</summary>"));
11267 assert!(!m.body.contains("Commits under review"));
11268 assert_eq!(landing_subject_source(&state), state.instruction);
11269 }
11270
11271 #[test]
11272 fn review_run_squash_subject_is_the_change_not_the_prompt() {
11273 let state = review_state(&["feat: the change", "fix: typo"]);
11274 let source = landing_subject_source(&state);
11275 assert_eq!(land::merge_subject("", &source), "feat: the change");
11276 assert_eq!(
11277 land::merge_subject("magi: candidate A (uncommitted work)", &source),
11278 "feat: the change"
11279 );
11280 assert_eq!(
11282 land::merge_subject("feat: renamed by hand", &source),
11283 "feat: renamed by hand"
11284 );
11285 let blank = review_state(&["日本語"]);
11286 assert!(
11287 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11288 );
11289 }
11290
11291 #[test]
11292 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11293 let state = review_state(&["feat: the change"]);
11294 let source = landing_subject_source(&state);
11295 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11296 assert_eq!(
11297 land::merge_subject(landing_title(&state, old), &source),
11298 "feat: the change"
11299 );
11300 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11301 let task = state_with_summary("add retries", "");
11302 assert_eq!(landing_title(&task, old), old);
11303 }
11304
11305 #[test]
11306 fn pr_message_describes_the_change_not_the_task() {
11307 let state = state_with_summary(
11308 "今回やってほしいこと: results projector を直す",
11309 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11310 );
11311 let m = pr_message(&state, 'A');
11312 assert_eq!(m.title, "fix(web): batch the runs list reads");
11313 assert!(
11314 m.body.starts_with("## Summary\n\n- reads run.json once"),
11315 "{}",
11316 m.body
11317 );
11318 assert!(!m.body.contains("TITLE:"), "{}", m.body);
11319 let task_at = m.body.find("今回やってほしいこと").unwrap();
11320 let details_at = m.body.find("<details>").unwrap();
11321 assert!(
11322 details_at < task_at,
11323 "the task lives inside <details>: {}",
11324 m.body
11325 );
11326 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11327 assert!(m.body.contains("magi:candidate-a"));
11328 }
11329
11330 #[test]
11331 fn pr_message_falls_back_to_the_task_without_a_title_line() {
11332 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11333 let m = pr_message(&state, 'A');
11334 assert_eq!(m.title, "add retries");
11335 assert!(
11336 m.body.contains("## Summary\n\n- did some things"),
11337 "{}",
11338 m.body
11339 );
11340
11341 let none = RunState::new(
11342 PathBuf::from("/repo"),
11343 "main".to_owned(),
11344 "abc1234".to_owned(),
11345 "add retries".to_owned(),
11346 Config::default(),
11347 );
11348 let m = pr_message(&none, 'A');
11349 assert_eq!(m.title, "add retries");
11350 assert!(!m.body.contains("## Summary"), "{}", m.body);
11351 }
11352
11353 #[test]
11354 fn pr_message_refuses_the_candidate_commit_subject() {
11355 for bad in [
11356 "TITLE: magi: candidate A (uncommitted work)",
11357 "TITLE: chore: stuff (uncommitted work)",
11358 "TITLE: ",
11359 ] {
11360 let state = state_with_summary("add retries", bad);
11361 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
11362 }
11363 }
11364
11365 #[test]
11366 fn pr_message_bounds_a_very_long_task_and_title() {
11367 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
11368 let state = state_with_summary(&long, "- nothing");
11369 let m = pr_message(&state, 'A');
11370 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11371 assert!(!m.title.contains('\n'));
11372
11373 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
11374 let m = pr_message(&state, 'A');
11375 assert!(m.title.starts_with("feat: "));
11376 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11377 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
11378 }
11379
11380 fn long_title_of(instruction: &str) -> String {
11381 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
11382 }
11383
11384 #[test]
11385 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
11386 let first = "Make the landing path keep a readable title for long tasks";
11387 let line = format!(
11388 "{first}. {}",
11389 "And then keep going with more words ".repeat(20)
11390 );
11391 let t = long_title_of(&line);
11392 assert_eq!(t, first);
11393 assert!(!t.starts_with("chore: land"));
11394 }
11395
11396 #[test]
11397 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
11398 let line = "word ".repeat(200);
11399 let t = long_title_of(&line);
11400 assert!(t.ends_with("word..."), "{t}");
11401 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
11402 }
11403
11404 #[test]
11405 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
11406 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
11407 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
11408 }
11409 }
11410
11411 #[test]
11412 fn pr_message_title_limit_is_exact() {
11413 let at = "a".repeat(PR_TITLE_MAX);
11414 assert_eq!(long_title_of(&at), at);
11415 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
11416 assert!(over.ends_with("..."), "{over}");
11417 assert_eq!(over.chars().count(), PR_TITLE_MAX);
11418 }
11419
11420 #[test]
11421 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
11422 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
11423 let t = long_title_of(&line);
11424 assert!(t.ends_with("..."), "{t}");
11425 assert!(t.is_ascii(), "{t}");
11426 }
11427
11428 #[test]
11429 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
11430 let line = format!(
11431 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
11432 "plus more filler words ".repeat(20)
11433 );
11434 let t = long_title_of(&line);
11435 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
11436 }
11437
11438 #[test]
11439 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
11440 let line = format!("\n\n# {}", "title words ".repeat(40));
11441 let state = state_with_summary(&line, "- nothing");
11442 let m = pr_message(&state, 'A');
11443 assert!(m.title.starts_with("title words"), "{}", m.title);
11444 assert_eq!(
11445 land::merge_subject(&m.title, &landing_subject_source(&state)),
11446 m.title
11447 );
11448 assert_eq!(
11450 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
11451 "feat: renamed by hand"
11452 );
11453 }
11454
11455 #[test]
11456 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
11457 let mut state = state_with_summary(
11461 "add retries",
11462 "TITLE: fix(web): batch reads\n- reads run.json once",
11463 );
11464 state.config.graph.language = "ja".to_owned();
11465 let m = pr_message(&state, 'A');
11466 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
11467
11468 let task = "今回やってほしいこと: results projector を直す";
11471 let mut state = state_with_summary(task, "- no title line");
11472 state.config.graph.language = "ja".to_owned();
11473 let m = pr_message(&state, 'A');
11474 assert_eq!(
11475 m.title,
11476 format!("chore: land candidate A of run {}", state.id)
11477 );
11478 assert!(
11479 m.body.contains(&format!(
11480 "<summary>Original task</summary>\n\n{task}\n\n</details>"
11481 )),
11482 "{}",
11483 m.body
11484 );
11485 }
11486
11487 #[test]
11488 fn pr_message_scrubs_home_paths_and_addresses() {
11489 let state = state_with_summary(
11490 "fix it in /Users/someone/src/x",
11491 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
11492 );
11493 let m = pr_message(&state, 'A');
11494 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
11495 assert!(!m.body.contains(leak), "{}", m.body);
11496 }
11497 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
11498 }
11499
11500 #[test]
11501 fn pr_message_survives_a_task_that_closes_details() {
11502 let state = state_with_summary("a </details> b", "TITLE: fix: x");
11503 let m = pr_message(&state, 'A');
11504 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
11505 }
11506
11507 #[test]
11508 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
11509 let cmd = manual_merge_command(
11510 MergeStyle::Squash,
11511 Path::new("/repo"),
11512 "b",
11513 "fix: \"quoted\" $(x) `y`\n\nbody",
11514 );
11515 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
11516 }
11517
11518 #[test]
11519 fn manual_merge_command_matches_the_configured_style() {
11520 let repo = Path::new("/repo");
11521 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
11522
11523 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
11524 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
11525
11526 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
11527 assert_eq!(
11528 squash,
11529 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
11530 \"Merge magi run 0832 (candidate A)\""
11531 );
11532
11533 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
11534 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
11535 }
11536
11537 #[test]
11538 fn a_nudge_gets_a_quarter_of_the_budget() {
11539 assert_eq!(retry_budget(secs(1200), true), secs(300));
11541 assert_eq!(retry_budget(secs(3600), true), secs(900));
11542 }
11543
11544 #[test]
11545 fn a_resent_prompt_keeps_the_whole_budget() {
11546 assert_eq!(retry_budget(secs(1200), false), secs(1200));
11549 assert_eq!(retry_budget(secs(60), false), secs(60));
11550 }
11551
11552 #[test]
11553 fn the_floor_never_exceeds_the_original_budget() {
11554 assert_eq!(retry_budget(secs(60), true), secs(60));
11558 assert_eq!(retry_budget(secs(480), true), secs(120));
11559 assert_eq!(retry_budget(secs(0), true), secs(0));
11560 }
11561
11562 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
11563 agent::CommandEvidence {
11564 id: "item1".to_owned(),
11565 description: "cargo test".to_owned(),
11566 exit_code,
11567 result_summary: String::new(),
11568 source: "codex".to_owned(),
11569 }
11570 }
11571
11572 #[test]
11573 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
11574 assert!(!has_unconfirmed_command(&[]));
11578 }
11579
11580 #[test]
11581 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
11582 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
11586 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
11587 assert!(!has_unconfirmed_command(&[
11588 evidence(Some(0)),
11589 evidence(Some(101))
11590 ]));
11591 }
11592
11593 #[test]
11594 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
11595 assert!(has_unconfirmed_command(&[
11596 evidence(Some(0)),
11597 evidence(None)
11598 ]));
11599 }
11600
11601 #[test]
11602 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
11603 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11604 assert_eq!(
11605 verified_noop_claim(true, &[], text).as_deref(),
11606 Some("already fixed by b32cfc4, on main.")
11607 );
11608 }
11609
11610 #[test]
11611 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
11612 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11615 assert!(verified_noop_claim(false, &[], text).is_none());
11616 }
11617
11618 #[test]
11619 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
11620 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11621 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
11622 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
11624 }
11625
11626 #[test]
11627 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
11628 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
11629 }
11630
11631 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
11634 runner.state.candidates = shape
11635 .iter()
11636 .enumerate()
11637 .map(|(i, &(empty, verified))| Candidate {
11638 index: i,
11639 label: (b'A' + i as u8) as char,
11640 agent: "sonnet".to_owned(),
11641 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
11642 worktree: PathBuf::from(format!("/wt/{i}")),
11643 summary: String::new(),
11644 stat: String::new(),
11645 files: 0,
11646 commits: 0,
11647 empty,
11648 failed: None,
11649 verified_noop: verified.map(str::to_owned),
11650 duration_ms: 0,
11651 folded: false,
11652 })
11653 .collect();
11654 }
11655
11656 #[test]
11657 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
11658 ask_test_home();
11659 let mut runner = runner_at(RunStatus::Implementing);
11660 set_candidates(
11661 &mut runner,
11662 &[
11663 (true, Some("already on main at b32cfc4")),
11664 (true, Some("same fix, see the existing test")),
11665 ],
11666 );
11667
11668 runner
11669 .after_implement()
11670 .expect("a verified no-op is not an error");
11671
11672 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
11673 }
11674
11675 #[test]
11676 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
11677 ask_test_home();
11678 let mut runner = runner_at(RunStatus::Implementing);
11679 set_candidates(
11683 &mut runner,
11684 &[(true, Some("already on main at b32cfc4")), (true, None)],
11685 );
11686
11687 let err = runner
11688 .after_implement()
11689 .expect_err("an unverified empty candidate must still fail the run");
11690
11691 assert!(
11692 err.to_string().contains("no candidate produced a change"),
11693 "{err}"
11694 );
11695 assert_eq!(runner.state.status, RunStatus::Failed);
11696 }
11697
11698 #[test]
11699 fn after_implement_still_fails_an_ordinary_all_empty_run() {
11700 ask_test_home();
11701 let mut runner = runner_at(RunStatus::Implementing);
11702 set_candidates(&mut runner, &[(true, None), (true, None)]);
11703
11704 let err = runner
11705 .after_implement()
11706 .expect_err("no candidate declared anything; this is an ordinary failure");
11707
11708 assert!(
11709 err.to_string().contains("no candidate produced a change"),
11710 "{err}"
11711 );
11712 assert_eq!(runner.state.status, RunStatus::Failed);
11713 }
11714}